SwiftUI Hooks,教你如何在 SwiftUI 中使用 React Hooks
最近,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
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
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 需要以下支持:
Swift 5.3+
Xcode 12.4.0+
iOS 13.0+
macOS 10.15+
tvOS 13.0+
watchOS 6.0+
安装的话支持 SPM、Cocoapod 和 Carthage 三种方式。
SPM
Repository: https://github.com/ra1028/SwiftUI-Hooks
CocoaPods
pod 'Hooks' :git => 'https://github.com/ra1028/SwiftUI-Hooks.git'
Carthage
github "ra1028/SwiftUI-Hooks"
小结
SwiftUI Hooks 是 React Hooks 开发的一套状态管理库,其 API 和行为规范完全基于React Hooks,所以想了解 SwiftUI Hooks 的能力,也可以参考 React Hooks 的相关文档。