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

最近,Github 基友 ra1028 基于 React Hooks 的思想,開發(fā)了一套 SwiftUI Hooks 并將其開源出來,倉庫地址是 https://github.com/ra1028/SwiftUI-Hooks 。
SwiftUI Hooks 將狀態(tài)和生命周期引入視圖,而不必依賴于類似 @State 或 @ObservedObject 這些僅允許在視圖中使用的元素。它還允許我們通過構(gòu)建由多個鉤子組成的自定義鉤子在視圖之間重用狀態(tài)邏輯。此外,諸如 useEffect 之類的鉤子也解決了 SwiftUI 中缺乏生命周期的問題。
支持的 Hook API
SwiftUI Hooks 的 API 和行為規(guī)范完全基于 React Hooks,所以如果熟悉 React 的話,了解起來會相當(dāng)容易。我們簡單介紹一下幾個主要的 API。
useState
這個 hook 使用 Binding
func useState<State>(_ initialState: State) -> Binding<State>
let count = useState(0) // Binding<Int>
count.wrappedValue = 123
useEffect
這個 hook 會調(diào)用一個副作用函數(shù),該函數(shù)通過 computation 指定。另外,當(dāng)從視圖樹中卸載這個 hook 或再次調(diào)用副作用函數(shù)時,可以取消該函數(shù)。
func useEffect(_ computation: HookComputation, _ effect: @escaping () -> (() -> Void)?)
useEffect(.once) {
print("View is mounted")
return {
print("View is unmounted")
}
}
useLayoutEffect
這個 hook 與 useEffect 相同,但會在調(diào)用 hook 時同步觸發(fā)操作。
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 的本質(zhì)是將值設(shè)置為 current 不會觸發(fā)視圖更新。
func useRef<T>(_ initialValue: T) -> RefObject<T>
let value = useRef("text") // RefObject<String>
value.current = "new text"
useReducer
這個 hook 使用傳遞的 reduce 來計算當(dāng)前狀態(tài),并通過 dispatch 來分發(fā)一個操作以更新狀態(tài)。更改狀態(tài)后全觸發(fā)視圖更新。
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 屬性包裝器的情況下通過視圖樹傳遞的環(huán)境值。
func useEnvironment<Value>(_ keyPath: KeyPath<EnvironmentValues, Value>) -> Value
let colorScheme = useEnvironment(\.colorScheme) // ColorScheme
usePublisher
這個 hook 使用傳遞的發(fā)布者的異步操作的最新狀態(tài)。
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 規(guī)則
為了充分利用 Hooks 的能力,SwiftUI Hooks 也必須遵循與 React 鉤子相同的規(guī)則。
僅在函數(shù)頂層調(diào)用 Hook
不要在條件或循環(huán)內(nèi)調(diào)用 Hook。Hook 的調(diào)用順序很重要,因為 Hook 使用LinkedList 跟蹤其狀態(tài)。
?? 正確的做法
@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 中調(diào)用 Hook
為了保存狀態(tài),必須在 HookScope 內(nèi)調(diào)用鉤子。
符合 HookView 協(xié)議的視圖將自動包含在 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 及測試
構(gòu)建自己的 Hook 可以使將狀態(tài)邏輯提取到可重用的函數(shù)中。
Hook 是可組合的,因為它們是有狀態(tài)的函數(shù)。因此,它們可以與其他鉤子組合在一起以創(chuàng)建自己的自定義 Hook。
在以下示例中,最基本的 useState 和 useEffect 使函數(shù)提供具有指定間隔的當(dāng)前 Date。如果更改了指定的時間間隔,則將調(diào)用 Timer.invalidate(),然后將激活一個新的計時器。
這樣,可以使用 Hooks 將有狀態(tài)邏輯作為函數(shù)提取出來。
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 重構(gòu)前面的 Example 視圖。
struct Example: HookView {
var hookBody: some View {
let time = useTimer(interval: 1)
Text("Now: \(time)")
}
}
這樣更簡單易讀,且代碼更少!
當(dāng)然,有狀態(tài)自定義鉤子可以由任意視圖調(diào)用。
如何測試自定義掛鉤
withTemporaryHookScope 這個 API 可以創(chuàng)建一個獨立于 SwiftUI 視圖的臨時 Hook 作用域。在 withTemporaryHookScope 函數(shù)中,可以多次啟動 Hook 作用域,以測試諸如多次評估 SwiftUI 視圖時的狀態(tài)轉(zhuǎn)換。
例如:
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 有一種通過組件樹傳遞數(shù)據(jù)而無需手動傳遞數(shù)據(jù)的方法,這稱為Context。
類似地,SwiftUI 具有實現(xiàn)相同的 EnvironmentValues,但是定義自定義環(huán)境值有點麻煩,因此 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
}
}
}
}
當(dāng)然,可以使用 useContext 代替 Context.Consumer 來檢索提供的值。
@ViewBuilder
var darkModeButton: some View {
let colorScheme = useContext(ColorSchemeContext.self)
Button("Use dark mode") {
colorScheme.wrappedValue = .dark
}
}
系統(tǒng)要求及使用
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"
小結(jié)
SwiftUI Hooks 是 React Hooks 開發(fā)的一套狀態(tài)管理庫,其 API 和行為規(guī)范完全基于React Hooks,所以想了解 SwiftUI Hooks 的能力,也可以參考 React Hooks 的相關(guān)文檔。
