├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── config.yml
│ ├── documentation_request.yml
│ ├── feature_request.yml
│ └── bug_report.yml
├── pull_request_template.md
└── workflows
│ ├── docs.yml
│ └── test.yml
├── Examples
├── TheMovieDB-MVVM
│ ├── Dependency.swift
│ ├── Entity
│ │ ├── NetworkImageSize.swift
│ │ ├── Movie.swift
│ │ └── PagedResponse.swift
│ ├── CustomHooks
│ │ ├── UseMovieImage.swift
│ │ └── UseTopRatedMoviesViewModel.swift
│ ├── App.swift
│ ├── Info.plist
│ ├── MovieDBService.swift
│ └── Pages
│ │ ├── MovieDetailPage.swift
│ │ └── TopRatedMoviesPage.swift
├── BasicUsage
│ ├── App.swift
│ ├── IndexPage.swift
│ ├── CounterPage.swift
│ ├── Info.plist
│ ├── APIRequestPage.swift
│ └── ShowcasePage.swift
├── Examples.xcodeproj
│ ├── project.xcworkspace
│ │ ├── contents.xcworkspacedata
│ │ └── xcshareddata
│ │ │ └── IDEWorkspaceChecks.plist
│ └── xcshareddata
│ │ └── xcschemes
│ │ ├── BasicUsage.xcscheme
│ │ ├── Todo.xcscheme
│ │ ├── Todo-UITests.xcscheme
│ │ ├── TheMovieDB-MVVM.xcscheme
│ │ └── TheMovieDB-MVVM-Tests.xcscheme
├── TheMovieDB-MVVM-Tests
│ ├── Utilities.swift
│ ├── MovieDBServiceMock.swift
│ ├── Info.plist
│ ├── UseMovieImageTests.swift
│ └── UseTopRatedMoviesViewModelTests.swift
├── Todo
│ ├── AppDelegate.swift
│ ├── SceneDelegate.swift
│ ├── Info.plist
│ └── TodoPage.swift
├── Todo-UITests
│ ├── SimpleUITests.swift
│ └── Info.plist
└── project.yml
├── Sources
└── Hooks
│ ├── Context
│ ├── Context.swift
│ ├── Provider.swift
│ └── Consumer.swift
│ ├── Internals
│ ├── EnvironmentKeys.swift
│ ├── Assertions.swift
│ └── LinkedList.swift
│ ├── RefObject.swift
│ ├── Hook
│ ├── UseHook.swift
│ ├── UseEnvironment.swift
│ ├── UseRef.swift
│ ├── UseContext.swift
│ ├── UseMemo.swift
│ ├── UsePublisher.swift
│ ├── UseState.swift
│ ├── UseReducer.swift
│ ├── UsePublisherSubscribe.swift
│ ├── UseEffect.swift
│ ├── UseAsyncPerform.swift
│ └── UseAsync.swift
│ ├── ViewExtensions.swift
│ ├── HookView.swift
│ ├── HookCoordinator.swift
│ ├── Hooks.docc
│ └── Hooks.md
│ ├── HookUpdateStrategy.swift
│ ├── Hook.swift
│ ├── HookScope.swift
│ ├── Testing
│ └── HookTester.swift
│ ├── AsyncPhase.swift
│ └── HookDispatcher.swift
├── Tests
└── HooksTests
│ ├── RefObjectTests.swift
│ ├── Hook
│ ├── Utilities.swift
│ ├── UseContextTests.swift
│ ├── UseEnvironmentTests.swift
│ ├── UseRefTests.swift
│ ├── UseMemoTests.swift
│ ├── UseHookTests.swift
│ ├── UseReducerTests.swift
│ ├── UsePublisherSubscribeTests.swift
│ ├── UsePublisherTests.swift
│ ├── UseStateTests.swift
│ ├── UseAsyncPerformTests.swift
│ ├── UseAsyncTests.swift
│ └── UseEffectTests.swift
│ ├── HookUpdateStrategyTests.swift
│ ├── LinkedListTests.swift
│ ├── Testing
│ └── HookTesterTests.swift
│ ├── AsyncPhaseTests.swift
│ └── HookDispatcherTests.swift
├── .gitignore
├── Tools
├── Package.swift
└── Package.resolved
├── Package.swift
├── LICENSE
├── Makefile
└── .swift-format
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: ra1028
2 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 |
--------------------------------------------------------------------------------
/Examples/TheMovieDB-MVVM/Dependency.swift:
--------------------------------------------------------------------------------
1 | struct Dependency {
2 | let service: MovieDBServiceProtocol
3 | }
4 |
--------------------------------------------------------------------------------
/Examples/TheMovieDB-MVVM/Entity/NetworkImageSize.swift:
--------------------------------------------------------------------------------
1 | enum NetworkImageSize: String {
2 | case original
3 | case small = "w154"
4 | case medium = "w500"
5 | case cast = "w185"
6 | }
7 |
--------------------------------------------------------------------------------
/Examples/BasicUsage/App.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @main
4 | struct HooksApp: App {
5 | var body: some Scene {
6 | WindowGroup {
7 | IndexPage()
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/Examples/Examples.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Sources/Hooks/Context/Context.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A type of context that to identify the context values.
4 | public enum Context: EnvironmentKey {
5 | /// The default value for the context.
6 | public static var defaultValue: T? { nil }
7 | }
8 |
--------------------------------------------------------------------------------
/Examples/TheMovieDB-MVVM-Tests/Utilities.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | extension XCTestCase {
4 | func wait(timeout seconds: TimeInterval) {
5 | let expectation = expectation(description: #function)
6 | expectation.isInverted = true
7 | wait(for: [expectation], timeout: seconds)
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/Examples/TheMovieDB-MVVM/Entity/Movie.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | struct Movie: Codable, Hashable, Identifiable {
4 | let id: Int
5 | let title: String
6 | let overview: String?
7 | let posterPath: String?
8 | let backdropPath: String?
9 | let voteAverage: Float
10 | let releaseDate: Date
11 | }
12 |
--------------------------------------------------------------------------------
/Examples/TheMovieDB-MVVM/Entity/PagedResponse.swift:
--------------------------------------------------------------------------------
1 | struct PagedResponse: Decodable {
2 | let page: Int
3 | let totalPages: Int
4 | let results: [T]
5 |
6 | var hasNextPage: Bool {
7 | page < totalPages
8 | }
9 | }
10 |
11 | extension PagedResponse: Equatable where T: Equatable {}
12 |
--------------------------------------------------------------------------------
/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Tests/HooksTests/RefObjectTests.swift:
--------------------------------------------------------------------------------
1 | import Hooks
2 | import XCTest
3 |
4 | final class RefObjectTests: XCTestCase {
5 | func testCurrent() {
6 | let object = RefObject(0)
7 |
8 | XCTAssertEqual(object.current, 0)
9 |
10 | object.current = 1
11 |
12 | XCTAssertEqual(object.current, 1)
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | ## Pull Request Type
2 |
3 | - [ ] Bug fix
4 | - [ ] New feature
5 | - [ ] Refactoring
6 | - [ ] Documentation update
7 | - [ ] Chore
8 |
9 | ## Issue for this PR
10 |
11 | Link:
12 |
13 | ## Description
14 |
15 | ## Motivation and Context
16 |
17 | ## Impact on Existing Code
18 |
19 | ## Screenshot/Video/Gif
20 |
--------------------------------------------------------------------------------
/Examples/Todo/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | @main
4 | final class AppDelegate: UIResponder, UIApplicationDelegate {
5 | func application(
6 | _ application: UIApplication,
7 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
8 | ) -> Bool {
9 | return true
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | */build/*
3 | *.pbxuser
4 | !default.pbxuser
5 | *.mode1v3
6 | !default.mode1v3
7 | *.mode2v3
8 | !default.mode2v3
9 | *.perspectivev3
10 | !default.perspectivev3
11 | xcuserdata
12 | profile
13 | *.moved-aside
14 | DerivedData
15 | .idea/
16 | *.hmap
17 | *.xccheckout
18 | *.xcuserstate
19 | build/
20 | archive/
21 | *.xcframework
22 | .swiftpm
23 | .build
24 | docs
25 |
--------------------------------------------------------------------------------
/Tests/HooksTests/Hook/Utilities.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | struct TestError: Error, Equatable {
4 | let value: Int
5 | }
6 |
7 | extension XCTestCase {
8 | func wait(timeout seconds: TimeInterval) {
9 | let expectation = expectation(description: #function)
10 | expectation.isInverted = true
11 | wait(for: [expectation], timeout: seconds)
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/Sources/Hooks/Internals/EnvironmentKeys.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | internal extension EnvironmentValues {
4 | var hooksRulesAssertionDisabled: Bool {
5 | get { self[DisableHooksRulesAssertionKey.self] }
6 | set { self[DisableHooksRulesAssertionKey.self] = newValue }
7 | }
8 | }
9 |
10 | private struct DisableHooksRulesAssertionKey: EnvironmentKey {
11 | static let defaultValue = false
12 | }
13 |
--------------------------------------------------------------------------------
/Sources/Hooks/RefObject.swift:
--------------------------------------------------------------------------------
1 | /// A mutable object that referencing a value.
2 | public final class RefObject {
3 | /// A current value.
4 | public var current: T
5 |
6 | /// Creates a new ref object whose `current` property is initialized to the passed `initialValue`
7 | /// - Parameter initialValue: An initial value.
8 | public init(_ initialValue: T) {
9 | current = initialValue
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Examples/TheMovieDB-MVVM/CustomHooks/UseMovieImage.swift:
--------------------------------------------------------------------------------
1 | import Combine
2 | import Hooks
3 | import UIKit
4 |
5 | func useMovieImage(for path: String?, size: NetworkImageSize) -> UIImage? {
6 | let service = useContext(Context.self).service
7 | let phase = useAsync(.preserved(by: [path, size.rawValue])) {
8 | try await service.getImage(path: path, size: size)
9 | }
10 |
11 | return phase.value ?? nil
12 | }
13 |
--------------------------------------------------------------------------------
/Examples/TheMovieDB-MVVM/App.swift:
--------------------------------------------------------------------------------
1 | import Hooks
2 | import SwiftUI
3 |
4 | @main
5 | struct TheMovieDBApp: App {
6 | var dependency: Dependency {
7 | Dependency(
8 | service: MovieDBService()
9 | )
10 | }
11 |
12 | var body: some Scene {
13 | WindowGroup {
14 | Context.Provider(value: dependency) {
15 | TopRatedMoviesPage()
16 | }
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Tests/HooksTests/Hook/UseContextTests.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import XCTest
3 |
4 | @testable import Hooks
5 |
6 | final class UseContextTests: XCTestCase {
7 | func testValue() {
8 | let tester = HookTester {
9 | useContext(TestContext.self)
10 | } environment: {
11 | $0[TestContext.self] = 100
12 | }
13 |
14 | XCTAssertEqual(tester.value, 100)
15 | }
16 | }
17 |
18 | private typealias TestContext = Context
19 |
--------------------------------------------------------------------------------
/Tools/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.6
2 |
3 | import PackageDescription
4 |
5 | let package = Package(
6 | name: "Tools",
7 | dependencies: [
8 | .package(name: "swiftui-hooks", path: ".."),
9 | .package(url: "https://github.com/apple/swift-docc-plugin", exact: "1.0.0"),
10 | .package(url: "https://github.com/apple/swift-format.git", exact: "0.50600.0"),
11 | .package(url: "https://github.com/yonaskolb/XcodeGen.git", exact: "2.28.0"),
12 | ]
13 | )
14 |
--------------------------------------------------------------------------------
/Examples/Todo/SceneDelegate.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
4 | var window: UIWindow?
5 |
6 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
7 | if let windowScene = scene as? UIWindowScene {
8 | let window = UIWindow(windowScene: windowScene)
9 | self.window = window
10 |
11 | window.rootViewController = UIHostingController(rootView: TodoPage())
12 | window.makeKeyAndVisible()
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.6
2 |
3 | import Foundation
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "swiftui-hooks",
8 | platforms: [
9 | .iOS(.v13),
10 | .macOS(.v10_15),
11 | .tvOS(.v13),
12 | .watchOS(.v6),
13 | ],
14 | products: [
15 | .library(name: "Hooks", targets: ["Hooks"])
16 | ],
17 | targets: [
18 | .target(name: "Hooks"),
19 | .testTarget(
20 | name: "HooksTests",
21 | dependencies: ["Hooks"]
22 | ),
23 | ],
24 | swiftLanguageVersions: [.v5]
25 | )
26 |
--------------------------------------------------------------------------------
/Sources/Hooks/Internals/Assertions.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | internal func assertMainThread(file: StaticString = #file, line: UInt = #line) {
4 | assert(Thread.isMainThread, "This API must be called only on the main thread.", file: file, line: line)
5 | }
6 |
7 | internal func fatalErrorHooksRules(file: StaticString = #file, line: UInt = #line) -> Never {
8 | fatalError(
9 | """
10 | Hooks must be called at the function top level within scope of the HookScope or the HookView.hookBody`.
11 |
12 | - SeeAlso: https://reactjs.org/docs/hooks-rules.html
13 | """,
14 | file: file,
15 | line: line
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/Sources/Hooks/Hook/UseHook.swift:
--------------------------------------------------------------------------------
1 | /// Register the hook to the view and returns its value.
2 | /// Must be called at the function top level within scope of the `HookScope` or the HookView.hookBody`.
3 | ///
4 | /// struct CustomHook: Hook {
5 | /// ...
6 | /// }
7 | ///
8 | /// let value = useHook(CustomHook())
9 | ///
10 | /// - Parameter hook: A hook to be used.
11 | /// - Returns: A value that this hook provides.
12 | public func useHook(_ hook: H) -> H.Value {
13 | assertMainThread()
14 |
15 | guard let dispatcher = HookDispatcher.current else {
16 | fatalErrorHooksRules()
17 | }
18 |
19 | return dispatcher.use(hook)
20 | }
21 |
--------------------------------------------------------------------------------
/.github/workflows/docs.yml:
--------------------------------------------------------------------------------
1 | # https://github.com/actions/virtual-environments
2 |
3 | name: docs
4 |
5 | on:
6 | release:
7 | types: [published]
8 | workflow_dispatch:
9 |
10 | jobs:
11 | test:
12 | name: Test
13 | runs-on: macos-12
14 | strategy:
15 | matrix:
16 | xcode_version:
17 | - 13.3
18 | env:
19 | DEVELOPER_DIR: /Applications/Xcode_${{ matrix.xcode_version }}.app
20 | steps:
21 | - uses: actions/checkout@v2
22 | - name: Build docs
23 | run: make docs
24 | - name: Deploy
25 | uses: peaceiris/actions-gh-pages@v3
26 | with:
27 | github_token: ${{ secrets.GITHUB_TOKEN }}
28 | publish_dir: docs
29 |
--------------------------------------------------------------------------------
/Examples/TheMovieDB-MVVM-Tests/MovieDBServiceMock.swift:
--------------------------------------------------------------------------------
1 | import Combine
2 | import UIKit
3 |
4 | @testable import TheMovieDB_MVVM
5 |
6 | final class MovieDBServiceMock: MovieDBServiceProtocol {
7 | var imageResult: Result?
8 | var moviesResult: Result<[Movie], URLError>?
9 | var totalPages = 100
10 |
11 | func getImage(path: String?, size: NetworkImageSize) async throws -> UIImage? {
12 | try imageResult?.get()
13 | }
14 |
15 | func getTopRated(page: Int) async throws -> PagedResponse {
16 | try PagedResponse(
17 | page: page,
18 | totalPages: totalPages,
19 | results: moviesResult?.get() ?? []
20 | )
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Tests/HooksTests/Hook/UseEnvironmentTests.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import XCTest
3 |
4 | @testable import Hooks
5 |
6 | final class UseEnvironmentTests: XCTestCase {
7 | func testValue() {
8 | let tester = HookTester {
9 | useEnvironment(\.testValue)
10 | } environment: {
11 | $0.testValue = 100
12 | }
13 |
14 | XCTAssertEqual(tester.value, 100)
15 | }
16 | }
17 |
18 | private extension EnvironmentValues {
19 | enum TestValueKey: EnvironmentKey {
20 | static var defaultValue: Int? { nil }
21 | }
22 |
23 | var testValue: Int? {
24 | get { self[TestValueKey.self] }
25 | set { self[TestValueKey.self] = newValue }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/Sources/Hooks/ViewExtensions.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | public extension View {
4 | /// Sets whether to disable assertions that an internal sanity
5 | /// check of hooks rules.
6 | ///
7 | /// If this is disabled and a violation of hooks rules is detected,
8 | /// hooks will clear the unrecoverable state and attempt to continue
9 | /// the program.
10 | ///
11 | /// * In -O builds, assertions for hooks rules are disabled by default.
12 | ///
13 | /// - Parameter isDisabled: A Boolean value that indicates whether
14 | /// the assertinos are disabled for this view.
15 | /// - Returns: A view that assertions disabled.
16 | func disableHooksRulesAssertion(_ isDisabled: Bool) -> some View {
17 | environment(\.hooksRulesAssertionDisabled, isDisabled)
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Sources/Hooks/Hook/UseEnvironment.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A hook to use environment value passed through the view tree without `@Environment` property wrapper.
4 | ///
5 | /// let colorScheme = useEnvironment(\.colorScheme)
6 | ///
7 | /// - Parameter keyPath: A key path to a specific resulting value.
8 | /// - Returns: A environment value from the view's environment.
9 | public func useEnvironment(_ keyPath: KeyPath) -> Value {
10 | useHook(EnvironmentHook(keyPath: keyPath))
11 | }
12 |
13 | private struct EnvironmentHook: Hook {
14 | let keyPath: KeyPath
15 | let updateStrategy: HookUpdateStrategy? = .once
16 |
17 | func value(coordinator: Coordinator) -> Value {
18 | coordinator.environment[keyPath: keyPath]
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Examples/BasicUsage/IndexPage.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct IndexPage: View {
4 | var body: some View {
5 | NavigationView {
6 | Form {
7 | NavigationLink(
8 | "Showcase",
9 | destination: ShowcasePage()
10 | )
11 |
12 | NavigationLink(
13 | "Counter",
14 | destination: CounterPage()
15 | )
16 |
17 | NavigationLink(
18 | "API Request",
19 | destination: APIRequestPage()
20 | )
21 | }
22 | .navigationTitle("Examples")
23 | .background(Color(.systemBackground).ignoresSafeArea())
24 | }
25 | .navigationViewStyle(StackNavigationViewStyle())
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/Tests/HooksTests/Hook/UseRefTests.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import XCTest
3 |
4 | @testable import Hooks
5 |
6 | final class UseRefTests: XCTestCase {
7 | func testUpdate() {
8 | let tester = HookTester {
9 | useRef(0)
10 | }
11 |
12 | let ref = tester.value
13 |
14 | XCTAssertEqual(ref.current, 0)
15 |
16 | tester.update()
17 | tester.value.current = 1
18 |
19 | XCTAssertTrue(tester.value === ref)
20 | XCTAssertEqual(ref.current, 1)
21 | }
22 |
23 | func testWhenInitialValueIsChanged() {
24 | let tester = HookTester(0) { initialValue in
25 | useRef(initialValue)
26 | }
27 |
28 | XCTAssertEqual(tester.value.current, 0)
29 |
30 | tester.update(with: 1)
31 |
32 | XCTAssertEqual(tester.value.current, 0)
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/Tests/HooksTests/HookUpdateStrategyTests.swift:
--------------------------------------------------------------------------------
1 | import Hooks
2 | import XCTest
3 |
4 | final class HookUpdateStrategyTests: XCTestCase {
5 | func testOnce() {
6 | struct Unique: Equatable {}
7 |
8 | let key = HookUpdateStrategy.once
9 | let expected = HookUpdateStrategy.once
10 | let unexpected = HookUpdateStrategy(dependency: Unique())
11 |
12 | XCTAssertEqual(key.dependency, expected.dependency)
13 | XCTAssertNotEqual(key.dependency, unexpected.dependency)
14 | }
15 |
16 | func testPreservedByEquatable() {
17 | let key = HookUpdateStrategy.preserved(by: 100)
18 | let expected = HookUpdateStrategy.preserved(by: 100)
19 | let unexpected = HookUpdateStrategy.preserved(by: 1)
20 |
21 | XCTAssertEqual(key.dependency, expected.dependency)
22 | XCTAssertNotEqual(key.dependency, unexpected.dependency)
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Sources/Hooks/Hook/UseRef.swift:
--------------------------------------------------------------------------------
1 | /// A hook to use a mutable ref object storing an arbitrary value.
2 | /// The essential of this hook is that setting a value to `current` doesn't trigger a view update.
3 | ///
4 | /// let value = useRef("text") // RefObject
5 | ///
6 | /// Button("Save text") {
7 | /// value.current = "new text"
8 | /// }
9 | ///
10 | /// - Parameter initialValue: A initial value that to initialize the ref object to be returned.
11 | /// - Returns: A mutable ref object.
12 | public func useRef(_ initialValue: T) -> RefObject {
13 | useHook(RefHook(initialValue: initialValue))
14 | }
15 |
16 | private struct RefHook: Hook {
17 | let initialValue: T
18 | let updateStrategy: HookUpdateStrategy? = .once
19 |
20 | func makeState() -> RefObject {
21 | RefObject(initialValue)
22 | }
23 |
24 | func value(coordinator: Coordinator) -> RefObject {
25 | coordinator.state
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/Sources/Hooks/HookView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A view that wrapper around the `HookScope` to use hooks inside.
4 | /// The view that is returned from `hookBody` will be encluded with `HookScope` and be able to use hooks.
5 | ///
6 | /// struct ContentView: HookView {
7 | /// var hookBody: some View {
8 | /// let count = useState(0)
9 | ///
10 | /// Button("\(count.wrappedValue)") {
11 | /// count.wrappedValue += 1
12 | /// }
13 | /// }
14 | /// }
15 | public protocol HookView: View {
16 | // The type of view representing the body of this view that can use hooks.
17 | associatedtype HookBody: View
18 |
19 | /// The content and behavior of the hook scoped view.
20 | @ViewBuilder
21 | var hookBody: HookBody { get }
22 | }
23 |
24 | public extension HookView {
25 | /// The content and behavior of the view.
26 | var body: some View {
27 | HookScope {
28 | hookBody
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Examples/Todo-UITests/SimpleUITests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | @testable import Todo
4 |
5 | final class SimpleUITests: XCTestCase {
6 | func testInsertAndDelete() {
7 | let app = XCUIApplication()
8 |
9 | app.launch()
10 |
11 | let todoText = "TODO"
12 | let scrollViewElements = app.scrollViews.otherElements
13 | let inputField = scrollViewElements.textFields["input"]
14 |
15 | inputField.tap()
16 |
17 | for text in todoText {
18 | inputField.typeText(String(text))
19 | Thread.sleep(forTimeInterval: 0.1)
20 | }
21 |
22 | inputField.typeText(XCUIKeyboardKey.return.rawValue)
23 |
24 | let todo = scrollViewElements.staticTexts["todo:\(todoText)"]
25 |
26 | XCTAssertTrue(todo.exists)
27 | XCTAssertEqual(todo.label, todoText)
28 |
29 | let deleteButton = scrollViewElements.buttons["delete:\(todoText)"]
30 |
31 | deleteButton.tap()
32 |
33 | XCTAssertFalse(todo.exists)
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/Sources/Hooks/HookCoordinator.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// Contextual information about the state of the hook.
4 | public struct HookCoordinator {
5 | /// The state of the hook stored in the scope.
6 | public let state: H.State
7 |
8 | /// The current environment of the scope.
9 | public let environment: EnvironmentValues
10 |
11 | /// A function that to update the content of the nearest scope.
12 | public let updateView: () -> Void
13 |
14 | /// Create a new coordinator.
15 | /// - Parameters:
16 | /// - state: The state of the hook stored in the scope.
17 | /// - environment: The current environment of the scope.
18 | /// - updateView: A function that to update the content of the nearest scope.
19 | public init(
20 | state: H.State,
21 | environment: EnvironmentValues,
22 | updateView: @escaping () -> Void
23 | ) {
24 | self.state = state
25 | self.environment = environment
26 | self.updateView = updateView
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/documentation_request.yml:
--------------------------------------------------------------------------------
1 | name: Documentation Request
2 | description: Suggest a new doc/example or ask a question about an existing one
3 | title: "[Doc Request]: "
4 | labels: ["documentation"]
5 | body:
6 | - type: checkboxes
7 | attributes:
8 | label: Checklist
9 | options:
10 | - label: Reviewed the README and documentation.
11 | required: true
12 | - label: Confirmed that this is uncovered by existing docs or examples.
13 | required: true
14 | - label: Checked existing issues & PRs to ensure not duplicated.
15 | required: true
16 |
17 | - type: textarea
18 | attributes:
19 | label: Description
20 | placeholder: Describe what the scenario you think is uncovered by the existing ones and why you think it should be covered.
21 | validations:
22 | required: true
23 |
24 | - type: textarea
25 | attributes:
26 | label: Motivation & Context
27 | placeholder: Feel free to describe any additional context, such as why you thought the scenario should be covered.
28 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2022 Ryo Aoyama
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Tests/HooksTests/Hook/UseMemoTests.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import XCTest
3 |
4 | @testable import Hooks
5 |
6 | final class UseMemoTests: XCTestCase {
7 | func testOnce() {
8 | var value = 0
9 | let tester = HookTester {
10 | useMemo(.once) { () -> Int in
11 | value
12 | }
13 | }
14 |
15 | XCTAssertEqual(tester.value, 0)
16 |
17 | value = 1
18 | tester.update()
19 |
20 | XCTAssertEqual(tester.value, 0)
21 |
22 | value = 2
23 | tester.update()
24 |
25 | XCTAssertEqual(tester.value, 0)
26 | }
27 |
28 | func testPreserved() {
29 | var flag = false
30 | var value = 0
31 | let tester = HookTester {
32 | useMemo(.preserved(by: flag)) { () -> Int in
33 | value
34 | }
35 | }
36 |
37 | XCTAssertEqual(tester.value, 0)
38 |
39 | value = 1
40 | tester.update()
41 |
42 | XCTAssertEqual(tester.value, 0)
43 |
44 | flag.toggle()
45 | value = 2
46 | tester.update()
47 |
48 | XCTAssertEqual(tester.value, 2)
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/Sources/Hooks/Context/Provider.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | public extension Context {
4 | /// A view that provides the context values through view tree.
5 | struct Provider: View {
6 | private let value: T
7 | private let content: () -> Content
8 |
9 | @Environment(\.self)
10 | private var environment
11 |
12 | /// Creates a `Provider` that provides the passed value.
13 | /// - Parameters:
14 | /// - value: A value that to be provided to child views.
15 | /// - content: A content view where the passed value will be provided.
16 | public init(value: T, @ViewBuilder content: @escaping () -> Content) {
17 | self.value = value
18 | self.content = content
19 | }
20 |
21 | /// The content and behavior of the view.
22 | public var body: some View {
23 | HookScope(content).environment(\.self, contextEnvironments)
24 | }
25 | }
26 | }
27 |
28 | private extension Context.Provider {
29 | var contextEnvironments: EnvironmentValues {
30 | var environment = self.environment
31 | environment[Context.self] = value
32 | return environment
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/Sources/Hooks/Hook/UseContext.swift:
--------------------------------------------------------------------------------
1 | /// A hook to use current context value that is provided by `Context.Provider`.
2 | /// The purpose is identical to use `Context.Consumer`.
3 | ///
4 | /// typealias CounterContext = Context>
5 | ///
6 | /// let count = useContext(CounterContext.self)
7 | ///
8 | /// - Parameter context: The type of context.
9 | /// - Returns: A value that provided by provider from upstream of the view tree.
10 | public func useContext(_ context: Context.Type) -> T {
11 | useHook(ContextHook(context: context))
12 | }
13 |
14 | private struct ContextHook: Hook {
15 | let context: Context.Type
16 | let updateStrategy: HookUpdateStrategy? = .once
17 |
18 | func value(coordinator: Coordinator) -> T {
19 | guard let value = coordinator.environment[context] else {
20 | fatalError(
21 | """
22 | No context value of type \(context) found.
23 | A \(context).Provider.init(value:content:) is missing as an ancestor of the consumer.
24 |
25 | - SeeAlso: https://reactjs.org/docs/context.html#contextprovider
26 | """
27 | )
28 | }
29 |
30 | return value
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Sources/Hooks/Hook/UseMemo.swift:
--------------------------------------------------------------------------------
1 | /// A hook to use memoized value preserved until it is updated at the timing determined with given `updateStrategy`.
2 | ///
3 | /// let random = useMemo(.once) {
4 | /// Int.random(in: 0...100)
5 | /// }
6 | ///
7 | /// - Parameters:
8 | /// - updateStrategy: A strategy that determines when to update the value.
9 | /// - makeValue: A closure that to create a new value.
10 | /// - Returns: A memoized value.
11 | public func useMemo(
12 | _ updateStrategy: HookUpdateStrategy,
13 | _ makeValue: @escaping () -> Value
14 | ) -> Value {
15 | useHook(
16 | MemoHook(
17 | updateStrategy: updateStrategy,
18 | makeValue: makeValue
19 | )
20 | )
21 | }
22 |
23 | private struct MemoHook: Hook {
24 | let updateStrategy: HookUpdateStrategy?
25 | let makeValue: () -> Value
26 |
27 | func makeState() -> State {
28 | State()
29 | }
30 |
31 | func updateState(coordinator: Coordinator) {
32 | coordinator.state.value = makeValue()
33 | }
34 |
35 | func value(coordinator: Coordinator) -> Value {
36 | coordinator.state.value ?? makeValue()
37 | }
38 | }
39 |
40 | private extension MemoHook {
41 | final class State {
42 | var value: Value?
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/Tests/HooksTests/Hook/UseHookTests.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import XCTest
3 |
4 | @testable import Hooks
5 |
6 | final class UseHookTests: XCTestCase {
7 | struct TestHook: Hook {
8 | final class State {
9 | var flag = false
10 | var isUpdated = false
11 | }
12 |
13 | let updateStrategy: HookUpdateStrategy? = nil
14 |
15 | func makeState() -> State {
16 | State()
17 | }
18 |
19 | func value(
20 | coordinator: Coordinator
21 | ) -> (flag: Bool, isUpdated: Bool, toggleFlag: () -> Void) {
22 | (
23 | flag: coordinator.state.flag,
24 | isUpdated: coordinator.state.isUpdated,
25 | toggleFlag: {
26 | coordinator.state.flag.toggle()
27 | coordinator.updateView()
28 | }
29 | )
30 | }
31 |
32 | func updateState(coordinator: Coordinator) {
33 | coordinator.state.isUpdated = true
34 | }
35 | }
36 |
37 | func testUseHook() {
38 | let tester = HookTester {
39 | useHook(TestHook())
40 | }
41 |
42 | XCTAssertFalse(tester.value.flag)
43 | XCTAssertTrue(tester.value.isUpdated)
44 |
45 | tester.value.toggleFlag()
46 |
47 | XCTAssertTrue(tester.value.flag)
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.yml:
--------------------------------------------------------------------------------
1 | name: Feature Request
2 | description: Suggest a new idea of feature
3 | title: "[Feat Request]: "
4 | labels: ["enhancement"]
5 | body:
6 | - type: checkboxes
7 | attributes:
8 | label: Checklist
9 | options:
10 | - label: Reviewed the README and documentation.
11 | required: true
12 | - label: Checked existing issues & PRs to ensure not duplicated.
13 | required: true
14 |
15 | - type: textarea
16 | attributes:
17 | label: Description
18 | placeholder: Describe the feature that you want to propose.
19 | validations:
20 | required: true
21 |
22 | - type: textarea
23 | attributes:
24 | label: Example Use Case
25 | placeholder: Describe an example use case that the feature is useful.
26 | validations:
27 | required: true
28 |
29 | - type: textarea
30 | attributes:
31 | label: Alternative Solution
32 | placeholder: Describe alternatives solutions that you've considered.
33 |
34 | - type: textarea
35 | attributes:
36 | label: Proposed Solution
37 | placeholder: Describe how we can achieve the feature you'd like to suggest.
38 |
39 | - type: textarea
40 | attributes:
41 | label: Motivation & Context
42 | placeholder: Feel free to describe any additional context, such as why you want to suggest this feature.
43 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | # https://github.com/actions/virtual-environments
2 |
3 | name: test
4 |
5 | on:
6 | pull_request:
7 | push:
8 | branches:
9 | - main
10 | workflow_dispatch:
11 |
12 | jobs:
13 | test:
14 | name: Test
15 | runs-on: macos-12
16 | strategy:
17 | matrix:
18 | xcode_version:
19 | - 13.3
20 | env:
21 | DEVELOPER_DIR: /Applications/Xcode_${{ matrix.xcode_version }}.app
22 | steps:
23 | - uses: actions/checkout@v2
24 | - name: Show environments
25 | run: |
26 | swift --version
27 | xcodebuild -version
28 | - name: Test library
29 | run: make test-library
30 | - name: Build examples
31 | run: make build-examples
32 |
33 | validation:
34 | name: Validation
35 | runs-on: macos-12
36 | env:
37 | DEVELOPER_DIR: /Applications/Xcode_13.3.app
38 | steps:
39 | - uses: actions/checkout@v2
40 | - name: Validate lint
41 | run: make lint
42 | - name: Validate format
43 | run: |
44 | make format
45 | if [ -n "$(git status --porcelain)" ]; then echo "Make sure that the code is formated by 'make format'."; exit 1; fi
46 | - name: Validate example project
47 | run: |
48 | make proj
49 | if [ -n "$(git status --porcelain)" ]; then echo "Make sure that 'Examples/Examples.xcodeproj' is formated by 'make proj'."; exit 1; fi
50 |
--------------------------------------------------------------------------------
/Examples/BasicUsage/CounterPage.swift:
--------------------------------------------------------------------------------
1 | import Hooks
2 | import SwiftUI
3 |
4 | struct CounterPage: HookView {
5 | var hookBody: some View {
6 | let count = useState(0)
7 | let isAutoIncrement = useState(false)
8 |
9 | useEffect(.preserved(by: isAutoIncrement.wrappedValue)) {
10 | guard isAutoIncrement.wrappedValue else { return nil }
11 |
12 | let timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
13 | count.wrappedValue += 1
14 | }
15 |
16 | return timer.invalidate
17 | }
18 |
19 | return VStack(spacing: 50) {
20 | Text(String(format: "%02d", count.wrappedValue))
21 | .lineLimit(1)
22 | .minimumScaleFactor(0.1)
23 | .font(.system(size: 100, weight: .heavy, design: .monospaced))
24 | .padding(30)
25 | .frame(width: 200, height: 200)
26 | .background(Color(.secondarySystemBackground))
27 | .clipShape(Circle())
28 |
29 | Stepper(value: count, in: 0...(.max), label: EmptyView.init).fixedSize()
30 |
31 | Toggle("Auto +", isOn: isAutoIncrement).fixedSize()
32 | }
33 | .navigationTitle("Counter")
34 | .frame(maxWidth: .infinity, maxHeight: .infinity)
35 | .background(Color(.systemBackground).ignoresSafeArea())
36 | }
37 | }
38 |
39 | struct CounterPage_Previews: PreviewProvider {
40 | static var previews: some View {
41 | CounterPage()
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/Sources/Hooks/Hooks.docc/Hooks.md:
--------------------------------------------------------------------------------
1 | # ``Hooks``
2 |
3 | 🪝 A SwiftUI implementation of React Hooks. Enhances reusability of stateful logic and gives state and lifecycle to function view.
4 |
5 | ## Overview
6 |
7 | 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.
8 | It allows you to reuse stateful logic between views by building custom hooks composed with multiple hooks.
9 | Furthermore, hooks such as useEffect also solve the problem of lack of lifecycles in SwiftUI.
10 |
11 | ## Source Code
12 |
13 |
14 |
15 | ## Topics
16 |
17 | ### Hooks
18 |
19 | - ``useState(_:)-52rjz``
20 | - ``useState(_:)-jg02``
21 | - ``useEffect(_:_:)``
22 | - ``useLayoutEffect(_:_:)``
23 | - ``useMemo(_:_:)``
24 | - ``useRef(_:)``
25 | - ``useReducer(_:initialState:)``
26 | - ``useEnvironment(_:)``
27 | - ``useAsync(_:_:)-14qp9``
28 | - ``useAsync(_:_:)-cow``
29 | - ``useAsyncPerform(_:)-6w8mq``
30 | - ``useAsyncPerform(_:)-5qq2h``
31 | - ``usePublisher(_:_:)``
32 | - ``usePublisherSubscribe(_:)``
33 | - ``useContext(_:)``
34 |
35 | ### User Interface
36 |
37 | - ``HookScope``
38 | - ``HookView``
39 |
40 | ### Values
41 |
42 | - ``Context``
43 | - ``AsyncPhase``
44 | - ``RefObject``
45 | - ``HookUpdateStrategy``
46 |
47 | ### Testing
48 |
49 | - ``HookTester``
50 |
51 | ### Internal System
52 |
53 | - ``useHook(_:)``
54 | - ``Hook``
55 | - ``HookCoordinator``
56 | - ``HookDispatcher``
57 |
--------------------------------------------------------------------------------
/Sources/Hooks/Context/Consumer.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | public extension Context {
4 | /// A view that consumes the context values that provided by `Provider` through view tree.
5 | /// If the value is not provided by the `Provider` from upstream of the view tree, the view's update will be asserted.
6 | struct Consumer: View {
7 | private let content: (T) -> Content
8 |
9 | @Environment(\.self)
10 | private var environment
11 |
12 | /// Creates a `Consumer` that consumes the provided value.
13 | /// - Parameter content: A content view that be able to use the provided value.
14 | public init(@ViewBuilder content: @escaping (T) -> Content) {
15 | self.content = content
16 | }
17 |
18 | /// The content and behavior of the view.
19 | public var body: some View {
20 | if let value = environment[Context.self] {
21 | content(value)
22 | }
23 | else {
24 | assertMissingContext()
25 | }
26 | }
27 | }
28 | }
29 |
30 | private extension Context.Consumer {
31 | func assertMissingContext() -> some View {
32 | assertionFailure(
33 | """
34 | No context value of type \(Context.self) found.
35 | A \(Context.self).Provider.init(value:content:) is missing as an ancestor of the consumer.
36 |
37 | - SeeAlso: https://reactjs.org/docs/context.html#contextprovider
38 | """
39 | )
40 | return EmptyView()
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.yml:
--------------------------------------------------------------------------------
1 | name: Bug Report
2 | description: File a bug report
3 | title: "[Bug]: "
4 | labels: ["bug"]
5 | body:
6 | - type: checkboxes
7 | attributes:
8 | label: Checklist
9 | options:
10 | - label: This is not a bug caused by platform.
11 | required: true
12 | - label: Reviewed the README and documentation.
13 | required: true
14 | - label: Checked existing issues & PRs to ensure not duplicated.
15 | required: true
16 |
17 | - type: textarea
18 | attributes:
19 | label: What happened?
20 | validations:
21 | required: true
22 |
23 | - type: textarea
24 | id: expected-behavior
25 | attributes:
26 | label: Expected Behavior
27 | validations:
28 | required: true
29 |
30 | - type: textarea
31 | attributes:
32 | label: Reproduction Steps
33 | value: |
34 | 1.
35 | 2.
36 | 3.
37 | validations:
38 | required: true
39 |
40 | - type: input
41 | attributes:
42 | label: Swift Version
43 | validations:
44 | required: true
45 |
46 | - type: input
47 | attributes:
48 | label: Library Version
49 | validations:
50 | required: true
51 |
52 | - type: dropdown
53 | attributes:
54 | label: Platform
55 | multiple: true
56 | options:
57 | - iOS
58 | - tvOS
59 | - macOS
60 | - watchOS
61 |
62 | - type: textarea
63 | attributes:
64 | label: Scrrenshot/Video/Gif
65 | placeholder: |
66 | Drag and drop screenshot, video, or gif here if you have.
67 |
--------------------------------------------------------------------------------
/Examples/BasicUsage/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 | LSRequiresIPhoneOS
22 |
23 | UIApplicationSceneManifest
24 |
25 | UIApplicationSupportsMultipleScenes
26 |
27 |
28 | UIApplicationSupportsIndirectInputEvents
29 |
30 | UILaunchScreen
31 |
32 | UIRequiredDeviceCapabilities
33 |
34 | armv7
35 |
36 | UISupportedInterfaceOrientations
37 |
38 | UIInterfaceOrientationPortrait
39 |
40 | UISupportedInterfaceOrientations~ipad
41 |
42 | UIInterfaceOrientationPortrait
43 | UIInterfaceOrientationPortraitUpsideDown
44 | UIInterfaceOrientationLandscapeLeft
45 | UIInterfaceOrientationLandscapeRight
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/Examples/Todo-UITests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 | LSRequiresIPhoneOS
22 |
23 | UIApplicationSceneManifest
24 |
25 | UIApplicationSupportsMultipleScenes
26 |
27 |
28 | UIApplicationSupportsIndirectInputEvents
29 |
30 | UILaunchScreen
31 |
32 | UIRequiredDeviceCapabilities
33 |
34 | armv7
35 |
36 | UISupportedInterfaceOrientations
37 |
38 | UIInterfaceOrientationPortrait
39 | UIInterfaceOrientationLandscapeLeft
40 | UIInterfaceOrientationLandscapeRight
41 |
42 | UISupportedInterfaceOrientations~ipad
43 |
44 | UIInterfaceOrientationPortrait
45 | UIInterfaceOrientationPortraitUpsideDown
46 | UIInterfaceOrientationLandscapeLeft
47 | UIInterfaceOrientationLandscapeRight
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/Examples/TheMovieDB-MVVM/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 | LSRequiresIPhoneOS
22 |
23 | UIApplicationSceneManifest
24 |
25 | UIApplicationSupportsMultipleScenes
26 |
27 |
28 | UIApplicationSupportsIndirectInputEvents
29 |
30 | UILaunchScreen
31 |
32 | UIRequiredDeviceCapabilities
33 |
34 | armv7
35 |
36 | UISupportedInterfaceOrientations
37 |
38 | UIInterfaceOrientationPortrait
39 | UIInterfaceOrientationLandscapeLeft
40 | UIInterfaceOrientationLandscapeRight
41 |
42 | UISupportedInterfaceOrientations~ipad
43 |
44 | UIInterfaceOrientationPortrait
45 | UIInterfaceOrientationPortraitUpsideDown
46 | UIInterfaceOrientationLandscapeLeft
47 | UIInterfaceOrientationLandscapeRight
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/Examples/TheMovieDB-MVVM-Tests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 | LSRequiresIPhoneOS
22 |
23 | UIApplicationSceneManifest
24 |
25 | UIApplicationSupportsMultipleScenes
26 |
27 |
28 | UIApplicationSupportsIndirectInputEvents
29 |
30 | UILaunchScreen
31 |
32 | UIRequiredDeviceCapabilities
33 |
34 | armv7
35 |
36 | UISupportedInterfaceOrientations
37 |
38 | UIInterfaceOrientationPortrait
39 | UIInterfaceOrientationLandscapeLeft
40 | UIInterfaceOrientationLandscapeRight
41 |
42 | UISupportedInterfaceOrientations~ipad
43 |
44 | UIInterfaceOrientationPortrait
45 | UIInterfaceOrientationPortraitUpsideDown
46 | UIInterfaceOrientationLandscapeLeft
47 | UIInterfaceOrientationLandscapeRight
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/Tests/HooksTests/Hook/UseReducerTests.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import XCTest
3 |
4 | @testable import Hooks
5 |
6 | final class UseReducerTests: XCTestCase {
7 | func testUpdate() {
8 | func reducer(state: Int, action: Int) -> Int {
9 | state + action
10 | }
11 |
12 | let tester = HookTester {
13 | useReducer(reducer, initialState: 0)
14 | }
15 |
16 | XCTAssertEqual(tester.value.state, 0)
17 |
18 | tester.value.dispatch(1)
19 |
20 | XCTAssertEqual(tester.value.state, 1)
21 |
22 | tester.value.dispatch(2)
23 |
24 | XCTAssertEqual(tester.value.state, 3)
25 |
26 | tester.dispose()
27 | tester.update()
28 |
29 | XCTAssertEqual(tester.value.state, 0)
30 | }
31 |
32 | func testWhenInitialStateIsChanged() {
33 | func reducer(state: Int, action: Int) -> Int {
34 | state + action
35 | }
36 |
37 | let tester = HookTester(0) { initialState in
38 | useReducer(reducer, initialState: initialState)
39 | }
40 |
41 | XCTAssertEqual(tester.value.state, 0)
42 |
43 | tester.update(with: 1)
44 |
45 | XCTAssertEqual(tester.value.state, 0)
46 |
47 | tester.update()
48 |
49 | XCTAssertEqual(tester.value.state, 0)
50 | }
51 |
52 | func testDispose() {
53 | var isReduced = false
54 |
55 | func reducer(state: Int, action: Int) -> Int {
56 | isReduced = true
57 | return state + action
58 | }
59 |
60 | let tester = HookTester {
61 | useReducer(reducer, initialState: 0)
62 | }
63 |
64 | tester.dispose()
65 | tester.value.dispatch(1)
66 |
67 | XCTAssertEqual(tester.value.state, 0)
68 | XCTAssertFalse(isReduced)
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | TOOL = swift run -c release --package-path Tools
2 | PACKAGE = swift package --package-path Tools
3 | SWIFT_FILE_PATHS = Package.swift Sources Tests Examples
4 | TEST_PLATFORM_IOS = iOS Simulator,name=iPhone 13 Pro
5 | TEST_PLATFORM_MACOS = macOS
6 | TEST_PLATFORM_TVOS = tvOS Simulator,name=Apple TV 4K (at 1080p) (2nd generation)
7 | TEST_PLATFORM_WATCHOS = watchOS Simulator,name=Apple Watch Series 7 - 45mm
8 |
9 | .PHONY: proj
10 | proj:
11 | $(TOOL) xcodegen -s Examples/project.yml
12 |
13 | .PHONY: format
14 | format:
15 | $(TOOL) swift-format format -i -p -r $(SWIFT_FILE_PATHS)
16 |
17 | .PHONY: lint
18 | lint:
19 | $(TOOL) swift-format lint -s -p -r $(SWIFT_FILE_PATHS)
20 |
21 | .PHONY: docs
22 | docs:
23 | $(PACKAGE) \
24 | --allow-writing-to-directory docs \
25 | generate-documentation \
26 | --product Hooks \
27 | --disable-indexing \
28 | --transform-for-static-hosting \
29 | --hosting-base-path swiftui-hooks \
30 | --output-path docs
31 |
32 | .PHONY: docs-preview
33 | docs-preview:
34 | $(PACKAGE) \
35 | --disable-sandbox \
36 | preview-documentation \
37 | --product Hooks
38 |
39 | .PHONY: test
40 | test: test-library build-examples
41 |
42 | .PHONY: test-library
43 | test-library:
44 | for platform in "$(TEST_PLATFORM_IOS)" "$(TEST_PLATFORM_MACOS)" "$(TEST_PLATFORM_TVOS)" "$(TEST_PLATFORM_WATCHOS)"; do \
45 | xcodebuild test -scheme swiftui-hooks -destination platform="$$platform"; \
46 | done
47 | cd Examples \
48 | && xcodebuild test -scheme Todo-UITests -destination platform="$(TEST_PLATFORM_IOS)" \
49 | && xcodebuild test -scheme TheMovieDB-MVVM-Tests -destination platform="$(TEST_PLATFORM_IOS)"
50 |
51 | .PHONY: build-examples
52 | build-examples:
53 | cd Examples && for scheme in "TheMovieDB-MVVM" "BasicUsage" "Todo" ; do \
54 | xcodebuild build -scheme "$$scheme" -destination platform="$(TEST_PLATFORM_IOS)"; \
55 | done
56 |
--------------------------------------------------------------------------------
/Examples/TheMovieDB-MVVM-Tests/UseMovieImageTests.swift:
--------------------------------------------------------------------------------
1 | import Hooks
2 | import XCTest
3 |
4 | @testable import TheMovieDB_MVVM
5 |
6 | final class UseMovieImageTests: XCTestCase {
7 | func testSuccess() {
8 | let image1 = UIImage()
9 | let image2 = UIImage()
10 | let image3 = UIImage()
11 | let service = MovieDBServiceMock()
12 | let tester = HookTester(("path1", .medium)) { path, size in
13 | useMovieImage(for: path, size: size)
14 | } environment: {
15 | $0[Context.self] = Dependency(service: service)
16 | }
17 |
18 | XCTAssertNil(tester.value)
19 |
20 | service.imageResult = .success(image1)
21 | wait(timeout: 0.1)
22 |
23 | XCTAssertTrue(tester.value === image1)
24 |
25 | tester.update()
26 | wait(timeout: 0.1)
27 |
28 | XCTAssertTrue(tester.value === image1)
29 |
30 | tester.update(with: ("path1", .original))
31 |
32 | XCTAssertNil(tester.value)
33 |
34 | service.imageResult = .success(image2)
35 | wait(timeout: 0.1)
36 |
37 | XCTAssertTrue(tester.value === image2)
38 |
39 | tester.update(with: ("path2", .original))
40 |
41 | XCTAssertNil(tester.value)
42 |
43 | service.imageResult = .success(image3)
44 | wait(timeout: 0.1)
45 |
46 | XCTAssertTrue(tester.value === image3)
47 | }
48 |
49 | func testFailure() {
50 | let error = URLError(.badURL)
51 | let service = MovieDBServiceMock()
52 | let tester = HookTester(("path1", .medium)) { path, size in
53 | useMovieImage(for: path, size: size)
54 | } environment: {
55 | $0[Context.self] = Dependency(service: service)
56 | }
57 |
58 | XCTAssertNil(tester.value)
59 |
60 | service.imageResult = .failure(error)
61 | wait(timeout: 0.1)
62 |
63 | XCTAssertNil(tester.value)
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/Examples/TheMovieDB-MVVM/CustomHooks/UseTopRatedMoviesViewModel.swift:
--------------------------------------------------------------------------------
1 | import Hooks
2 | import SwiftUI
3 |
4 | struct TopRatedMoviesViewModel {
5 | let selectedMovie: Binding
6 | let loadPhase: AsyncPhase<[Movie], Error>
7 | let hasNextPage: Bool
8 | let load: () async -> Void
9 | let loadNext: () async -> Void
10 | }
11 |
12 | func useTopRatedMoviesViewModel() -> TopRatedMoviesViewModel {
13 | let selectedMovie = useState(nil as Movie?)
14 | let nextMovies = useRef([Movie]())
15 | let (loadPhase, load) = useLoadMovies()
16 | let (loadNextPhase, loadNext) = useLoadMovies()
17 | let latestResponse = loadNextPhase.value ?? loadPhase.value
18 |
19 | useLayoutEffect(.preserved(by: loadPhase.isSuccess)) {
20 | nextMovies.current = []
21 | return nil
22 | }
23 |
24 | useLayoutEffect(.preserved(by: loadNextPhase.isSuccess)) {
25 | nextMovies.current += loadNextPhase.value?.results ?? []
26 | return nil
27 | }
28 |
29 | return TopRatedMoviesViewModel(
30 | selectedMovie: selectedMovie,
31 | loadPhase: loadPhase.map {
32 | $0.results + nextMovies.current
33 | },
34 | hasNextPage: latestResponse?.hasNextPage ?? false,
35 | load: {
36 | await load(1)
37 | },
38 | loadNext: {
39 | if let currentPage = latestResponse?.page {
40 | await loadNext(currentPage + 1)
41 | }
42 | }
43 | )
44 | }
45 |
46 | private func useLoadMovies() -> (phase: AsyncPhase, Error>, load: (Int) async -> Void) {
47 | let page = useRef(0)
48 | let service = useContext(Context.self).service
49 | let (phase, load) = useAsyncPerform {
50 | try await service.getTopRated(page: page.current)
51 | }
52 |
53 | return (
54 | phase: phase,
55 | load: { newPage in
56 | page.current = newPage
57 | await load()
58 | }
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/Examples/Todo/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 | LSRequiresIPhoneOS
22 |
23 | UIApplicationSceneManifest
24 |
25 | UIApplicationSupportsMultipleScenes
26 |
27 | UISceneConfigurations
28 |
29 | UIWindowSceneSessionRoleApplication
30 |
31 |
32 | UISceneConfigurationName
33 | Default Configuration
34 | UISceneDelegateClassName
35 | $(PRODUCT_MODULE_NAME).SceneDelegate
36 |
37 |
38 |
39 |
40 | UIApplicationSupportsIndirectInputEvents
41 |
42 | UIRequiredDeviceCapabilities
43 |
44 | armv7
45 |
46 | UISupportedInterfaceOrientations
47 |
48 | UIInterfaceOrientationPortrait
49 | UIInterfaceOrientationLandscapeLeft
50 | UIInterfaceOrientationLandscapeRight
51 |
52 | UISupportedInterfaceOrientations~ipad
53 |
54 | UIInterfaceOrientationPortrait
55 | UIInterfaceOrientationPortraitUpsideDown
56 | UIInterfaceOrientationLandscapeLeft
57 | UIInterfaceOrientationLandscapeRight
58 |
59 | UILaunchStoryboardName
60 |
61 |
62 |
63 |
--------------------------------------------------------------------------------
/.swift-format:
--------------------------------------------------------------------------------
1 | {
2 | "version": 1,
3 | "indentation": {
4 | "spaces": 4
5 | },
6 | "fileScopedDeclarationPrivacy": {
7 | "accessLevel": "private"
8 | },
9 | "indentConditionalCompilationBlocks": true,
10 | "indentSwitchCaseLabels": false,
11 | "lineBreakAroundMultilineExpressionChainComponents": false,
12 | "lineBreakBeforeControlFlowKeywords": true,
13 | "lineBreakBeforeEachArgument": true,
14 | "lineBreakBeforeEachGenericRequirement": true,
15 | "lineLength": 150,
16 | "maximumBlankLines": 1,
17 | "prioritizeKeepingFunctionOutputTogether": false,
18 | "respectsExistingLineBreaks": true,
19 | "rules": {
20 | "AllPublicDeclarationsHaveDocumentation": true,
21 | "AlwaysUseLowerCamelCase": true,
22 | "AmbiguousTrailingClosureOverload": true,
23 | "BeginDocumentationCommentWithOneLineSummary": false,
24 | "DoNotUseSemicolons": true,
25 | "DontRepeatTypeInStaticProperties": false,
26 | "FileScopedDeclarationPrivacy": true,
27 | "FullyIndirectEnum": true,
28 | "GroupNumericLiterals": true,
29 | "IdentifiersMustBeASCII": true,
30 | "NeverForceUnwrap": false,
31 | "NeverUseForceTry": true,
32 | "NeverUseImplicitlyUnwrappedOptionals": false,
33 | "NoAccessLevelOnExtensionDeclaration": false,
34 | "NoBlockComments": true,
35 | "NoCasesWithOnlyFallthrough": true,
36 | "NoEmptyTrailingClosureParentheses": true,
37 | "NoLabelsInCasePatterns": true,
38 | "NoLeadingUnderscores": false,
39 | "NoParensAroundConditions": true,
40 | "NoVoidReturnOnFunctionSignature": true,
41 | "OneCasePerLine": true,
42 | "OneVariableDeclarationPerLine": true,
43 | "OnlyOneTrailingClosureArgument": false,
44 | "OrderedImports": true,
45 | "ReturnVoidInsteadOfEmptyTuple": true,
46 | "UseEarlyExits": false,
47 | "UseLetInEveryBoundCaseVariable": true,
48 | "UseShorthandTypeNames": true,
49 | "UseSingleLinePropertyGetter": true,
50 | "UseSynthesizedInitializer": true,
51 | "UseTripleSlashForDocumentationComments": true,
52 | "UseWhereClausesInForLoops": false,
53 | "ValidateDocumentationComments": false
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/Examples/BasicUsage/APIRequestPage.swift:
--------------------------------------------------------------------------------
1 | import Hooks
2 | import SwiftUI
3 |
4 | struct Post: Codable {
5 | let id: Int
6 | let title: String
7 | let body: String
8 | }
9 |
10 | func useFetchPosts() -> (phase: AsyncPhase<[Post], Error>, fetch: () async -> Void) {
11 | let url = URL(string: "https://jsonplaceholder.typicode.com/posts")!
12 | let (phase, fetch) = useAsyncPerform { () throws -> [Post] in
13 | let decoder = JSONDecoder()
14 | let (data, _) = try await URLSession.shared.data(from: url)
15 | return try decoder.decode([Post].self, from: data)
16 | }
17 |
18 | return (phase: phase, fetch: fetch)
19 | }
20 |
21 | struct APIRequestPage: HookView {
22 | var hookBody: some View {
23 | let (phase, fetch) = useFetchPosts()
24 |
25 | ScrollView {
26 | VStack {
27 | switch phase {
28 | case .running:
29 | ProgressView()
30 |
31 | case .success(let posts):
32 | postRows(posts)
33 |
34 | case .failure(let error):
35 | errorRow(error, retry: fetch)
36 |
37 | case .pending:
38 | EmptyView()
39 | }
40 | }
41 | .padding(.vertical, 16)
42 | .padding(.horizontal, 24)
43 | }
44 | .navigationTitle("API Request")
45 | .background(Color(.systemBackground).ignoresSafeArea())
46 | .task {
47 | await fetch()
48 | }
49 | }
50 |
51 | func postRows(_ posts: [Post]) -> some View {
52 | ForEach(posts, id: \.id) { post in
53 | VStack(alignment: .leading) {
54 | Text(post.title).bold()
55 | Text(post.body).padding(.vertical, 16)
56 | Divider()
57 | }
58 | .frame(maxWidth: .infinity)
59 | }
60 | }
61 |
62 | func errorRow(_ error: Error, retry: @escaping () async -> Void) -> some View {
63 | VStack {
64 | Text("Error: \(error.localizedDescription)")
65 | .fixedSize(horizontal: false, vertical: true)
66 |
67 | Divider()
68 |
69 | Button("Refresh") {
70 | Task {
71 | await retry()
72 | }
73 | }
74 | }
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/Tests/HooksTests/Hook/UsePublisherSubscribeTests.swift:
--------------------------------------------------------------------------------
1 | import Combine
2 | import SwiftUI
3 | import XCTest
4 |
5 | @testable import Hooks
6 |
7 | final class UsePublisherSubscribeTests: XCTestCase {
8 | func testUpdate() {
9 | let subject = PassthroughSubject()
10 | let tester = HookTester(0) { value in
11 | usePublisherSubscribe {
12 | subject.map { value }
13 | }
14 | }
15 |
16 | XCTAssertEqual(tester.value.phase, .pending)
17 |
18 | tester.value.subscribe()
19 |
20 | XCTAssertEqual(tester.value.phase, .running)
21 |
22 | subject.send()
23 |
24 | XCTAssertEqual(tester.value.phase.value, 0)
25 |
26 | tester.update(with: 1)
27 | tester.value.subscribe()
28 | subject.send()
29 |
30 | XCTAssertEqual(tester.value.phase.value, 1)
31 |
32 | tester.update(with: 2)
33 | tester.value.subscribe()
34 | subject.send()
35 |
36 | XCTAssertEqual(tester.value.phase.value, 2)
37 | }
38 |
39 | func testUpdateFailure() {
40 | let subject = PassthroughSubject()
41 | let tester = HookTester(0) { value in
42 | usePublisherSubscribe {
43 | subject.map { value }
44 | }
45 | }
46 |
47 | XCTAssertEqual(tester.value.phase, .pending)
48 |
49 | tester.value.subscribe()
50 |
51 | XCTAssertEqual(tester.value.phase, .running)
52 |
53 | subject.send(completion: .failure(URLError(.badURL)))
54 |
55 | XCTAssertEqual(tester.value.phase.error, URLError(.badURL))
56 | }
57 |
58 | func testDispose() {
59 | var isSubscribed = false
60 | let subject = PassthroughSubject()
61 | let tester = HookTester {
62 | usePublisherSubscribe {
63 | subject.handleEvents(receiveSubscription: { _ in
64 | isSubscribed = true
65 | })
66 | }
67 | }
68 |
69 | XCTAssertEqual(tester.value.phase, .pending)
70 |
71 | tester.dispose()
72 | subject.send(1)
73 |
74 | XCTAssertEqual(tester.value.phase, .pending)
75 | XCTAssertFalse(isSubscribed)
76 |
77 | tester.value.subscribe()
78 | subject.send(2)
79 |
80 | XCTAssertEqual(tester.value.phase, .pending)
81 | XCTAssertFalse(isSubscribed)
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/Sources/Hooks/HookUpdateStrategy.swift:
--------------------------------------------------------------------------------
1 | public extension HookUpdateStrategy {
2 | /// A strategy that a hook will update its state just once.
3 | static var once: Self {
4 | struct Unique: Equatable {}
5 | return self.init(dependency: Unique())
6 | }
7 |
8 | /// Returns a strategy that a hook will update its state when the given value is changed.
9 | /// - Parameter value: The value to check against when determining whether to update a state of hook.
10 | /// - Returns: A strategy that a hook will update its state when the given value is changed.
11 | static func preserved(by value: Value) -> Self {
12 | self.init(dependency: value)
13 | }
14 | }
15 |
16 | /// Represents a strategy that determines when to update the state of hooks.
17 | public struct HookUpdateStrategy {
18 | /// A dependency value for updates. Hooks will attempt to update a state of hook when this value changes.
19 | public let dependency: Dependency
20 |
21 | /// Creates a new strategy with given dependency value.
22 | /// - Parameter dependency: A dependency value that to determine if a hook should update its state.
23 | public init(dependency: D) {
24 | self.dependency = Dependency(dependency)
25 | }
26 | }
27 |
28 | public extension HookUpdateStrategy {
29 | /// A type erased dependency value that to determine if a hook should update its state.
30 | struct Dependency: Equatable {
31 | private let value: Any
32 | private let equals: (Self) -> Bool
33 |
34 | /// Create a new dependency from the given equatable value.
35 | /// - Parameter value: An actual value that will be compared.
36 | public init(_ value: T) {
37 | if let key = value as? Self {
38 | self = key
39 | return
40 | }
41 |
42 | self.value = value
43 | self.equals = { other in
44 | value == other.value as? T
45 | }
46 | }
47 |
48 | /// Returns a Boolean value indicating whether two values are equal.
49 | /// - Parameters:
50 | /// - lhs: A value to compare.
51 | /// - rhs: Another value to compare.
52 | /// - Returns: A Boolean value indicating whether two values are equal.
53 | public static func == (lhs: Self, rhs: Self) -> Bool {
54 | lhs.equals(rhs)
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/Sources/Hooks/Hook/UsePublisher.swift:
--------------------------------------------------------------------------------
1 | import Combine
2 |
3 | /// A hook to use the most recent phase of asynchronous operation of the passed publisher.
4 | /// The publisher will be subscribed at the first update and will be re-subscribed according to the given `updateStrategy`.
5 | ///
6 | /// let phase = usePublisher(.once) {
7 | /// URLSession.shared.dataTaskPublisher(for: url)
8 | /// }
9 | ///
10 | /// - Parameters:
11 | /// - updateStrategy: A strategy that determines when to re-subscribe the given publisher.
12 | /// - makePublisher: A closure that to create a new publisher to be subscribed.
13 | /// - Returns: A most recent publisher phase.
14 | @discardableResult
15 | public func usePublisher(
16 | _ updateStrategy: HookUpdateStrategy,
17 | _ makePublisher: @escaping () -> P
18 | ) -> AsyncPhase {
19 | useHook(
20 | PublisherHook(
21 | updateStrategy: updateStrategy,
22 | makePublisher: makePublisher
23 | )
24 | )
25 | }
26 |
27 | private struct PublisherHook: Hook {
28 | let updateStrategy: HookUpdateStrategy?
29 | let makePublisher: () -> P
30 |
31 | func makeState() -> State {
32 | State()
33 | }
34 |
35 | func updateState(coordinator: Coordinator) {
36 | coordinator.state.phase = .running
37 | coordinator.state.cancellable = makePublisher()
38 | .sink(
39 | receiveCompletion: { completion in
40 | switch completion {
41 | case .failure(let error):
42 | coordinator.state.phase = .failure(error)
43 |
44 | case .finished:
45 | break
46 | }
47 | coordinator.updateView()
48 | },
49 | receiveValue: { output in
50 | coordinator.state.phase = .success(output)
51 | coordinator.updateView()
52 | }
53 | )
54 | }
55 |
56 | func value(coordinator: Coordinator) -> AsyncPhase {
57 | coordinator.state.phase
58 | }
59 |
60 | func dispose(state: State) {
61 | state.cancellable = nil
62 | }
63 | }
64 |
65 | private extension PublisherHook {
66 | final class State {
67 | var phase = AsyncPhase.pending
68 | var cancellable: AnyCancellable?
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/Tests/HooksTests/Hook/UsePublisherTests.swift:
--------------------------------------------------------------------------------
1 | import Combine
2 | import SwiftUI
3 | import XCTest
4 |
5 | @testable import Hooks
6 |
7 | final class UsePublisherTests: XCTestCase {
8 | func testUpdateOnce() {
9 | let subject = PassthroughSubject()
10 | let tester = HookTester(0) { value in
11 | usePublisher(.once) {
12 | subject.map { value }
13 | }
14 | }
15 |
16 | XCTAssertEqual(tester.value, .running)
17 |
18 | subject.send()
19 |
20 | XCTAssertEqual(tester.value.value, 0)
21 |
22 | tester.update(with: 1)
23 | subject.send()
24 |
25 | XCTAssertEqual(tester.value.value, 0)
26 |
27 | tester.update(with: 2)
28 | subject.send()
29 |
30 | XCTAssertEqual(tester.value.value, 0)
31 | }
32 |
33 | func testUpdatePreserved() {
34 | let subject = PassthroughSubject()
35 | let tester = HookTester((0, false)) { value, flag in
36 | usePublisher(.preserved(by: flag)) {
37 | subject.map { value }
38 | }
39 | }
40 |
41 | XCTAssertEqual(tester.value, .running)
42 |
43 | subject.send()
44 |
45 | XCTAssertEqual(tester.value.value, 0)
46 |
47 | tester.update(with: (1, false))
48 | subject.send()
49 |
50 | XCTAssertEqual(tester.value.value, 0)
51 |
52 | tester.update(with: (2, true))
53 | subject.send()
54 |
55 | XCTAssertEqual(tester.value.value, 2)
56 |
57 | tester.update(with: (3, true))
58 | subject.send()
59 |
60 | XCTAssertEqual(tester.value.value, 2)
61 | }
62 |
63 | func testUpdateFailure() {
64 | let subject = PassthroughSubject()
65 | let tester = HookTester {
66 | usePublisher(.once) {
67 | subject
68 | }
69 | }
70 |
71 | XCTAssertEqual(tester.value, .running)
72 |
73 | subject.send(completion: .failure(URLError(.badURL)))
74 |
75 | XCTAssertEqual(tester.value.error, URLError(.badURL))
76 | }
77 |
78 | func testDispose() {
79 | let subject = PassthroughSubject()
80 | let tester = HookTester {
81 | usePublisher(.once) {
82 | subject
83 | }
84 | }
85 |
86 | XCTAssertEqual(tester.value, .running)
87 |
88 | tester.dispose()
89 | subject.send(1)
90 |
91 | XCTAssertEqual(tester.value, .running)
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/Examples/TheMovieDB-MVVM/MovieDBService.swift:
--------------------------------------------------------------------------------
1 | import Combine
2 | import UIKit
3 |
4 | protocol MovieDBServiceProtocol {
5 | func getImage(path: String?, size: NetworkImageSize) async throws -> UIImage?
6 | func getTopRated(page: Int) async throws -> PagedResponse
7 | }
8 |
9 | struct MovieDBService: MovieDBServiceProtocol {
10 | private let session = URLSession(configuration: .default)
11 | private let baseURL = URL(string: "https://api.themoviedb.org/3")!
12 | private let imageBaseURL = URL(string: "https://image.tmdb.org/t/p")!
13 | private let apiKey = "3de15b0402484d3d089399ea0b8d98f1"
14 | private let jsonDecoder: JSONDecoder = {
15 | let formatter = DateFormatter()
16 | let decoder = JSONDecoder()
17 | formatter.dateFormat = "yyy-MM-dd"
18 | decoder.keyDecodingStrategy = .convertFromSnakeCase
19 | decoder.dateDecodingStrategy = .formatted(formatter)
20 | return decoder
21 | }()
22 |
23 | func getImage(path: String?, size: NetworkImageSize) async throws -> UIImage? {
24 | guard let path = path else {
25 | return nil
26 | }
27 |
28 | let url =
29 | imageBaseURL
30 | .appendingPathComponent(size.rawValue)
31 | .appendingPathComponent(path)
32 |
33 | let (data, _) = try await session.data(from: url)
34 | return UIImage(data: data)
35 | }
36 |
37 | func getTopRated(page: Int) async throws -> PagedResponse {
38 | try await get(path: "movie/top_rated", parameters: ["page": String(page)])
39 | }
40 | }
41 |
42 | private extension MovieDBService {
43 | func get(path: String, parameters: [String: String]) async throws -> Response {
44 | let (data, _) = try await session.data(for: makeGetRequest(path: path, parameters: parameters))
45 | return try jsonDecoder.decode(Response.self, from: data)
46 | }
47 |
48 | func makeGetRequest(path: String, parameters: [String: String]) -> URLRequest {
49 | let url = baseURL.appendingPathComponent(path)
50 | var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true)!
51 | var queryItems = [URLQueryItem(name: "api_key", value: apiKey)]
52 |
53 | for (name, value) in parameters {
54 | queryItems.append(URLQueryItem(name: name, value: value))
55 | }
56 |
57 | urlComponents.queryItems = queryItems
58 |
59 | var urlRequest = URLRequest(url: urlComponents.url!)
60 | urlRequest.httpMethod = "GET"
61 |
62 | return urlRequest
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/Tests/HooksTests/Hook/UseStateTests.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import XCTest
3 |
4 | @testable import Hooks
5 |
6 | final class UseStateTests: XCTestCase {
7 | func testUpdate() {
8 | let tester = HookTester {
9 | useState(0)
10 | }
11 |
12 | XCTAssertEqual(tester.value.wrappedValue, 0)
13 |
14 | tester.value.wrappedValue = 1
15 |
16 | XCTAssertEqual(tester.value.wrappedValue, 1)
17 |
18 | tester.value.wrappedValue = 2
19 |
20 | XCTAssertEqual(tester.value.wrappedValue, 2)
21 |
22 | tester.dispose()
23 | tester.update()
24 |
25 | XCTAssertEqual(tester.value.wrappedValue, 0)
26 | }
27 |
28 | func testWhenInitialStateIsChanged() {
29 | let tester = HookTester(0) { initialState in
30 | useState(initialState)
31 | }
32 |
33 | XCTAssertEqual(tester.value.wrappedValue, 0)
34 |
35 | tester.update(with: 1)
36 |
37 | XCTAssertEqual(tester.value.wrappedValue, 0)
38 |
39 | tester.update()
40 |
41 | XCTAssertEqual(tester.value.wrappedValue, 0)
42 | }
43 |
44 | func testInitialStateCreatedOnEachUpdate() {
45 | var updateCalls = 0
46 |
47 | func createState() -> Int {
48 | updateCalls += 1
49 | return 0
50 | }
51 |
52 | let tester = HookTester {
53 | useState(createState())
54 | }
55 |
56 | XCTAssertEqual(updateCalls, 1)
57 |
58 | tester.update()
59 |
60 | XCTAssertEqual(updateCalls, 2)
61 |
62 | tester.update()
63 |
64 | XCTAssertEqual(updateCalls, 3)
65 | }
66 |
67 | func testInitialStateCreateOnceWhenGivenClosure() {
68 | var closureCalls = 0
69 |
70 | func createState() -> Int {
71 | closureCalls += 1
72 | return 0
73 | }
74 |
75 | let tester = HookTester {
76 | useState {
77 | createState()
78 | }
79 | }
80 |
81 | XCTAssertEqual(closureCalls, 1)
82 |
83 | tester.update()
84 |
85 | XCTAssertEqual(closureCalls, 1)
86 |
87 | tester.update()
88 |
89 | XCTAssertEqual(closureCalls, 1)
90 | }
91 |
92 | func testDispose() {
93 | let tester = HookTester {
94 | useState(0)
95 | }
96 |
97 | tester.dispose()
98 | tester.value.wrappedValue = 1
99 |
100 | XCTAssertEqual(tester.value.wrappedValue, 0)
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/Examples/project.yml:
--------------------------------------------------------------------------------
1 | name: Examples
2 | options:
3 | bundleIdPrefix: com.ryo.swiftui-hooks-examples
4 | createIntermediateGroups: true
5 | settingGroups:
6 | app:
7 | CODE_SIGNING_REQUIRED: NO
8 | CODE_SIGN_IDENTITY: ""
9 | CODE_SIGN_STYLE: Manual
10 |
11 | schemes:
12 | TheMovieDB-MVVM:
13 | build:
14 | targets:
15 | TheMovieDB-MVVM: all
16 | test:
17 | targets:
18 | - TheMovieDB-MVVM-Tests
19 |
20 | TheMovieDB-MVVM-Tests:
21 | build:
22 | targets:
23 | TheMovieDB-MVVM-Tests: all
24 | test:
25 | targets:
26 | - TheMovieDB-MVVM-Tests
27 |
28 | BasicUsage:
29 | build:
30 | targets:
31 | BasicUsage: all
32 |
33 | Todo:
34 | build:
35 | targets:
36 | Todo: all
37 | test:
38 | targets:
39 | - Todo-UITests
40 |
41 | Todo-UITests:
42 | build:
43 | targets:
44 | Todo-UITests: all
45 | test:
46 | targets:
47 | - Todo-UITests
48 |
49 | packages:
50 | Hooks:
51 | path: ..
52 |
53 | targets:
54 | TheMovieDB-MVVM:
55 | type: application
56 | platform: iOS
57 | sources:
58 | - TheMovieDB-MVVM
59 | dependencies:
60 | - package: Hooks
61 | deploymentTarget: 15.0
62 | settings:
63 | groups:
64 | - app
65 | base:
66 | SUPPORTED_PLATFORMS: iphoneos iphonesimulator
67 | TARGETED_DEVICE_FAMILY: 1,2
68 |
69 | TheMovieDB-MVVM-Tests:
70 | type: bundle.unit-test
71 | platform: iOS
72 | sources:
73 | - TheMovieDB-MVVM-Tests
74 | dependencies:
75 | - target: TheMovieDB-MVVM
76 |
77 | BasicUsage:
78 | type: application
79 | platform: iOS
80 | sources:
81 | - BasicUsage
82 | dependencies:
83 | - package: Hooks
84 | deploymentTarget: 15.0
85 | settings:
86 | groups:
87 | - app
88 | base:
89 | SUPPORTED_PLATFORMS: iphoneos iphonesimulator
90 | TARGETED_DEVICE_FAMILY: 1,2
91 |
92 | Todo:
93 | type: application
94 | platform: iOS
95 | sources:
96 | - Todo
97 | dependencies:
98 | - package: Hooks
99 | deploymentTarget: 13.0
100 | settings:
101 | groups:
102 | - app
103 | base:
104 | SUPPORTED_PLATFORMS: iphoneos iphonesimulator appletvos appletvsimulator
105 | TARGETED_DEVICE_FAMILY: 1,2,3
106 |
107 | Todo-UITests:
108 | type: bundle.ui-testing
109 | platform: iOS
110 | deploymentTarget: 13.0
111 | sources:
112 | - Todo-UITests
113 | dependencies:
114 | - target: Todo
115 |
--------------------------------------------------------------------------------
/Sources/Hooks/Hook/UseState.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A hook to use a `Binding` wrapping current state to be updated by setting a new state to `wrappedValue`.
4 | /// Triggers a view update when the state has been changed.
5 | ///
6 | /// let count = useState {
7 | /// let initialState = expensiveComputation() // Int
8 | /// return initialState
9 | /// } // Binding
10 | ///
11 | /// Button("Increment") {
12 | /// count.wrappedValue += 1
13 | /// }
14 | ///
15 | /// - Parameter initialState: A closure creating an initial state. The closure will only be called once, during the initial render.
16 | /// - Returns: A `Binding` wrapping current state.
17 | public func useState(_ initialState: @escaping () -> State) -> Binding {
18 | useHook(StateHook(initialState: initialState))
19 | }
20 |
21 | /// A hook to use a `Binding` wrapping current state to be updated by setting a new state to `wrappedValue`.
22 | /// Triggers a view update when the state has been changed.
23 | ///
24 | /// let count = useState(0) // Binding
25 | ///
26 | /// Button("Increment") {
27 | /// count.wrappedValue += 1
28 | /// }
29 | ///
30 | /// - Parameter initialState: An initial state.
31 | /// - Returns: A `Binding` wrapping current state.
32 | public func useState(_ initialState: State) -> Binding {
33 | useState {
34 | initialState
35 | }
36 | }
37 |
38 | private struct StateHook: Hook {
39 | let initialState: () -> State
40 | var updateStrategy: HookUpdateStrategy? = .once
41 |
42 | func makeState() -> Ref {
43 | Ref(initialState: initialState())
44 | }
45 |
46 | func value(coordinator: Coordinator) -> Binding {
47 | Binding(
48 | get: {
49 | coordinator.state.state
50 | },
51 | set: { newState, transaction in
52 | assertMainThread()
53 |
54 | guard !coordinator.state.isDisposed else {
55 | return
56 | }
57 |
58 | withTransaction(transaction) {
59 | coordinator.state.state = newState
60 | coordinator.updateView()
61 | }
62 | }
63 | )
64 | }
65 |
66 | func dispose(state: Ref) {
67 | state.isDisposed = true
68 | }
69 | }
70 |
71 | private extension StateHook {
72 | final class Ref {
73 | var state: State
74 | var isDisposed = false
75 |
76 | init(initialState: State) {
77 | state = initialState
78 | }
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/Sources/Hooks/Hook/UseReducer.swift:
--------------------------------------------------------------------------------
1 | /// A hook to use the state returned by the passed `reducer`, and a `dispatch` function to send actions to update the state.
2 | /// Triggers a view update when the state has been changed.
3 | ///
4 | /// enum Action {
5 | /// case increment, decrement
6 | /// }
7 | ///
8 | /// func reducer(state: Int, action: Action) -> Int {
9 | /// switch action {
10 | /// case .increment:
11 | /// return state + 1
12 | ///
13 | /// case .decrement:
14 | /// return state - 1
15 | /// }
16 | /// }
17 | ///
18 | /// let (count, dispatch) = useReducer(reducer, initialState: 0)
19 | ///
20 | /// - Parameters:
21 | /// - reducer: A function that to return a new state with an action.
22 | /// - initialState: An initial state.
23 | /// - Returns: A tuple value that has a new state returned by the passed `reducer` and a dispatch function to send actions.
24 | public func useReducer(
25 | _ reducer: @escaping (State, Action) -> State,
26 | initialState: State
27 | ) -> (state: State, dispatch: (Action) -> Void) {
28 | useHook(ReducerHook(reducer: reducer, initialState: initialState))
29 | }
30 |
31 | private struct ReducerHook: Hook {
32 | let reducer: (State, Action) -> State
33 | let initialState: State
34 | let updateStrategy: HookUpdateStrategy? = nil
35 |
36 | func makeState() -> Ref {
37 | Ref(initialState: initialState)
38 | }
39 |
40 | func updateState(coordinator: Coordinator) {
41 | guard let action = coordinator.state.nextAction else {
42 | return
43 | }
44 |
45 | coordinator.state.state = reducer(coordinator.state.state, action)
46 | coordinator.state.nextAction = nil
47 | }
48 |
49 | func value(coordinator: Coordinator) -> (
50 | state: State,
51 | dispatch: (Action) -> Void
52 | ) {
53 | (
54 | state: coordinator.state.state,
55 | dispatch: { action in
56 | assertMainThread()
57 |
58 | guard !coordinator.state.isDisposed else {
59 | return
60 | }
61 |
62 | coordinator.state.nextAction = action
63 | coordinator.updateView()
64 | }
65 | )
66 | }
67 |
68 | func dispose(state: Ref) {
69 | state.isDisposed = true
70 | state.nextAction = nil
71 | }
72 | }
73 |
74 | private extension ReducerHook {
75 | final class Ref {
76 | var state: State
77 | var nextAction: Action?
78 | var isDisposed = false
79 |
80 | init(initialState: State) {
81 | state = initialState
82 | }
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/Sources/Hooks/Hook/UsePublisherSubscribe.swift:
--------------------------------------------------------------------------------
1 | import Combine
2 |
3 | /// 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.
4 | ///
5 | /// let (phase, subscribe) = usePublisherSubscribe {
6 | /// URLSession.shared.dataTaskPublisher(for: url)
7 | /// }
8 | ///
9 | /// - Parameter makePublisher: A closure that to create a new publisher to be subscribed.
10 | /// - Returns: A tuple of the most recent publisher phase and its subscribe function.
11 | @discardableResult
12 | public func usePublisherSubscribe(
13 | _ makePublisher: @escaping () -> P
14 | ) -> (
15 | phase: AsyncPhase,
16 | subscribe: () -> Void
17 | ) {
18 | useHook(PublisherSubscribeHook(makePublisher: makePublisher))
19 | }
20 |
21 | private struct PublisherSubscribeHook: Hook {
22 | let makePublisher: () -> P
23 | let updateStrategy: HookUpdateStrategy? = .once
24 |
25 | func makeState() -> State {
26 | State()
27 | }
28 |
29 | func value(coordinator: Coordinator) -> (
30 | phase: AsyncPhase,
31 | subscribe: () -> Void
32 | ) {
33 | (
34 | phase: coordinator.state.phase,
35 | subscribe: {
36 | assertMainThread()
37 |
38 | guard !coordinator.state.isDisposed else {
39 | return
40 | }
41 |
42 | coordinator.state.phase = .running
43 | coordinator.updateView()
44 |
45 | coordinator.state.cancellable = makePublisher()
46 | .sink(
47 | receiveCompletion: { completion in
48 | switch completion {
49 | case .failure(let error):
50 | coordinator.state.phase = .failure(error)
51 | coordinator.updateView()
52 |
53 | case .finished:
54 | break
55 | }
56 | },
57 | receiveValue: { output in
58 | coordinator.state.phase = .success(output)
59 | coordinator.updateView()
60 | }
61 | )
62 | }
63 | )
64 | }
65 |
66 | func dispose(state: State) {
67 | state.isDisposed = true
68 | state.cancellable = nil
69 | }
70 | }
71 |
72 | private extension PublisherSubscribeHook {
73 | final class State {
74 | var phase = AsyncPhase.pending
75 | var isDisposed = false
76 | var cancellable: AnyCancellable?
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/Tests/HooksTests/LinkedListTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | @testable import Hooks
4 |
5 | final class LinkedListTests: XCTestCase {
6 | func testInit() {
7 | let first = LinkedList.Node(0)
8 | let last = LinkedList.Node(1)
9 | let list1 = LinkedList(first: first, last: last)
10 | let list2 = LinkedList(first: first)
11 |
12 | XCTAssertTrue(list1.first === first)
13 | XCTAssertTrue(list1.last === last)
14 |
15 | XCTAssertTrue(list2.first === first)
16 | XCTAssertTrue(list2.last === first)
17 | }
18 |
19 | func testAppend() {
20 | var list = LinkedList()
21 |
22 | list.append(0)
23 |
24 | XCTAssertEqual(list.first?.element, 0)
25 | XCTAssertEqual(list.last?.element, 0)
26 | XCTAssertEqual(list.map(\.element), [0])
27 |
28 | list.append(1)
29 |
30 | XCTAssertEqual(list.first?.element, 0)
31 | XCTAssertEqual(list.last?.element, 1)
32 | XCTAssertEqual(list.map(\.element), [0, 1])
33 |
34 | list.append(2)
35 |
36 | XCTAssertEqual(list.first?.element, 0)
37 | XCTAssertEqual(list.last?.element, 2)
38 | XCTAssertEqual(list.map(\.element), [0, 1, 2])
39 | }
40 |
41 | func testDropSuffix() {
42 | var list = LinkedList()
43 |
44 | list.append(0)
45 | list.append(1)
46 | let node = list.append(2)
47 | list.append(3)
48 |
49 | XCTAssertEqual(list.first?.element, 0)
50 | XCTAssertEqual(list.last?.element, 3)
51 | XCTAssertEqual(list.map(\.element), [0, 1, 2, 3])
52 |
53 | let suffix = list.dropSuffix(from: node)
54 |
55 | XCTAssertEqual(list.first?.element, 0)
56 | XCTAssertEqual(list.last?.element, 1)
57 | XCTAssertEqual(list.map(\.element), [0, 1])
58 |
59 | XCTAssertEqual(suffix.first?.element, 2)
60 | XCTAssertEqual(suffix.last?.element, 3)
61 | XCTAssertEqual(suffix.map(\.element), [2, 3])
62 | }
63 |
64 | func testReversed() {
65 | var list = LinkedList()
66 |
67 | list.append(0)
68 | list.append(1)
69 | list.append(2)
70 |
71 | XCTAssertEqual(list.reversed().map(\.element), [2, 1, 0])
72 | }
73 |
74 | func testIterator() {
75 | var list = LinkedList()
76 |
77 | list.append(0)
78 | list.append(1)
79 | list.append(2)
80 |
81 | var iterator = list.makeIterator()
82 |
83 | XCTAssertEqual(iterator.next()?.element, 0)
84 | XCTAssertEqual(iterator.next()?.element, 1)
85 | XCTAssertEqual(iterator.next()?.element, 2)
86 | }
87 |
88 | func testSwap() {
89 | let node = LinkedList.Node(0)
90 | let old = node.swap(element: 1)
91 |
92 | XCTAssertEqual(node.element, 1)
93 | XCTAssertEqual(old, 0)
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/Examples/Todo/TodoPage.swift:
--------------------------------------------------------------------------------
1 | import Hooks
2 | import SwiftUI
3 |
4 | typealias TodoContext = Context>
5 |
6 | struct TodoPage: HookView {
7 | var hookBody: some View {
8 | let todos = useState(["Contribute to SwiftUI Hooks"])
9 |
10 | return NavigationView {
11 | ScrollView {
12 | TodoContext.Provider(value: todos) {
13 | VStack {
14 | todoInput
15 | todoContent
16 | }
17 | .padding(.vertical, 16)
18 | }
19 | .navigationBarTitle("TODO")
20 | }
21 | }
22 | .navigationViewStyle(StackNavigationViewStyle())
23 | }
24 |
25 | var todoInput: some View {
26 | let todos = useContext(TodoContext.self)
27 | let text = useState("")
28 |
29 | return Row {
30 | TextField(
31 | "Enter new task",
32 | text: text,
33 | onCommit: {
34 | guard !text.wrappedValue.isEmpty else { return }
35 | todos.wrappedValue.append(text.wrappedValue)
36 | text.wrappedValue = ""
37 | }
38 | )
39 | .accessibility(identifier: "input")
40 | .padding(16)
41 | .background(
42 | RoundedRectangle(cornerRadius: 4)
43 | .strokeBorder(lineWidth: 1)
44 | )
45 | }
46 | }
47 |
48 | var todoContent: some View {
49 | let todos = useContext(TodoContext.self)
50 |
51 | return ForEach(0..: View {
74 | let content: Content
75 |
76 | init(@ViewBuilder content: () -> Content) {
77 | self.content = content()
78 | }
79 |
80 | var body: some View {
81 | VStack(alignment: .leading) {
82 | HStack {
83 | content
84 | }
85 | .padding(.vertical, 16)
86 |
87 | Divider()
88 | }
89 | .padding(.horizontal, 24)
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/Sources/Hooks/Internals/LinkedList.swift:
--------------------------------------------------------------------------------
1 | internal struct LinkedList: Sequence {
2 | final class Node {
3 | fileprivate(set) var element: Element
4 | fileprivate(set) var next: Node?
5 | fileprivate(set) weak var previous: Node?
6 |
7 | init(_ element: Element) {
8 | self.element = element
9 | }
10 |
11 | @discardableResult
12 | func swap(element newElement: Element) -> Element {
13 | let oldElement = element
14 | element = newElement
15 | return oldElement
16 | }
17 | }
18 |
19 | private(set) var first: Node?
20 | private(set) weak var last: Node?
21 |
22 | init(first: Node? = nil, last: Node? = nil) {
23 | self.first = first
24 | self.last = last ?? first
25 | }
26 |
27 | @discardableResult
28 | mutating func append(_ newElement: Element) -> Node {
29 | let node = Node(newElement)
30 |
31 | if let last = last {
32 | node.previous = last
33 | last.next = node
34 | }
35 | else {
36 | first = node
37 | }
38 |
39 | last = node
40 |
41 | return node
42 | }
43 |
44 | mutating func dropSuffix(from node: Node) -> LinkedList {
45 | let previousLast = last
46 |
47 | if let previous = node.previous {
48 | previous.next = nil
49 | }
50 | else {
51 | first = nil
52 | }
53 |
54 | last = node.previous
55 | node.previous = nil
56 |
57 | return LinkedList(first: node, last: previousLast)
58 | }
59 |
60 | func reversed() -> Reversed {
61 | Reversed(last: last)
62 | }
63 |
64 | func makeIterator() -> Iterator {
65 | Iterator(first: first)
66 | }
67 | }
68 |
69 | internal extension LinkedList {
70 | struct Reversed: Sequence {
71 | let last: Node?
72 |
73 | func makeIterator() -> Iterator {
74 | Iterator(last: last)
75 | }
76 |
77 | struct Iterator: IteratorProtocol {
78 | private var previousNode: Node?
79 |
80 | init(last: Node?) {
81 | previousNode = last
82 | }
83 |
84 | mutating func next() -> Node? {
85 | let node = previousNode
86 | previousNode = node?.previous
87 | return node
88 | }
89 | }
90 | }
91 |
92 | struct Iterator: IteratorProtocol {
93 | private var nextNode: Node?
94 |
95 | init(first: Node?) {
96 | nextNode = first
97 | }
98 |
99 | mutating func next() -> Node? {
100 | let node = nextNode
101 | nextNode = node?.next
102 | return node
103 | }
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/Sources/Hooks/Hook.swift:
--------------------------------------------------------------------------------
1 | /// `Hook` manages the state and overall behavior of a hook. It has lifecycles to manage the state and when to update the value.
2 | /// It must be immutable, and should not have any state in itself, but should perform appropriate operations on the state managed by the internal system passed to lifecycle functions.
3 | ///
4 | /// Use it when your custom hook becomes too complex can not be made with existing hooks composition.
5 | public protocol Hook {
6 | /// The type of state that is used to preserves the value returned by this hook.
7 | associatedtype State = Void
8 |
9 | /// The type of value that this hook returns.
10 | associatedtype Value
11 |
12 | /// The type of contextual information about the state of the hook.
13 | typealias Coordinator = HookCoordinator
14 |
15 | /// A strategy that determines when to update the state.
16 | var updateStrategy: HookUpdateStrategy? { get }
17 |
18 | /// Indicates whether the value should be updated after all hooks have been evaluated.
19 | var shouldDeferredUpdate: Bool { get }
20 |
21 | /// Returns a initial state of this hook.
22 | /// Internal system calls this function to create a state at first time each hook is evaluated.
23 | func makeState() -> State
24 |
25 | /// Updates the state when the `updateStrategy` determines that an update is necessary.
26 | /// - Parameter coordinator: A contextual information about the state of the hook.
27 | func updateState(coordinator: Coordinator)
28 |
29 | /// Returns a value which is returned when this hook is called.
30 | /// - Parameter coordinator: A contextual information about the state of the hook.
31 | func value(coordinator: Coordinator) -> Value
32 |
33 | /// Dispose of the state and interrupt running asynchronous operation.
34 | func dispose(state: State)
35 | }
36 |
37 | public extension Hook {
38 | /// Indicates whether the value should be updated after other hooks have been updated.
39 | /// Default is `false`.
40 | var shouldDeferredUpdate: Bool { false }
41 |
42 | /// Updates the state when the `updateStrategy` determines that an update is necessary.
43 | /// Does not do anything by default.
44 | /// - Parameter coordinator: A contextual information about the state of the hook.
45 | func updateState(coordinator: Coordinator) {}
46 |
47 | /// Dispose of the state and interrupt running asynchronous operation.
48 | /// Does not do anything by default.
49 | func dispose(state: State) {}
50 | }
51 |
52 | public extension Hook where State == Void {
53 | /// Returns a initial state of this hook.
54 | /// Internal system calls this function to create a state at first time each hook is evaluated.
55 | /// Default is Void.
56 | func makeState() -> State { () }
57 | }
58 |
59 | public extension Hook where Value == Void {
60 | /// Returns a value for each hook call.
61 | /// Default is Void.
62 | /// - Parameter coordinator: A contextual information about the state of the hook.
63 | func value(coordinator: Coordinator) {}
64 | }
65 |
--------------------------------------------------------------------------------
/Sources/Hooks/Hook/UseEffect.swift:
--------------------------------------------------------------------------------
1 | /// A hook to use a side effect function that is called the number of times according to the strategy specified with `updateStrategy`.
2 | /// Optionally the function can be cancelled when this hook is disposed or when the side-effect function is called again.
3 | /// Note that the execution is deferred until after other hooks have been updated.
4 | ///
5 | /// useEffect {
6 | /// print("Do side effects")
7 | ///
8 | /// return {
9 | /// print("Do cleanup")
10 | /// }
11 | /// }
12 | ///
13 | /// - Parameters:
14 | /// - updateStrategy: A strategy that determines when to re-call the given side effect function.
15 | /// - effect: A closure that typically represents a side-effect.
16 | /// It is able to return a closure that to do something when this hook is unmount from the view or when the side-effect function is called again.
17 | public func useEffect(
18 | _ updateStrategy: HookUpdateStrategy? = nil,
19 | _ effect: @escaping () -> (() -> Void)?
20 | ) {
21 | useHook(
22 | EffectHook(
23 | updateStrategy: updateStrategy,
24 | shouldDeferredUpdate: true,
25 | effect: effect
26 | )
27 | )
28 | }
29 |
30 | /// A hook to use a side effect function that is called the number of times according to the strategy specified with `updateStrategy`.
31 | /// Optionally the function can be cancelled when this hook is unmount from the view tree or when the side-effect function is called again.
32 | /// The signature is identical to `useEffect`, but this fires synchronously when the hook is called.
33 | ///
34 | /// useLayoutEffect {
35 | /// print("Do side effects")
36 | /// return nil
37 | /// }
38 | ///
39 | /// - Parameters:
40 | /// - updateStrategy: A strategy that determines when to re-call the given side effect function.
41 | /// - effect: A closure that typically represents a side-effect.
42 | /// It is able to return a closure that to do something when this hook is unmount from the view or when the side-effect function is called again.
43 | public func useLayoutEffect(
44 | _ updateStrategy: HookUpdateStrategy? = nil,
45 | _ effect: @escaping () -> (() -> Void)?
46 | ) {
47 | useHook(
48 | EffectHook(
49 | updateStrategy: updateStrategy,
50 | shouldDeferredUpdate: false,
51 | effect: effect
52 | )
53 | )
54 | }
55 |
56 | private struct EffectHook: Hook {
57 | let updateStrategy: HookUpdateStrategy?
58 | let shouldDeferredUpdate: Bool
59 | let effect: () -> (() -> Void)?
60 |
61 | func makeState() -> State {
62 | State()
63 | }
64 |
65 | func updateState(coordinator: Coordinator) {
66 | coordinator.state.cleanup?()
67 | coordinator.state.cleanup = effect()
68 | }
69 |
70 | func dispose(state: State) {
71 | state.cleanup?()
72 | state.cleanup = nil
73 | }
74 | }
75 |
76 | private extension EffectHook {
77 | final class State {
78 | var cleanup: (() -> Void)?
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/Sources/Hooks/HookScope.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A view that hosts the state of hooks.
4 | /// All hooks should be called within the evaluation of this view's body.
5 | /// The state of hooks are hosted by this view, and changes in state will cause re-evaluation the body of this view.
6 | /// It is possible to limit the scope of re-evaluation by wrapping the views that use hooks in a `HookScope`.
7 | ///
8 | /// struct ContentView: View {
9 | /// var body: some View {
10 | /// HookScope {
11 | /// let count = useState(0)
12 | ///
13 | /// Button("\(count.wrappedValue)") {
14 | /// count.wrappedValue += 1
15 | /// }
16 | /// }
17 | /// }
18 | /// }
19 | public struct HookScope: View {
20 | private let content: () -> Content
21 |
22 | /// Creates a `HookScope` that hosts the state of hooks.
23 | /// - Parameter content: A content view that uses the hooks.
24 | public init(@ViewBuilder _ content: @escaping () -> Content) {
25 | self.content = content
26 | }
27 |
28 | /// The content and behavior of the hook scoped view.
29 | public var body: some View {
30 | if #available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) {
31 | HookScopeBody(content)
32 | }
33 | else {
34 | HookScopeCompatBody(content)
35 | }
36 | }
37 | }
38 |
39 | @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)
40 | private struct HookScopeBody: View {
41 | @StateObject
42 | private var dispatcher = HookDispatcher()
43 |
44 | @Environment(\.self)
45 | private var environment
46 |
47 | private let content: () -> Content
48 |
49 | init(@ViewBuilder _ content: @escaping () -> Content) {
50 | self.content = content
51 | }
52 |
53 | var body: some View {
54 | dispatcher.scoped(environment: environment, content)
55 | }
56 | }
57 |
58 | @available(iOS, deprecated: 14.0)
59 | @available(macOS, deprecated: 11.0)
60 | @available(tvOS, deprecated: 14.0)
61 | @available(watchOS, deprecated: 7.0)
62 | private struct HookScopeCompatBody: View {
63 | struct Body: View {
64 | @ObservedObject
65 | private var dispatcher: HookDispatcher
66 |
67 | @Environment(\.self)
68 | private var environment
69 |
70 | private let content: () -> Content
71 |
72 | init(dispatcher: HookDispatcher, @ViewBuilder _ content: @escaping () -> Content) {
73 | self.dispatcher = dispatcher
74 | self.content = content
75 | }
76 |
77 | var body: some View {
78 | dispatcher.scoped(environment: environment, content)
79 | }
80 | }
81 |
82 | @State
83 | private var dispatcher = HookDispatcher()
84 | private let content: () -> Content
85 |
86 | init(@ViewBuilder _ content: @escaping () -> Content) {
87 | self.content = content
88 | }
89 |
90 | var body: Body {
91 | Body(dispatcher: dispatcher, content)
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/Examples/TheMovieDB-MVVM-Tests/UseTopRatedMoviesViewModelTests.swift:
--------------------------------------------------------------------------------
1 | import Hooks
2 | import XCTest
3 |
4 | @testable import TheMovieDB_MVVM
5 |
6 | @MainActor
7 | final class UseTopRatedMoviesViewModelTests: XCTestCase {
8 | func testSelectedMovie() {
9 | let tester = HookTester {
10 | useTopRatedMoviesViewModel()
11 | } environment: {
12 | $0[Context.self] = Dependency(
13 | service: MovieDBServiceMock()
14 | )
15 | }
16 |
17 | XCTAssertNil(tester.value.selectedMovie.wrappedValue)
18 |
19 | tester.value.selectedMovie.wrappedValue = .stub
20 |
21 | XCTAssertEqual(tester.value.selectedMovie.wrappedValue, .stub)
22 | }
23 |
24 | func testLoad() async {
25 | let service = MovieDBServiceMock()
26 | let tester = HookTester {
27 | useTopRatedMoviesViewModel()
28 | } environment: {
29 | $0[Context.self] = Dependency(service: service)
30 | }
31 |
32 | let movies = Array(repeating: Movie.stub, count: 3)
33 |
34 | XCTAssertTrue(tester.value.loadPhase.isPending)
35 |
36 | service.moviesResult = .success(movies)
37 | await tester.value.load()
38 |
39 | XCTAssertEqual(tester.value.loadPhase.value, movies)
40 | }
41 |
42 | func testLoadNext() async {
43 | let service = MovieDBServiceMock()
44 | let tester = HookTester {
45 | useTopRatedMoviesViewModel()
46 | } environment: {
47 | $0[Context.self] = Dependency(service: service)
48 | }
49 |
50 | let movies = Array(repeating: Movie.stub, count: 3)
51 | service.moviesResult = .success(movies)
52 |
53 | XCTAssertTrue(tester.value.loadPhase.isPending)
54 |
55 | await tester.value.loadNext()
56 |
57 | XCTAssertTrue(tester.value.loadPhase.isPending)
58 |
59 | await tester.value.load()
60 |
61 | XCTAssertEqual(tester.value.loadPhase.value, movies)
62 |
63 | await tester.value.loadNext()
64 |
65 | XCTAssertEqual(tester.value.loadPhase.value, movies + movies)
66 | }
67 |
68 | func testHasNext() async {
69 | let service = MovieDBServiceMock()
70 | let tester = HookTester {
71 | useTopRatedMoviesViewModel()
72 | } environment: {
73 | $0[Context.self] = Dependency(service: service)
74 | }
75 |
76 | XCTAssertFalse(tester.value.hasNextPage)
77 |
78 | service.moviesResult = .success([])
79 | await tester.value.load()
80 |
81 | XCTAssertTrue(tester.value.hasNextPage)
82 |
83 | service.totalPages = 0
84 | await tester.value.load()
85 |
86 | XCTAssertFalse(tester.value.hasNextPage)
87 | }
88 | }
89 |
90 | private extension Movie {
91 | static let stub = Movie(
92 | id: 0,
93 | title: "",
94 | overview: nil,
95 | posterPath: nil,
96 | backdropPath: nil,
97 | voteAverage: 0,
98 | releaseDate: Date(timeIntervalSince1970: 0)
99 | )
100 | }
101 |
--------------------------------------------------------------------------------
/Tests/HooksTests/Hook/UseAsyncPerformTests.swift:
--------------------------------------------------------------------------------
1 | import Combine
2 | import SwiftUI
3 | import XCTest
4 |
5 | @testable import Hooks
6 |
7 | @MainActor
8 | final class UseAsyncPerformTests: XCTestCase {
9 | func testUpdate() async {
10 | let tester = HookTester(0) { value in
11 | useAsyncPerform { () async -> Int in
12 | try? await Task.sleep(nanoseconds: 50_000_000)
13 | return value
14 | }
15 | }
16 |
17 | XCTAssertEqual(tester.value.phase, .pending)
18 |
19 | await tester.value.perform()
20 |
21 | XCTAssertEqual(tester.value.phase.value, 0)
22 |
23 | tester.update(with: 1)
24 | await tester.value.perform()
25 | XCTAssertEqual(tester.value.phase.value, 1)
26 |
27 | tester.update(with: 2)
28 | await tester.value.perform()
29 | XCTAssertEqual(tester.value.phase.value, 2)
30 | }
31 |
32 | func testUpdateWithError() async {
33 | let tester = HookTester(0) { value in
34 | useAsyncPerform { () async throws -> Int in
35 | try await Task.sleep(nanoseconds: 50_000_000)
36 | throw TestError(value: value)
37 | }
38 | }
39 |
40 | XCTAssertTrue(tester.value.phase.isPending)
41 |
42 | await tester.value.perform()
43 |
44 | XCTAssertEqual(tester.value.phase.error as? TestError, TestError(value: 0))
45 |
46 | tester.update(with: 1)
47 | await tester.value.perform()
48 | XCTAssertEqual(tester.value.phase.error as? TestError, TestError(value: 1))
49 |
50 | tester.update(with: 2)
51 | await tester.value.perform()
52 | XCTAssertEqual(tester.value.phase.error as? TestError, TestError(value: 2))
53 | }
54 |
55 | func testDispose() async {
56 | var isPerformed = false
57 | let tester = HookTester {
58 | useAsyncPerform { () async -> Int in
59 | isPerformed = true
60 | return 0
61 | }
62 | }
63 |
64 | XCTAssertTrue(tester.value.phase.isPending)
65 |
66 | tester.dispose()
67 | wait(timeout: 0.1)
68 |
69 | XCTAssertTrue(tester.value.phase.isPending)
70 | XCTAssertFalse(isPerformed)
71 |
72 | await tester.value.perform()
73 |
74 | XCTAssertTrue(tester.value.phase.isPending)
75 | XCTAssertFalse(isPerformed)
76 | }
77 |
78 | func testDisposeWithError() async {
79 | var isPerformed = false
80 | let tester = HookTester {
81 | useAsyncPerform { () async throws -> Int in
82 | isPerformed = true
83 | throw TestError(value: 0)
84 | }
85 | }
86 |
87 | XCTAssertTrue(tester.value.phase.isPending)
88 |
89 | tester.dispose()
90 | wait(timeout: 0.1)
91 |
92 | XCTAssertTrue(tester.value.phase.isPending)
93 | XCTAssertFalse(isPerformed)
94 |
95 | await tester.value.perform()
96 |
97 | XCTAssertTrue(tester.value.phase.isPending)
98 | XCTAssertFalse(isPerformed)
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/Tests/HooksTests/Testing/HookTesterTests.swift:
--------------------------------------------------------------------------------
1 | import Hooks
2 | import SwiftUI
3 | import XCTest
4 |
5 | final class HookTesterTests: XCTestCase {
6 | func testValue() {
7 | let tester = HookTester {
8 | useState(0)
9 | }
10 |
11 | XCTAssertEqual(tester.value.wrappedValue, 0)
12 |
13 | tester.value.wrappedValue = 1
14 |
15 | XCTAssertEqual(tester.value.wrappedValue, 1)
16 | }
17 |
18 | func testValueHistory() {
19 | let tester = HookTester(0) { value in
20 | useMemo(.preserved(by: value)) {
21 | value
22 | }
23 | }
24 |
25 | tester.update(with: 1)
26 | tester.update(with: 2)
27 | tester.update(with: 3)
28 |
29 | XCTAssertEqual(tester.valueHistory, [0, 1, 2, 3])
30 | }
31 |
32 | func testUpdateWithParameter() {
33 | let tester = HookTester(0) { value in
34 | useMemo(.preserved(by: value)) {
35 | value
36 | }
37 | }
38 |
39 | XCTAssertEqual(tester.value, 0)
40 |
41 | tester.update(with: 1)
42 |
43 | XCTAssertEqual(tester.value, 1)
44 |
45 | tester.update(with: 2)
46 |
47 | XCTAssertEqual(tester.value, 2)
48 |
49 | XCTAssertEqual(tester.valueHistory, [0, 1, 2])
50 | }
51 |
52 | func testUpdate() {
53 | var value = 0
54 | let tester = HookTester {
55 | useMemo(.preserved(by: value)) {
56 | value
57 | }
58 | }
59 |
60 | XCTAssertEqual(tester.value, 0)
61 |
62 | value = 1
63 | tester.update()
64 |
65 | XCTAssertEqual(tester.value, 1)
66 |
67 | value = 2
68 | tester.update()
69 |
70 | XCTAssertEqual(tester.value, 2)
71 |
72 | XCTAssertEqual(tester.valueHistory, [0, 1, 2])
73 | }
74 |
75 | func testDispose() {
76 | var isCleanedup = false
77 | let tester = HookTester {
78 | useEffect(.once) {
79 | { isCleanedup = true }
80 | }
81 | }
82 |
83 | XCTAssertFalse(isCleanedup)
84 |
85 | tester.dispose()
86 |
87 | XCTAssertTrue(isCleanedup)
88 | }
89 |
90 | func testEnvironment() {
91 | let tester = HookTester {
92 | useEnvironment(\.testValue)
93 | } environment: {
94 | $0.testValue = 0
95 | }
96 |
97 | XCTAssertEqual(tester.value, 0)
98 | }
99 |
100 | func testContext() {
101 | enum Value: Int {
102 | case a, b, c
103 | }
104 |
105 | typealias ValueContext = Context
106 |
107 | let tester = HookTester {
108 | useContext(ValueContext.self)
109 | } environment: {
110 | $0[ValueContext.self] = .a
111 | }
112 |
113 | XCTAssertEqual(tester.value, .a)
114 | }
115 | }
116 |
117 | private extension EnvironmentValues {
118 | enum TestValueKey: EnvironmentKey {
119 | static var defaultValue: Int? { nil }
120 | }
121 |
122 | var testValue: Int? {
123 | get { self[TestValueKey.self] }
124 | set { self[TestValueKey.self] = newValue }
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/Sources/Hooks/Testing/HookTester.swift:
--------------------------------------------------------------------------------
1 | import Combine
2 | import SwiftUI
3 |
4 | /// A testing tool that simulates the behaviors on a view of a given hook
5 | /// and manages the resulting values.
6 | public final class HookTester {
7 | /// The latest result value that the given Hook was executed.
8 | public private(set) var value: Value
9 |
10 | /// A history of the resulting values of the given Hook being executed.
11 | public private(set) var valueHistory: [Value]
12 |
13 | private var currentParameter: Parameter
14 | private let hook: (Parameter) -> Value
15 | private let dispatcher = HookDispatcher()
16 | private let environment: EnvironmentValues
17 | private var cancellable: AnyCancellable?
18 |
19 | /// Creates a new tester that simulates the behavior on a view of a given hook
20 | /// and manages the resulting values.
21 | /// - Parameters:
22 | /// - initialParameter: An initial value of the parameter passed when calling the hook.
23 | /// - hook: A closure for calling the hook under test.
24 | /// - environment: A closure for mutating an `EnvironmentValues` that to be used for testing environment.
25 | public init(
26 | _ initialParameter: Parameter,
27 | _ hook: @escaping (Parameter) -> Value,
28 | environment: (inout EnvironmentValues) -> Void = { _ in }
29 | ) {
30 | var environmentValues = EnvironmentValues()
31 | environment(&environmentValues)
32 |
33 | self.currentParameter = initialParameter
34 | self.hook = hook
35 | self.value = dispatcher.scoped(
36 | environment: environmentValues,
37 | { hook(initialParameter) }
38 | )
39 | self.valueHistory = [value]
40 | self.environment = environmentValues
41 | self.cancellable = dispatcher.objectWillChange
42 | .sink(receiveValue: { [weak self] in
43 | self?.update()
44 | })
45 | }
46 |
47 | /// Creates a new tester that simulates the behavior on a view of a given hook
48 | /// and manages the resulting values.
49 | /// - Parameters:
50 | /// - hook: A closure for running the hook under test.
51 | /// - environment: A closure for mutating an `EnvironmentValues` that to be used for testing environment.
52 | public convenience init(
53 | _ hook: @escaping (Parameter) -> Value,
54 | environment: (inout EnvironmentValues) -> Void = { _ in }
55 | ) where Parameter == Void {
56 | self.init((), hook, environment: environment)
57 | }
58 |
59 | /// Simulate a view update and re-call the hook under test with a given parameter.
60 | /// - Parameter parameter: A parameter value passed when calling the hook.
61 | public func update(with parameter: Parameter) {
62 | value = dispatcher.scoped(
63 | environment: environment,
64 | { hook(parameter) }
65 | )
66 | valueHistory.append(value)
67 | currentParameter = parameter
68 | }
69 |
70 | /// Simulate a view update and re-call the hook under test with the latest parameter that already applied.
71 | public func update() {
72 | update(with: currentParameter)
73 | }
74 |
75 | /// Simulate view unmounting and disposes the hook under test.
76 | public func dispose() {
77 | dispatcher.disposeAll()
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/Examples/Examples.xcodeproj/xcshareddata/xcschemes/BasicUsage.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
9 |
10 |
16 |
22 |
23 |
24 |
25 |
26 |
32 |
33 |
34 |
35 |
41 |
42 |
43 |
44 |
54 |
56 |
62 |
63 |
64 |
65 |
71 |
73 |
79 |
80 |
81 |
82 |
84 |
85 |
88 |
89 |
90 |
--------------------------------------------------------------------------------
/Tests/HooksTests/Hook/UseAsyncTests.swift:
--------------------------------------------------------------------------------
1 | import Combine
2 | import SwiftUI
3 | import XCTest
4 |
5 | @testable import Hooks
6 |
7 | @MainActor
8 | final class UseAsyncTests: XCTestCase {
9 | func testUpdateOnce() {
10 | let tester = HookTester(0) { value in
11 | useAsync(.once) {
12 | value
13 | }
14 | }
15 |
16 | XCTAssertEqual(tester.value, .running)
17 |
18 | wait(timeout: 0.1)
19 | XCTAssertEqual(tester.value.value, 0)
20 |
21 | tester.update(with: 1)
22 | wait(timeout: 0.1)
23 | XCTAssertEqual(tester.value.value, 0)
24 |
25 | tester.update(with: 2)
26 | wait(timeout: 0.1)
27 | XCTAssertEqual(tester.value.value, 0)
28 | }
29 |
30 | func testUpdateOnceWithError() {
31 | let tester = HookTester(0) { _ in
32 | useAsync(.once) { () async throws -> Int in
33 | throw TestError(value: 0)
34 | }
35 | }
36 |
37 | XCTAssertTrue(tester.value.isRunning)
38 |
39 | wait(timeout: 0.1)
40 |
41 | XCTAssertEqual(
42 | tester.valueHistory.map {
43 | $0.mapError { $0 as! TestError }
44 | },
45 | [.running, .failure(TestError(value: 0))]
46 | )
47 | }
48 |
49 | func testUpdatePreserved() {
50 | let tester = HookTester((0, false)) { value, flag in
51 | useAsync(.preserved(by: flag)) {
52 | value
53 | }
54 | }
55 |
56 | XCTAssertTrue(tester.value.isRunning)
57 |
58 | wait(timeout: 0.1)
59 | XCTAssertEqual(tester.value.value, 0)
60 |
61 | tester.update(with: (1, false))
62 | wait(timeout: 0.1)
63 | XCTAssertEqual(tester.value.value, 0)
64 |
65 | tester.update(with: (2, true))
66 | wait(timeout: 0.1)
67 | XCTAssertEqual(tester.value.value, 2)
68 |
69 | tester.update(with: (3, true))
70 | wait(timeout: 0.1)
71 | XCTAssertEqual(tester.value.value, 2)
72 | }
73 |
74 | func testUpdatePreservedWithError() {
75 | let tester = HookTester((0, false)) { value, flag in
76 | useAsync(.preserved(by: flag)) { () async throws -> Int in
77 | throw TestError(value: value)
78 | }
79 | }
80 |
81 | XCTAssertTrue(tester.value.isRunning)
82 |
83 | wait(timeout: 0.1)
84 | XCTAssertEqual(tester.value.error as? TestError, TestError(value: 0))
85 |
86 | tester.update(with: (1, false))
87 | wait(timeout: 0.1)
88 | XCTAssertEqual(tester.value.error as? TestError, TestError(value: 0))
89 |
90 | tester.update(with: (2, true))
91 | wait(timeout: 0.1)
92 | XCTAssertEqual(tester.value.error as? TestError, TestError(value: 2))
93 |
94 | tester.update(with: (3, true))
95 | wait(timeout: 0.1)
96 | XCTAssertEqual(tester.value.error as? TestError, TestError(value: 2))
97 | }
98 |
99 | func testDispose() {
100 | let tester = HookTester {
101 | useAsync(.once) { () async -> Int in
102 | try? await Task.sleep(nanoseconds: 50_000_000)
103 | return 0
104 | }
105 | }
106 |
107 | XCTAssertTrue(tester.value.isRunning)
108 |
109 | tester.dispose()
110 | wait(timeout: 0.1)
111 |
112 | XCTAssertTrue(tester.value.isRunning)
113 | }
114 |
115 | func testDisposeWithError() {
116 | let tester = HookTester {
117 | useAsync(.once) { () async throws -> Int in
118 | try await Task.sleep(nanoseconds: 50_000_000)
119 | throw TestError(value: 0)
120 | }
121 | }
122 |
123 | XCTAssertTrue(tester.value.isRunning)
124 |
125 | tester.dispose()
126 | wait(timeout: 0.1)
127 |
128 | XCTAssertTrue(tester.value.isRunning)
129 | }
130 | }
131 |
--------------------------------------------------------------------------------
/Examples/Examples.xcodeproj/xcshareddata/xcschemes/Todo.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
9 |
10 |
16 |
22 |
23 |
24 |
25 |
26 |
32 |
33 |
35 |
41 |
42 |
43 |
44 |
45 |
51 |
52 |
53 |
54 |
55 |
56 |
66 |
68 |
74 |
75 |
76 |
77 |
83 |
85 |
91 |
92 |
93 |
94 |
96 |
97 |
100 |
101 |
102 |
--------------------------------------------------------------------------------
/Examples/Examples.xcodeproj/xcshareddata/xcschemes/Todo-UITests.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
9 |
10 |
16 |
22 |
23 |
24 |
25 |
26 |
32 |
33 |
35 |
41 |
42 |
43 |
44 |
45 |
51 |
52 |
53 |
54 |
55 |
56 |
66 |
67 |
73 |
74 |
75 |
76 |
82 |
84 |
90 |
91 |
92 |
93 |
95 |
96 |
99 |
100 |
101 |
--------------------------------------------------------------------------------
/Examples/Examples.xcodeproj/xcshareddata/xcschemes/TheMovieDB-MVVM.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
9 |
10 |
16 |
22 |
23 |
24 |
25 |
26 |
32 |
33 |
35 |
41 |
42 |
43 |
44 |
45 |
51 |
52 |
53 |
54 |
55 |
56 |
66 |
68 |
74 |
75 |
76 |
77 |
83 |
85 |
91 |
92 |
93 |
94 |
96 |
97 |
100 |
101 |
102 |
--------------------------------------------------------------------------------
/Examples/Examples.xcodeproj/xcshareddata/xcschemes/TheMovieDB-MVVM-Tests.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
9 |
10 |
16 |
22 |
23 |
24 |
25 |
26 |
32 |
33 |
35 |
41 |
42 |
43 |
44 |
45 |
51 |
52 |
53 |
54 |
55 |
56 |
66 |
67 |
73 |
74 |
75 |
76 |
82 |
84 |
90 |
91 |
92 |
93 |
95 |
96 |
99 |
100 |
101 |
--------------------------------------------------------------------------------
/Sources/Hooks/Hook/UseAsyncPerform.swift:
--------------------------------------------------------------------------------
1 | /// A hook to use the most recent phase of the passed non-throwing asynchronous operation, and a `perform` function to call the it at arbitrary timing.
2 | ///
3 | /// let (phase, perform) = useAsyncPerform {
4 | /// try! await URLSession.shared.data(from: url)
5 | /// }
6 | ///
7 | /// - Parameter operation: A closure that produces a resulting value asynchronously.
8 | /// - Returns: A tuple of the most recent async phase and its perform function.
9 | @discardableResult
10 | public func useAsyncPerform