Skip to content

OpenLyl/Water

Repository files navigation

Water

Swift5 Platform chat on Discord

English | 简体中文

Water - help you to progressively write functional SwiftUI.

func CounterView() -> some View {
    let count = def(value: 0)

    return View {
        Text("\(count.value)")
        HStack {
            Button("+1") {
                count.value += 1
            }
            Button("-1") {
                count.value -= 1
            }
        }
    }
}


Why use Water?

As we all know, SwiftUI is a data-driven responsive UI framework. Apple provides a lot of state management tools, such as: @State@StateObject@Binding@Observable ..., but these tools can be very confusing for a newbie to SwiftUI, what tool to use when exactly? when master all these tools, switch between them also has a big const. finally, when the project gets complex, the screen full of @ and $ symbols make the code difficult to maintain and read.

Now, let's see what @ means in Swift:

  • Attribute: @main@objc, @autoclosure
  • PropertyWrapper: @State, @StateObject
  • Macro: @Observable

For developers, all those @ usages will place a heavy burden on the development mind.

So I am trying to develop this library - Water.

Of course, Water not only solves the above problems, but more importantly guides you through a progressive approach to writing SwiftUI code that will help you step-by-step towards your own standalone project.

Water design for the following purposes:

  • Clear: not require confusing @ symbols
  • Clean: focus on code logic rather than code style
  • Composable: reuse your code use Composable (support MVVM, not recommend)
  • Freedom: not constrain the way you write code (support Redux style, not recommend)
  • Maintainable: easy and visual testing the state logic

Installation

Xcode

From Xcode menu: File > Add Packages...:

https://github.com/OpenLyl/Water

Swift Package Manager

Add the Package url to Package.swift, finally your Package.swift manifest should like below:

let package = Package(
  name: "MyApp",
  dependencies: [
    .package(url: "https://github.com/OpenLyl/Water", .branch("main")),
  ],
  targets: [
    .target(name: "MyApp", dependencies: [
      .product(name: "Water", package: "Water"),
    ]),
  ]
)

Cocoapods

First, add the following entry in your Podfile:

pod 'Water', :git => 'https://github.com/OpenLyl/Water.git', :branch => 'main'

Then run pod install.

Import

Finally, don't forget to import the framework with import Water.

Usage

When using Water, you only need to consider whether your state is a valueobject or an array, define state is like defining variable, so simple.

define value

use def(value: ) to define a value state, because the value is wrapped in a box, so use .value to get or set the value:

func UserView() -> some View {
    let name = def(value: "jack")
    let age = def(value: 20)
    
    return View {
        Text("\(name.value)'s age = \(age.value)")
        Button("change age") {
            age.value += 1
        }
        TextField("input your name", text: name.bindable)
    }
}

define object

use def(object: ) to change object reactivity, support struct and class.

struct User {
    var name: String
    var age: Int
}
func UserView() -> some View {
    let user = def(object: User(name: "jack", age: 20))
    
    return View {
        VStack {
            Text("user.name = \(user.name)")
            Text("user.age = \(user.age)")
            VStack {
                Button("change name") {
                    user.name = "rose"
                }
                Button("change age") {
                    user.age += 1
                }
            }
        }
    }
}

define array

To make array reactivity use def(array: ), as simple as define object.

func NumberListView() -> some View {
    let array = ["1", "2", "3"]
    var nextIndex = array.count + 1

    let items = def(array: array)

    return View {
        VStack {
            LazyVStack {
                ForEach(items, id: \.self) { item in
                    Text("the item = \(item)")
                }
                Text("combined value = \(items.joined(separator: "-|"))")
            }
            HStack(spacing: 16) {
                Button("add item") {
                    nextIndex += 1
                    items.append("\(nextIndex)")
                }
                Button("remove all") {
                    nextIndex = 0
                    items.removeAll()
                }
                Button("clean item") {
                    nextIndex = 3
                    items.replace(with: ["1", "2", "3"])
                }
            }
        }
    }
}

define watch

Water also has the ability to listen for data changes and quickly select useful states by using defWatch.

func WatchEffectView() -> some View {
    let count = def(value: 0)
    let name = def(value: "some name")
    
    defWatchEffect { _ in
        // declare a side effect
        print("trigger watch effect")
    }
    
    defWatch(name) { value, oldValue, _ in
        // when name change do something
        print("name changed = \(value), old name = \(oldValue)")
    }
    
    return View {
        Text("the count = \(count.value)")
        Button("click me change count") {
            count.value += 1
        }
        Text("the name = \(name.value)")
        TextField("name", text: name.bindable)
    }
}

define computed

In most cases, you can use Swift native computed property directly to pick the defined states.

let user = def(object: User(name: "hello", age: 18))

var displayName: String {
    "name is \(user.name)"
}

var displayAge: String {
    "\(user.age) years old"
}

outside of this,Water also provide the cacheable computed property, when there are complex data processing, use defComputed.

func FilterNumbersView() -> some View {
    let showEven = def(value: false)
    let items = def(array: [1, 2, 3, 4, 5, 6])
    
    let evenNumbers = defComputed {
        items.filter { !showEven.value || $0 % 2 == 0}
    }
    
    return View {
        VStack {
            Toggle(isOn: showEven.bindable) {
                Text("Only show even numbers")
            }
            Button("dynamic insert num") {
                let newNumbers = [7, 8, 9, 10]
                items.append(contentsOf: newNumbers)
            }
        }
        .padding(.horizontal, 15)
        List(evenNumbers.value, id: \.self) { num in
            Text("the num = \(num)")
        }
    }
}

nested state

under writing

Composables

Once all the states become reactive, use composable way to extract the data logic is so natural.

useReducer

useReducer allow you code SwiftUI in Redux style, very similar to TCA.

struct CountState {
    var count: Int = 0
}

enum CountAction {
    case increase
    case decrease
}

func countReducer(state: inout CountState, action: CountAction) {
    switch action {
    case .increase:
        state.count += 1
    case .decrease:
        state.count -= 1
    }
}

func ReducerCounterView() -> some View {
    let (useCountState, dispatch) = useReducer(CountState(), countReducer)

    return View {
        Text("the count = \(useCountState().count)")
        HStack {
            Button("+1") {
                dispatch(.increase)
            }
            Button("-1") {
                dispatch(.decrease)
            }
        }
    }
}

useStore

useStore will be more powerful than useReducer, it's still under development.

let useCounterStore = defStore("counter") {
    let count = def(value: 0)

    func increment() {
        count.value += 1
    }

    func decrement() {
        count.value -= 1
    }

    return (count, increment, decrement)
}

func StoreCountView() -> some View {
    let store = useCounterStore()

    return View {
        Text("the count = \(store().count)")
        HStack {
            Button("+1") {
                store.increment()
            }
            Button("-1") {
                store.decrement()
            }
        }
    }
}

useFetch

useFetch provides the ability to send http restful requests and final fetch the network result data, now is a simple version, it will be more flexible and powerful in the future.

func UseFetchView() -> some View {
    let (isFetching, error, data) = useFetch(url: "https://httpbin.org/get")
    
    return View {
        VStack {
            Text(isFetching.value ? "is fetching" : "fetch completed")
            if let error = error.value {
                Text("error = \(error.errorDescription ?? "no error")")
            }
            if let result = result.value, let responseString = result.mapString() {
                Text("data is \(responseString)")
            }
        }
    }
}

useFetch also support manual trigger send request.

let (isFetching, result, error, execute) = useFetch({ "http://www.numbersapi.com/\(count.value)" }, immediate: false)

func sendRequest() {
    Task {
        await execute()
    }
}

useAsyncState

useAsyncState provides the ability to use state from existing async context. sometimes, it's more useful than useFetch.

struct Todo: Codable {
    let id: Int
    let todo: String
    let completed: Bool
}

func fetchTodos() async -> [Todo] {
    ...
}

func UseAsyncStateView() -> some View {
    let (state, isLoading) = useAsyncState(fetchTodos, [] as [Todo])
    
    var todos: [Todo] {
        state.value
    }

    return View {
        if isLoading.value {
            Text("loading...")
        } else {
            List(todos, id: \.id) { todo in
                Text(todo.todo)
            }
        }
    }
}

useEnvironment

The following code shows how to get the system environment on demand, it's equivalent to @Environment(\.dismiss) private var dismiss.

func UseEnvironmentView() -> some View {
    let dismiss = useEnvironment(\.dismiss)
    let count = def(value: 0)
    
    return View {
        VStack {
            Text("new value = \(count.value)")
            Button("+1") {
                count.value += 1
            }
            Button("-1") {
                count.value -= 1
            }
            Button("dismiss") {
                dismiss.value?()
            }
        }
    }
    .useEnvironment(\.dismiss)
}

can also use .bindable to keep sync with system bindable environment.

func UseEditModeEnvironmentView() -> some View {
    let name = def(value: "hello word edit mode")
    let editMode = def(value: EditMode.inactive)
    
    return View {
        Form {
            if editMode.value.isEditing == true {
                TextField("Name", text: name.bindable)
            } else {
                Text(name.value)
            }
        }
        .animation(nil, value: editMode.value)
        .toolbar {
            EditButton()
        }
        .environment(\.editMode, editMode.bindable)
    }
}

useRouter

under development

Build your own composable

under writing

Plugins

under development

Middlewares

under development

Integration

use official struct view style

struct CountereView: View {
    let count = def(value: 0)

    var body: some View {
        Water.View { // will change in future
            Text("current count = \(count.value)")
            HStack {
                Button("+") {
                    count.value += 1
                }
                Button("-") {
                    count.value -= 1
                }
            }
        }
    }
}

integrate with other SwfitUI views

under writing

Examples

  • UseCases
    • ValueUseCases
    • ReactivityUseCases
    • WatchUseCases
    • ReducerUseCases
    • StoreUseCases
    • ComputedUseCases
    • ComposableUseCases
    • MemoUseCases
    • EnvironmentUseCases
    • NavigationUseCases
    • EffectScopeUseCases
    • UseFetchUseCasesView
  • Todos
  • SwiftUI Essentials
  • Garden (Mastodon client) - under development
  • Other TCA examples - under development

Compare with X

compare with TCA

under writing

Community

If you want to discuss Water or have a question about how to use it to solve a particular problem, you can join the discord channel:

Discord

Contribution

Water is only a basic MVP at this point and is not recommended for online products, there are still some areas that need to be worked on, as follows:

  • need more util functions to handle reactivity system
  • Composables is just getting started, need more logic to handle complex situations
  • add more unit test and improve the test coverage
  • write more use cases with snapshot test
  • write more example apps and tutorials
  • code with more comments
  • performance test

so if you are interested in this project, please join us for something fun!

Thanks

This project is heavily inspired by the following awesome projects.

License

This library is released under the MIT license. See LICENSE for details.