• Stars
    star
    479
  • Rank 91,752 (Top 2 %)
  • Language
    Swift
  • License
    MIT License
  • Created almost 4 years ago
  • Updated over 2 years ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

πŸͺ A SwiftUI implementation of React Hooks. Enhances reusability of stateful logic and gives state and lifecycle to function view.

SwiftUI Hooks

A SwiftUI implementation of React Hooks.

Enhances reusability of stateful logic and gives state and lifecycle to function view.

πŸ“” API Reference

test release Swift5 Platform license



Introduction

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 is a SwiftUI implementation of React Hooks. Brings the state and lifecycle into the function view, without depending on elements that are only allowed to be used in struct views such as @State or @ObservedObject.
It allows you to reuse stateful logic between views by building custom hooks composed with multiple hooks.
Furthermore, hooks such as useEffect also solve the problem of lack of lifecycles in SwiftUI.

The API and behavioral specs of SwiftUI Hooks are entirely based on React Hooks, so you can leverage your knowledge of web applications to your advantage.

There're already a bunch of documentations on React Hooks, so you can refer to it and learn more about Hooks.


Getting Started

Requirements

Minimum Version
Swift 5.6
Xcode 13.3
iOS 13.0
macOS 10.15
tvOS 13.0
watchOS 6.0

Installation

The module name of the package is Hooks. Choose one of the instructions below to install and add the following import statement to your source code.

import Hooks

Xcode Package Dependency

From Xcode menu: File > Swift Packages > Add Package Dependency

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

Swift Package Manager

In your Package.swift file, first add the following to the package dependencies:

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

And then, include "Hooks" as a dependency for your target:

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

Documentation


Hooks API

πŸ‘‡ Click to open the description.

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

A hook to use a Binding<State> wrapping current state to be updated by setting a new state to wrappedValue.
Triggers a view update when the state has been changed.

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

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

If the initial state is the result of an expensive computation, you may provide a closure instead. The closure will be executed once, during the initial render.

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)?)

A hook to use a side effect function that is called the number of times according to the strategy specified with updateStrategy.
Optionally the function can be cancelled when this hook is disposed or when the side-effect function is called again.
Note that the execution is deferred until after ohter hooks have been updated.

useEffect {
    print("Do side effects")

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

A hook to use a side effect function that is called the number of times according to the strategy specified with updateStrategy.
Optionally the function can be cancelled when this hook is unmount from the view tree or when the side-effect function is called again.
The signature is identical to useEffect, but this fires synchronously when the hook is called.

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

A hook to use memoized value preserved until it is updated at the timing determined with given updateStrategy.

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

A hook to use a mutable ref object storing an arbitrary value.
The essential of this hook is that setting a value to current doesn't trigger a view update.

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)

A hook to use the state returned by the passed reducer, and a dispatch function to send actions to update the state.
Triggers a view update when the state has been changed.

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>

A hook to use the most recent phase of asynchronous operation of the passed function.
The function will be performed at the first update and will be re-performed according to the given 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)

A hook to use the most recent phase of the passed asynchronous operation, and a perform function to call the it at arbitrary timing.

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>

A hook to use the most recent phase of asynchronous operation of the passed publisher.
The publisher will be subscribed at the first update and will be re-subscribed according to the given 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)

A hook to use the most recent phase of asynchronous operation of the passed publisher, and a subscribe function to subscribe to it at arbitrary timing.

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

A hook to use environment value passed through the view tree without @Environment property wrapper.

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

A hook to use current context value that is provided by Context<T>.Provider.
The purpose is identical to use Context<T>.Consumer.
See Context section for more details.

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

See also: React Hooks API Reference


Rules of Hooks

In order to take advantage of the wonderful interface of Hooks, the same rules that React hooks has must also be followed by SwiftUI Hooks.

[Disclaimer]: These rules are not technical constraints specific to SwiftUI Hooks, but are necessary based on the design of the Hooks itself. You can see here to know more about the rules defined for React Hooks.

* In -Onone builds, if a violation against this rules is detected, it asserts by an internal sanity check to help the developer notice the mistake in the use of hooks. However, hooks also has disableHooksRulesAssertion modifier in case you want to disable the assertions.

Only Call Hooks at the Function Top Level

Do not call Hooks inside conditions or loops. The order in which hook is called is important since Hooks uses LinkedList to keep track of its state.

🟒 DO

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

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

πŸ”΄ DON'T

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

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

Only Call Hooks from HookScope or HookView.hookBody

In order to preserve the state, hooks must be called inside a HookScope.
A view that conforms to the HookView protocol will automatically be enclosed in a HookScope.

🟒 DO

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
        }
    }
}

πŸ”΄ DON'T

// πŸ”΄ 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
    }
}

See also: Rules of React Hooks


Building Your Own Hooks

Building your own hooks lets you extract stateful logic into reusable functions.
Hooks are composable since they serve as a stateful functions. So, they can be able to compose with other hooks to create your own custom hook.

In the following example, the most basic useState and useEffect are used to make a function provides a current Date with the specified interval. If the specified interval is changed, Timer.invalidate() will be called and then a new timer will be activated.
Like this, the stateful logic can be extracted out as a function using 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
}

Let's refactor the Example view at the beginning of the README using this custom hook.

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

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

It's so much easier to read and less codes!
Of course the stateful custom hook can be called by arbitrary views.

See also: Building Your Own React Hooks


How to Test Your Custom Hooks

So far, we have explained that hooks should be called within HookScope or HookView. Then, how can the custom hook you have created be tested?
To making unit testing of your custom hooks easy, SwiftUI Hooks provides a simple and complete test utility library.

HookTester enables unit testing independent of UI of custom hooks by simulating the behavior on the view of a given hook and managing the result values.

Example:

// 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 has a way to pass data through the component tree without having to pass it down manually, it's called Context.
Similarly, SwiftUI has EnvironmentValues to achieve the same, but defining a custom environment value is a bit of a pain, so SwiftUI Hooks provides Context API that a more user-friendly.
This is a simple wrapper around the 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
            }
        }
    }
}

And of course, there is a useContext hook that can be used instead of Context.Consumer to retrieve the provided value.

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

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

See also: React Context


Acknowledgements


License

MIT Β© Ryo Aoyama


More Repositories

1

DifferenceKit

πŸ’» A fast and flexible O(n) difference algorithm framework for Swift collection.
Swift
3,421
star
2

RACollectionViewReorderableTripletLayout

The custom collectionView layout that can perform reordering of cells by dragging it.
Objective-C
1,481
star
3

Former

Former is a fully customizable Swift library for easy creating UITableView based form.
Swift
1,307
star
4

Carbon

🚴 A declarative library for building component-based user interfaces in UITableView and UICollectionView.
Swift
1,291
star
5

RAReorderableLayout

A UICollectionView layout whitch can move item with drag and drop.
Swift
867
star
6

DiffableDataSources

πŸ’Ύ A library for backporting UITableView/UICollectionViewDiffableDataSource.
Swift
836
star
7

SwiftUI-Combine

This is an example project of SwiftUI and Combine using GitHub API.
Swift
452
star
8

VueFlux

♻️ Unidirectional State Management Architecture for Swift - Inspired by Vuex and Flux
Swift
331
star
9

PathDynamicModal

A modal view using UIDynamicAnimator, like the Path for iOS.
Swift
286
star
10

swiftui-atom-properties

βš›οΈ Atomic approach state management and dependency injection for SwiftUI
Swift
232
star
11

FloatingActionSheetController

FloatingActionSheetController is a cool design ActionSheetController library written in Swift2.
Swift
140
star
12

DelegateProxy

Proxy for receive delegate events more practically
Swift
129
star
13

RASlideInViewController

RASlideInViewController has an transition effect expressing the depth, and you can dismiss it by draging
Objective-C
127
star
14

Alembic

βš—οΈ Functional JSON Parser - Linux Ready 🐧
Swift
116
star
15

swift-mod

A tool for Swift code modification intermediating between code generation and formatting.
Swift
101
star
16

SwiftUI-Flux

πŸš€ This is a tiny experimental application using SwiftUI with Flux architecture.
Swift
54
star
17

monkey-lang-swift

The Monkey Programming Language written in Swift -- Writing An Interpreter In Go
Swift
17
star
18

LiveStreamingApp

A sample app repository that broadcasting and player of HLS for iOS.
Swift
16
star
19

KenBurnsSlideshowView

Slideshow with Ken Burns effect for iOS.
Swift
11
star
20

VueFluxExample-GitHub

VueFlux VueFluxReactive example project
Swift
8
star
21

OwnKit

My own utility toolkit for ios
Swift
7
star
22

ra1028

2
star
23

Dribbble_client_sample

Swift
1
star