SwiftUI Hooks

SwiftUI 版本的 React Hooks

增强了状态逻辑的可复用性,并为函数式视图赋予状态和生命周期。

📔 API 参考

test release Swift5 Platform license



简介

func timer() -> some View {
    let time = useState(Date())

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

        return {
            timer.invalidate()
        }
    }

    return Text("Time: \(time.wrappedValue)")
}

SwiftUI Hooks 是 React Hooks 的 SwiftUI 实现。它将状态和生命周期引入函数式视图,而无需依赖只能在结构体视图中使用的元素,例如 @State@ObservedObject
它允许你通过构建由多个 hooks 组成的自定义 hooks,在视图之间复用状态逻辑。
此外,诸如 useEffect 之类的 hooks 也解决了 SwiftUI 中缺少生命周期的问题。

SwiftUI Hooks 的 API 和行为规范完全基于 React Hooks,因此你可以利用你的 Web 应用程序知识。

已经有很多关于 React Hooks 的文档,你可以参考它们并了解更多关于 Hooks 的信息。


开始使用

要求

最低版本
Swift 5.6
Xcode 13.3
iOS 13.0
macOS 10.15
tvOS 13.0
watchOS 6.0

安装

该软件包的模块名称为 Hooks。选择以下说明之一进行安装,并将以下 import 语句添加到你的源代码中。

import Hooks

Xcode Package Dependency

从 Xcode 菜单:File > Swift Packages > Add Package Dependency

https://github.com/ra1028/swiftui-hooks

Swift Package Manager

在你的 Package.swift 文件中,首先将以下内容添加到 package 的 dependencies

.package(url: "https://github.com/ra1028/swiftui-hooks"),

然后,将 "Hooks" 作为你目标的依赖项包含进来

.target(name: "<target>", dependencies: [
    .product(name: "Hooks", package: "swiftui-hooks"),
]),

文档


Hooks API

👇 点击以展开描述。

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

一个 hook,用于使用 Binding<State> 包裹当前状态,通过将新状态设置为 wrappedValue 来更新状态。
当状态发生更改时,触发视图更新。

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

Button("Increment") {
    count.wrappedValue += 1
}

如果初始状态是昂贵计算的结果,你可以提供一个闭包来代替。该闭包将在初始渲染期间执行一次。

let count = useState {
    let initialState = expensiveComputation() // Int
    return initialState
}                                             // Binding<Int>

Button("Increment") {
    count.wrappedValue += 1
}
useEffect
func useEffect(_ updateStrategy: HookUpdateStrategy? = nil, _ effect: @escaping () -> (() -> Void)?)

一个 hook,用于使用副作用函数,该函数根据 updateStrategy 指定的策略调用多次。
可选地,当此 hook 被释放或副作用函数再次被调用时,可以取消该函数。
请注意,执行会延迟到其他 hooks 更新之后。

useEffect {
    print("Do side effects")

    return {
        print("Do cleanup")
    }
}
useLayoutEffect
func useLayoutEffect(_ updateStrategy: HookUpdateStrategy? = nil, _ effect: @escaping () -> (() -> Void)?)

一个 hook,用于使用副作用函数,该函数根据 updateStrategy 指定的策略调用多次。
可选地,当此 hook 从视图树中卸载或副作用函数再次被调用时,可以取消该函数。
签名与 useEffect 相同,但它在 hook 被调用时同步触发。

useLayoutEffect {
    print("Do side effects")
    return nil
}
useMemo
func useMemo<Value>(_ updateStrategy: HookUpdateStrategy, _ makeValue: @escaping () -> Value) -> Value

一个 hook,用于使用记忆化的值,该值被保留直到在给定的 updateStrategy 确定的时机更新。

let random = useMemo(.once) {
    Int.random(in: 0...100)
}
useRef
func useRef<T>(_ initialValue: T) -> RefObject<T>

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

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

Button("Save text") {
    value.current = "new text"
}
useReducer
func useReducer<State, Action>(_ reducer: @escaping (State, Action) -> State, initialState: State) -> (state: State, dispatch: (Action) -> Void)

一个 hook,用于使用由传递的 reducer 返回的状态,以及一个 dispatch 函数来发送 action 以更新状态。
当状态发生更改时,触发视图更新。

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)
useAsync
func useAsync<Output>(_ updateStrategy: HookUpdateStrategy, _ operation: @escaping () async -> Output) -> AsyncPhase<Output, Never>
func useAsync<Output>(_ updateStrategy: HookUpdateStrategy, _ operation: @escaping () async throws -> Output) -> AsyncPhase<Output, Error>

一个 hook,用于使用传递的异步操作函数的最新阶段。
该函数将在首次更新时执行,并将根据给定的 updateStrategy 重新执行。

let phase = useAsync(.once) {
    try await URLSession.shared.data(from: url)
}
useAsyncPerform
func useAsyncPerform<Output>(_ operation: @escaping @MainActor () async -> Output) -> (phase: AsyncPhase<Output, Never>, perform: @MainActor () async -> Void)
func useAsyncPerform<Output>(_ operation: @escaping @MainActor () async throws -> Output) -> (phase: AsyncPhase<Output, Error>, perform: @MainActor () async -> Void)

一个 hook,用于使用传递的异步操作的最新阶段,以及一个 perform 函数,用于在任意时机调用它。

let (phase, perform) = useAsyncPerform {
    try await URLSession.shared.data(from: url)
}
usePublisher
func usePublisher<P: Publisher>(_ updateStrategy: HookUpdateStrategy, _ makePublisher: @escaping () -> P) -> AsyncPhase<P.Output, P.Failure>

一个 hook,用于使用传递的 publisher 的异步操作的最新阶段。
publisher 将在首次更新时被订阅,并将根据给定的 updateStrategy 重新订阅。

let phase = usePublisher(.once) {
    URLSession.shared.dataTaskPublisher(for: url)
}
usePublisherSubscribe
func usePublisherSubscribe<P: Publisher>(_ makePublisher: @escaping () -> P) -> (phase: AsyncPhase<P.Output, P.Failure>, subscribe: () -> Void)

一个 hook,用于使用传递的 publisher 的异步操作的最新阶段,以及一个 subscribe 函数,用于在任意时机订阅它。

let (phase, subscribe) = usePublisherSubscribe {
    URLSession.shared.dataTaskPublisher(for: url)
}
useEnvironment
func useEnvironment<Value>(_ keyPath: KeyPath<EnvironmentValues, Value>) -> Value

一个 hook,用于使用通过视图树传递的环境值,而无需 @Environment 属性包装器。

let colorScheme = useEnvironment(\.colorScheme)  // ColorScheme
useContext
func useContext<T>(_ context: Context<T>.Type) -> T

一个 hook,用于使用由 Context<T>.Provider 提供的当前 context 值。
目的与使用 Context<T>.Consumer 相同。
有关更多详细信息,请参阅 Context 部分。

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

另请参阅:React Hooks API 参考


Hooks 的规则

为了利用 Hooks 精彩的接口,SwiftUI Hooks 也必须遵循 React hooks 相同的规则。

[免责声明]:这些规则不是 SwiftUI Hooks 特有的技术约束,而是基于 Hooks 本身的设计所必需的。你可以 这里 了解更多关于为 React Hooks 定义的规则。

* 在 -Onone 构建中,如果检测到违反这些规则的行为,它会通过内部健全性检查来断言,以帮助开发人员注意到 hooks 使用中的错误。但是,hooks 也具有 disableHooksRulesAssertion 修饰符,以防你想禁用断言。

仅在函数顶层调用 Hooks

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

🟢 推荐做法

@ViewBuilder
func counterButton() -> some View {
    let count = useState(0)  // 🟢 Uses hook at the top level

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

🔴 避免做法

@ViewBuilder
func counterButton() -> some View {
    if condition {
        let count = useState(0)  // 🔴 Uses hook inside condition.

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

仅从 HookScopeHookView.hookBody 中调用 Hooks

为了保持状态,hooks 必须在 HookScope 内调用。
符合 HookView 协议的视图将自动被 HookScope 包裹。

🟢 推荐做法

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

        Button("You clicked \(count.wrappedValue) times") {
            count.wrappedValue += 1
        }
    }
}
func counterButton() -> some View {
    HookScope {  // 🟢 `HookScope` is used.
        let count = useState(0)

        Button("You clicked \(count.wrappedValue) times") {
            count.wrappedValue += 1
        }
    }
}
struct ContentView: HookView {
    var hookBody: some View {
        counterButton()
    }

    // 🟢 Called from `HookView.hookBody` or `HookScope`.
    @ViewBuilder
    var counterButton: some View {
        let count = useState(0)

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

🔴 避免做法

// 🔴 Neither `HookScope` nor `HookView` is used, and is not called from them.
@ViewBuilder
func counterButton() -> some View {
    let count = useState(0)

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

另请参阅:React Hooks 的规则


构建你自己的 Hooks

构建你自己的 hooks 允许你将状态逻辑提取到可复用的函数中。
Hooks 是可组合的,因为它们充当有状态的函数。因此,它们可以与其他 hooks 组合以创建你自己的自定义 hook。

在以下示例中,最基本的 useStateuseEffect 被用来创建一个函数,该函数以指定的间隔提供当前的 Date。如果指定的间隔发生更改,将调用 Timer.invalidate(),然后激活一个新的 timer。
像这样,有状态的逻辑可以使用 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 重构 README 开头的 Example 视图。

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

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

现在更容易阅读,代码也更少了!
当然,有状态的自定义 hook 可以被任意视图调用。

另请参阅:构建你自己的 React Hooks


如何测试你的自定义 Hooks

到目前为止,我们已经解释了 hooks 应该在 HookScopeHookView 中调用。那么,你创建的自定义 hook 如何进行测试呢?
为了使你的自定义 hooks 的单元测试变得容易,SwiftUI Hooks 提供了一个简单而完整的测试实用程序库。

HookTester 通过模拟给定 hook 的视图行为并管理结果值,使自定义 hooks 的单元测试独立于 UI。

示例

// Your custom hook.
func useCounter() -> (count: Int, increment: () -> Void) {
    let count = useState(0)

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

    return (count: count.wrappedValue, increment: increment)
}
let tester = HookTester {
    useCounter()
}

XCTAssertEqual(tester.value.count, 0)

tester.value.increment()

XCTAssertEqual(tester.value.count, 1)

tester.update()  // Simulates view's update.

XCTAssertEqual(tester.value.count, 1)

Context

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 hook 可以代替 Context.Consumer 来检索提供的值。

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

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

另请参阅:React Context


致谢


许可证

MIT © Ryo Aoyama