SwiftUI Hooks,教你如何在 SwiftUI 中使用 React Hooks

698次阅读  |  发布于3年以前

最近,Github 基友 ra1028 基于 React Hooks 的思想,开发了一套 SwiftUI Hooks 并将其开源出来,仓库地址是 https://github.com/ra1028/SwiftUI-Hooks 。

SwiftUI Hooks 将状态和生命周期引入视图,而不必依赖于类似 @State 或 @ObservedObject 这些仅允许在视图中使用的元素。它还允许我们通过构建由多个钩子组成的自定义钩子在视图之间重用状态逻辑。此外,诸如 useEffect 之类的钩子也解决了 SwiftUI 中缺乏生命周期的问题。

支持的 Hook API

SwiftUI Hooks 的 API 和行为规范完全基于 React Hooks,所以如果熟悉 React 的话,了解起来会相当容易。我们简单介绍一下几个主要的 API。

useState

这个 hook 使用 Binding 包装当前状态,并将新状态设置为 wrapperValue。更改状态后触发视图更新。

func useState<State>(_ initialState: State) -> Binding<State>

let count = useState(0)  // Binding<Int>
count.wrappedValue = 123

useEffect

这个 hook 会调用一个副作用函数,该函数通过 computation 指定。另外,当从视图树中卸载这个 hook 或再次调用副作用函数时,可以取消该函数。

func useEffect(_ computation: HookComputation, _ effect: @escaping () -> (() -> Void)?)

useEffect(.once) {
    print("View is mounted")

    return {
        print("View is unmounted")
    }
}

useLayoutEffect

这个 hook 与 useEffect 相同,但会在调用 hook 时同步触发操作。

func useLayoutEffect(_ computation: HookComputation, _ effect: @escaping () -> (() -> Void)?)

useLayoutEffect(.always) {
    print("View is being evaluated")
    return nil
}

useMemo

这个 hook 会使用保留的记忆值,直到在计算指定的时间重新计算记忆值为止。

func useMemo<Value>(_ computation: HookComputation, _ makeValue: @escaping () -> Value) -> Value

let random = useMemo(.once) {
    Int.random(in: 0...100)
}

useRef

这个 hook 使用可变引用对象来存储任意值的,这个 hook 的本质是将值设置为 current 不会触发视图更新。

func useRef<T>(_ initialValue: T) -> RefObject<T>

let value = useRef("text")  // RefObject<String>
value.current = "new text"

useReducer

这个 hook 使用传递的 reduce 来计算当前状态,并通过 dispatch 来分发一个操作以更新状态。更改状态后全触发视图更新。

func useReducer<State, Action>(_ reducer: @escaping (State, Action) -> State, initialState: State) -> (state: State, dispatch: (Action) -> Void)

enum Action {
    case increment, decrement
}

func reducer(state: Int, action: Action) -> Int {
    switch action {
        case .increment:
            return state + 1

        case .decrement:
            return state - 1
    }
}

let (count, dispatch) = useReducer(reducer, initialState: 0)

useEnvironment

这个 hook 可以在不有 @Environment 属性包装器的情况下通过视图树传递的环境值。

func useEnvironment<Value>(_ keyPath: KeyPath<EnvironmentValues, Value>) -> Value

let colorScheme = useEnvironment(\.colorScheme)  // ColorScheme

usePublisher

这个 hook 使用传递的发布者的异步操作的最新状态。

func usePublisher<P: Publisher>(_ computation: HookComputation, _ makePublisher: @escaping () -> P) -> AsyncStatus<P.Output, P.Failure>

let status = usePublisher(.once) {
    URLSession.shared.dataTaskPublisher(for: url)
}

usePublisherSubscribe

这个 hook 与 usePublisher 相同,并会启动一个 subscribe 来订阅任意事件。

func usePublisherSubscribe<P: Publisher>(_ makePublisher: @escaping () -> P) -> (status: AsyncStatus<P.Output, P.Failure>, subscribe: () -> Void)

let (status, subscribe) = usePublisherSubscribe {
    URLSession.shared.dataTaskPublisher(for: url)
}

useContext

这个 hook 使用 Context.Provider提供的当前上下文值

func useContext<T>(_ context: Context<T>.Type) -> T

let value = useContext(Context<Int>.self)  // Int

Hook 规则

为了充分利用 Hooks 的能力,SwiftUI Hooks 也必须遵循与 React 钩子相同的规则。

仅在函数顶层调用 Hook

不要在条件或循环内调用 Hook。Hook 的调用顺序很重要,因为 Hook 使用LinkedList 跟踪其状态。

正确的做法

@ViewBuilder
var counterButton: some View {
    let count = useState(0)  // Uses hook at the top level

    Button("You clicked \(count.wrappedValue) times") {
        count.wrappedValue += 1
    }
}

错误做法

@ViewBuilder
var counterButton: some View {
    if condition {
        let count = useState(0)  // Uses hook inside condition.

        Button("You clicked \(count.wrappedValue) times") {
            count.wrappedValue += 1
        }
    }
}

仅在 HookScope 或 HookView.hookBody 中调用 Hook

为了保存状态,必须在 HookScope 内调用钩子。

符合 HookView 协议的视图将自动包含在 HookScope 中。

正确的做法

struct ContentView: HookView {  // `HookView` is used.
    var hookBody: some View {
        let count = useState(0)

        Button("You clicked \(count.wrappedValue) times") {
            count.wrappedValue += 1
        }
    }
}
struct ContentView: View {
    var body: some View {
        HookScope {  // `HookScope` is used.
            let count = useState(0)

            Button("You clicked \(count.wrappedValue) times") {
                count.wrappedValue += 1
            }
        }
    }
}

错误做法

struct ContentView: View {
    var body: some View {  // Neither `HookScope` nor `HookView` is used.
        let count = useState(0)

        Button("You clicked \(count.wrappedValue) times") {
            count.wrappedValue += 1
        }
    }
}

自定义 Hook 及测试

构建自己的 Hook 可以使将状态逻辑提取到可重用的函数中。

Hook 是可组合的,因为它们是有状态的函数。因此,它们可以与其他钩子组合在一起以创建自己的自定义 Hook。

在以下示例中,最基本的 useState 和 useEffect 使函数提供具有指定间隔的当前 Date。如果更改了指定的时间间隔,则将调用 Timer.invalidate(),然后将激活一个新的计时器。

这样,可以使用 Hooks 将有状态逻辑作为函数提取出来。

func useTimer(interval: TimeInterval) -> Date {
    let time = useState(Date())

    useEffect(.preserved(by: interval)) {
        let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) {
            time.wrappedValue = $0.fireDate
        }

        return {
            timer.invalidate()
        }
    }

    return time.wrappedValue
}

让我们使用此自定义 Hook 重构前面的 Example 视图。

struct Example: HookView {
    var hookBody: some View {
        let time = useTimer(interval: 1)

        Text("Now: \(time)")
    }
}

这样更简单易读,且代码更少!

当然,有状态自定义钩子可以由任意视图调用。

如何测试自定义挂钩

withTemporaryHookScope 这个 API 可以创建一个独立于 SwiftUI 视图的临时 Hook 作用域。在 withTemporaryHookScope 函数中,可以多次启动 Hook 作用域,以测试诸如多次评估 SwiftUI 视图时的状态转换。

例如:

withTemporaryHookScope { scope in
    scope {
        let count = useState(0)
        count.wrappedValue = 1
    }

    scope {
        let count = useState(0)
        XCTAssertEqual(count.wrappedValue, 1)  // The previous state is preserved.
    }
}

上下文

React 有一种通过组件树传递数据而无需手动传递数据的方法,这称为Context。

类似地,SwiftUI 具有实现相同的 EnvironmentValues,但是定义自定义环境值有点麻烦,因此 SwiftUI Hooks 提供了更加用户友好的 Context API。这是围绕 EnvironmentValues 的简单包装。

typealias ColorSchemeContext = Context<Binding<ColorScheme>>

struct ContentView: HookView {
    var hookBody: some View {
        let colorScheme = useState(ColorScheme.light)

        ColorSchemeContext.Provider(value: colorScheme) {
            darkModeButton
                .background(Color(.systemBackground))
                .colorScheme(colorScheme.wrappedValue)
        }
    }

    var darkModeButton: some View {
        ColorSchemeContext.Consumer { colorScheme in
            Button("Use dark mode") {
                colorScheme.wrappedValue = .dark
            }
        }
    }
}

当然,可以使用 useContext 代替 Context.Consumer 来检索提供的值。

@ViewBuilder
var darkModeButton: some View {
    let colorScheme = useContext(ColorSchemeContext.self)

    Button("Use dark mode") {
        colorScheme.wrappedValue = .dark
    }
}

系统要求及使用

SwiftUI Hooks 需要以下支持:

安装的话支持 SPM、Cocoapod 和 Carthage 三种方式。

Repository: https://github.com/ra1028/SwiftUI-Hooks
pod 'Hooks' :git => 'https://github.com/ra1028/SwiftUI-Hooks.git'
github "ra1028/SwiftUI-Hooks"

小结

SwiftUI Hooks 是 React Hooks 开发的一套状态管理库,其 API 和行为规范完全基于React Hooks,所以想了解 SwiftUI Hooks 的能力,也可以参考 React Hooks 的相关文档。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8