├── .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( 11 | _ operation: @escaping @MainActor () async -> Output 12 | ) -> ( 13 | phase: AsyncPhase, 14 | perform: @MainActor () async -> Void 15 | ) { 16 | useHook(AsyncPerformHook(operation: operation)) 17 | } 18 | 19 | /// A hook to use the most recent phase of the passed throwing asynchronous operation, and a `perform` function to call the it at arbitrary timing. 20 | /// 21 | /// let (phase, perform) = useAsyncPerform { 22 | /// try await URLSession.shared.data(from: url) 23 | /// } 24 | /// 25 | /// - Parameter operation: A closure that produces a resulting value asynchronously. 26 | /// - Returns: A most recent async phase. 27 | @discardableResult 28 | public func useAsyncPerform( 29 | _ operation: @escaping @MainActor () async throws -> Output 30 | ) -> ( 31 | phase: AsyncPhase, 32 | perform: @MainActor () async -> Void 33 | ) { 34 | useHook(AsyncThrowingPerformHook(operation: operation)) 35 | } 36 | 37 | internal struct AsyncPerformHook: Hook { 38 | let updateStrategy: HookUpdateStrategy? = .once 39 | let operation: @MainActor () async -> Output 40 | 41 | func makeState() -> State { 42 | State() 43 | } 44 | 45 | func value(coordinator: Coordinator) -> ( 46 | phase: AsyncPhase, 47 | perform: @MainActor () async -> Void 48 | ) { 49 | ( 50 | phase: coordinator.state.phase, 51 | perform: { 52 | guard !coordinator.state.isDisposed else { 53 | return 54 | } 55 | 56 | coordinator.state.phase = .running 57 | coordinator.updateView() 58 | 59 | let output = await operation() 60 | 61 | if !Task.isCancelled { 62 | coordinator.state.phase = .success(output) 63 | coordinator.updateView() 64 | } 65 | } 66 | ) 67 | } 68 | 69 | func dispose(state: State) { 70 | state.isDisposed = true 71 | } 72 | } 73 | 74 | internal extension AsyncPerformHook { 75 | final class State { 76 | var phase = AsyncPhase.pending 77 | var isDisposed = false 78 | } 79 | } 80 | 81 | internal struct AsyncThrowingPerformHook: Hook { 82 | let updateStrategy: HookUpdateStrategy? = .once 83 | let operation: @MainActor () async throws -> Output 84 | 85 | func makeState() -> State { 86 | State() 87 | } 88 | 89 | func value(coordinator: Coordinator) -> ( 90 | phase: AsyncPhase, 91 | perform: @MainActor () async -> Void 92 | ) { 93 | ( 94 | phase: coordinator.state.phase, 95 | perform: { 96 | guard !coordinator.state.isDisposed else { 97 | return 98 | } 99 | 100 | coordinator.state.phase = .running 101 | coordinator.updateView() 102 | 103 | let phase: AsyncPhase 104 | 105 | do { 106 | let output = try await operation() 107 | phase = .success(output) 108 | } 109 | catch { 110 | phase = .failure(error) 111 | } 112 | 113 | if !Task.isCancelled { 114 | coordinator.state.phase = phase 115 | coordinator.updateView() 116 | } 117 | } 118 | ) 119 | } 120 | 121 | func dispose(state: State) { 122 | state.isDisposed = true 123 | } 124 | } 125 | 126 | internal extension AsyncThrowingPerformHook { 127 | final class State { 128 | var phase = AsyncPhase.pending 129 | var isDisposed = false 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /Sources/Hooks/Hook/UseAsync.swift: -------------------------------------------------------------------------------- 1 | /// A hook to use the most recent phase of asynchronous operation of the passed non-throwing function. 2 | /// The function will be performed at the first update and will be re-performed according to the given `updateStrategy`. 3 | /// 4 | /// let phase = useAsync(.once) { 5 | /// try! await URLSession.shared.data(from: url) 6 | /// } 7 | /// 8 | /// - Parameters: 9 | /// - updateStrategy: A strategy that determines when to re-perform the given function. 10 | /// - operation: A closure that produces a resulting value asynchronously. 11 | /// - Returns: A most recent async phase. 12 | @discardableResult 13 | public func useAsync( 14 | _ updateStrategy: HookUpdateStrategy, 15 | _ operation: @escaping () async -> Output 16 | ) -> AsyncPhase { 17 | useHook( 18 | AsyncHook( 19 | updateStrategy: updateStrategy, 20 | operation: operation 21 | ) 22 | ) 23 | } 24 | 25 | /// A hook to use the most recent phase of asynchronous operation of the passed throwing function. 26 | /// The function will be performed at the first update and will be re-performed according to the given `updateStrategy`. 27 | /// 28 | /// let phase = useAsync(.once) { 29 | /// try await URLSession.shared.data(from: url) 30 | /// } 31 | /// 32 | /// - Parameters: 33 | /// - updateStrategy: A strategy that determines when to re-perform the given function. 34 | /// - operation: A closure that produces a resulting value asynchronously. 35 | /// - Returns: A most recent async phase. 36 | @discardableResult 37 | public func useAsync( 38 | _ updateStrategy: HookUpdateStrategy, 39 | _ operation: @escaping () async throws -> Output 40 | ) -> AsyncPhase { 41 | useHook( 42 | AsyncThrowingHook( 43 | updateStrategy: updateStrategy, 44 | operation: operation 45 | ) 46 | ) 47 | } 48 | 49 | internal struct AsyncHook: Hook { 50 | let updateStrategy: HookUpdateStrategy? 51 | let operation: () async -> Output 52 | 53 | func makeState() -> State { 54 | State() 55 | } 56 | 57 | func value(coordinator: Coordinator) -> AsyncPhase { 58 | coordinator.state.phase 59 | } 60 | 61 | func updateState(coordinator: Coordinator) { 62 | coordinator.state.phase = .running 63 | coordinator.state.task = Task { @MainActor in 64 | let output = await operation() 65 | 66 | if !Task.isCancelled { 67 | coordinator.state.phase = .success(output) 68 | coordinator.updateView() 69 | } 70 | } 71 | } 72 | 73 | func dispose(state: State) { 74 | state.task = nil 75 | } 76 | } 77 | 78 | internal extension AsyncHook { 79 | final class State { 80 | var phase = AsyncPhase.pending 81 | var task: Task? { 82 | didSet { 83 | oldValue?.cancel() 84 | } 85 | } 86 | } 87 | } 88 | 89 | internal struct AsyncThrowingHook: Hook { 90 | let updateStrategy: HookUpdateStrategy? 91 | let operation: () async throws -> Output 92 | 93 | func makeState() -> State { 94 | State() 95 | } 96 | 97 | func value(coordinator: Coordinator) -> AsyncPhase { 98 | coordinator.state.phase 99 | } 100 | 101 | func updateState(coordinator: Coordinator) { 102 | coordinator.state.phase = .running 103 | coordinator.state.task = Task { @MainActor in 104 | let phase: AsyncPhase 105 | 106 | do { 107 | let output = try await operation() 108 | phase = .success(output) 109 | } 110 | catch { 111 | phase = .failure(error) 112 | } 113 | 114 | if !Task.isCancelled { 115 | coordinator.state.phase = phase 116 | coordinator.updateView() 117 | } 118 | } 119 | } 120 | 121 | func dispose(state: State) { 122 | state.task = nil 123 | } 124 | } 125 | 126 | internal extension AsyncThrowingHook { 127 | final class State { 128 | var phase = AsyncPhase.pending 129 | var task: Task? { 130 | didSet { 131 | oldValue?.cancel() 132 | } 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /Examples/TheMovieDB-MVVM/Pages/MovieDetailPage.swift: -------------------------------------------------------------------------------- 1 | import Hooks 2 | import SwiftUI 3 | 4 | struct MovieDetailPage: HookView { 5 | let movie: Movie 6 | 7 | var hookBody: some View { 8 | ScrollView { 9 | VStack(alignment: .leading, spacing: 24) { 10 | ZStack(alignment: .topLeading) { 11 | backdropImage 12 | closeButton 13 | 14 | HStack(alignment: .top) { 15 | posterImage 16 | 17 | Text(movie.title) 18 | .font(.title) 19 | .fontWeight(.heavy) 20 | .foregroundColor(Color(.label)) 21 | .colorInvert() 22 | .padding(8) 23 | .shadow(radius: 4, y: 2) 24 | } 25 | .padding(.top, 70) 26 | .padding(.horizontal, 16) 27 | } 28 | 29 | VStack(alignment: .leading, spacing: 16) { 30 | informationSection 31 | overviewSection 32 | } 33 | .padding(.horizontal, 16) 34 | .padding(.bottom, 24) 35 | } 36 | } 37 | .background(Color(.secondarySystemBackground).ignoresSafeArea()) 38 | } 39 | 40 | @ViewBuilder 41 | var closeButton: some View { 42 | let presentation = useEnvironment(\.presentationMode) 43 | 44 | Button(action: { presentation.wrappedValue.dismiss() }) { 45 | ZStack { 46 | Color(.systemGray) 47 | .opacity(0.4) 48 | .clipShape(Circle()) 49 | .frame(width: 34, height: 34) 50 | 51 | Image(systemName: "xmark") 52 | .imageScale(.large) 53 | .font(Font.subheadline.bold()) 54 | .foregroundColor(Color(.systemGray)) 55 | } 56 | .padding(16) 57 | } 58 | } 59 | 60 | @ViewBuilder 61 | var backdropImage: some View { 62 | let image = useMovieImage(for: movie.backdropPath, size: .medium) 63 | 64 | ZStack { 65 | Color(.systemGroupedBackground) 66 | 67 | if let image = image { 68 | Image(uiImage: image) 69 | .resizable() 70 | .aspectRatio(contentMode: .fill) 71 | .clipped() 72 | } 73 | 74 | Color(.systemBackground).colorInvert().opacity(0.8) 75 | } 76 | .aspectRatio(CGSize(width: 5, height: 2), contentMode: .fit) 77 | } 78 | 79 | @ViewBuilder 80 | var posterImage: some View { 81 | let image = useMovieImage(for: movie.posterPath, size: .medium) 82 | 83 | ZStack { 84 | Color(.systemGroupedBackground) 85 | 86 | if let image = image { 87 | Image(uiImage: image) 88 | .resizable() 89 | .aspectRatio(contentMode: .fill) 90 | } 91 | } 92 | .frame(width: 150, height: 230) 93 | .cornerRadius(8) 94 | .shadow(radius: 4, y: 2) 95 | } 96 | 97 | var informationSection: some View { 98 | HStack { 99 | Text(Int(movie.voteAverage * 10).description) 100 | .bold() 101 | .font(.title) 102 | .foregroundColor(Color(.systemGreen)) 103 | + Text("%") 104 | .bold() 105 | .font(.caption) 106 | .foregroundColor(Color(.systemGreen)) 107 | 108 | Text(DateFormatter.shared.string(from: movie.releaseDate)) 109 | .font(.headline) 110 | .foregroundColor(Color(.secondaryLabel)) 111 | } 112 | } 113 | 114 | var overviewSection: some View { 115 | VStack(alignment: .leading, spacing: 8) { 116 | Text("Overview") 117 | .font(.title) 118 | .bold() 119 | 120 | if let overview = movie.overview { 121 | Text(overview) 122 | .font(.system(size: 24)) 123 | .foregroundColor(Color(.secondaryLabel)) 124 | } 125 | } 126 | } 127 | } 128 | 129 | private extension DateFormatter { 130 | static let shared: DateFormatter = { 131 | let formatter = DateFormatter() 132 | formatter.dateStyle = .medium 133 | formatter.timeStyle = .none 134 | return formatter 135 | }() 136 | } 137 | -------------------------------------------------------------------------------- /Tools/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "aexml", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/tadija/AEXML", 7 | "state" : { 8 | "revision" : "38f7d00b23ecd891e1ee656fa6aeebd6ba04ecc3", 9 | "version" : "4.6.1" 10 | } 11 | }, 12 | { 13 | "identity" : "graphviz", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/SwiftDocOrg/GraphViz.git", 16 | "state" : { 17 | "revision" : "70bebcf4597b9ce33e19816d6bbd4ba9b7bdf038", 18 | "version" : "0.2.0" 19 | } 20 | }, 21 | { 22 | "identity" : "jsonutilities", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/yonaskolb/JSONUtilities.git", 25 | "state" : { 26 | "revision" : "128d2ffc22467f69569ef8ff971683e2393191a0", 27 | "version" : "4.2.0" 28 | } 29 | }, 30 | { 31 | "identity" : "pathkit", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/kylef/PathKit.git", 34 | "state" : { 35 | "revision" : "3bfd2737b700b9a36565a8c94f4ad2b050a5e574", 36 | "version" : "1.0.1" 37 | } 38 | }, 39 | { 40 | "identity" : "rainbow", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/onevcat/Rainbow.git", 43 | "state" : { 44 | "revision" : "626c3d4b6b55354b4af3aa309f998fae9b31a3d9", 45 | "version" : "3.2.0" 46 | } 47 | }, 48 | { 49 | "identity" : "spectre", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/kylef/Spectre.git", 52 | "state" : { 53 | "revision" : "26cc5e9ae0947092c7139ef7ba612e34646086c7", 54 | "version" : "0.10.1" 55 | } 56 | }, 57 | { 58 | "identity" : "swift-argument-parser", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/apple/swift-argument-parser.git", 61 | "state" : { 62 | "revision" : "f3c9084a71ef4376f2fabbdf1d3d90a49f1fabdb", 63 | "version" : "1.1.2" 64 | } 65 | }, 66 | { 67 | "identity" : "swift-docc-plugin", 68 | "kind" : "remoteSourceControl", 69 | "location" : "https://github.com/apple/swift-docc-plugin", 70 | "state" : { 71 | "revision" : "3303b164430d9a7055ba484c8ead67a52f7b74f6", 72 | "version" : "1.0.0" 73 | } 74 | }, 75 | { 76 | "identity" : "swift-format", 77 | "kind" : "remoteSourceControl", 78 | "location" : "https://github.com/apple/swift-format.git", 79 | "state" : { 80 | "revision" : "c06258081a3f8703f55ff6e9647b32cf3144e247", 81 | "version" : "0.50600.0" 82 | } 83 | }, 84 | { 85 | "identity" : "swift-syntax", 86 | "kind" : "remoteSourceControl", 87 | "location" : "https://github.com/apple/swift-syntax", 88 | "state" : { 89 | "revision" : "0b6c22b97f8e9320bca62e82cdbee601cf37ad3f", 90 | "version" : "0.50600.1" 91 | } 92 | }, 93 | { 94 | "identity" : "swift-system", 95 | "kind" : "remoteSourceControl", 96 | "location" : "https://github.com/apple/swift-system.git", 97 | "state" : { 98 | "revision" : "836bc4557b74fe6d2660218d56e3ce96aff76574", 99 | "version" : "1.1.1" 100 | } 101 | }, 102 | { 103 | "identity" : "swift-tools-support-core", 104 | "kind" : "remoteSourceControl", 105 | "location" : "https://github.com/apple/swift-tools-support-core.git", 106 | "state" : { 107 | "revision" : "b7667f3e266af621e5cc9c77e74cacd8e8c00cb4", 108 | "version" : "0.2.5" 109 | } 110 | }, 111 | { 112 | "identity" : "swiftcli", 113 | "kind" : "remoteSourceControl", 114 | "location" : "https://github.com/jakeheis/SwiftCLI.git", 115 | "state" : { 116 | "revision" : "2e949055d9797c1a6bddcda0e58dada16cc8e970", 117 | "version" : "6.0.3" 118 | } 119 | }, 120 | { 121 | "identity" : "version", 122 | "kind" : "remoteSourceControl", 123 | "location" : "https://github.com/mxcl/Version", 124 | "state" : { 125 | "revision" : "a94b48f36763c05629fc102837398505032dead9", 126 | "version" : "2.0.0" 127 | } 128 | }, 129 | { 130 | "identity" : "xcodegen", 131 | "kind" : "remoteSourceControl", 132 | "location" : "https://github.com/yonaskolb/XcodeGen.git", 133 | "state" : { 134 | "revision" : "322c5658f3427e25bdca673759e9938ec179d761", 135 | "version" : "2.28.0" 136 | } 137 | }, 138 | { 139 | "identity" : "xcodeproj", 140 | "kind" : "remoteSourceControl", 141 | "location" : "https://github.com/tuist/XcodeProj.git", 142 | "state" : { 143 | "revision" : "c75c3acc25460195cfd203a04dde165395bf00e0", 144 | "version" : "8.7.1" 145 | } 146 | }, 147 | { 148 | "identity" : "yams", 149 | "kind" : "remoteSourceControl", 150 | "location" : "https://github.com/jpsim/Yams.git", 151 | "state" : { 152 | "revision" : "9ff1cc9327586db4e0c8f46f064b6a82ec1566fa", 153 | "version" : "4.0.6" 154 | } 155 | } 156 | ], 157 | "version" : 2 158 | } 159 | -------------------------------------------------------------------------------- /Examples/TheMovieDB-MVVM/Pages/TopRatedMoviesPage.swift: -------------------------------------------------------------------------------- 1 | import Hooks 2 | import SwiftUI 3 | 4 | struct TopRatedMoviesPage: HookView { 5 | var hookBody: some View { 6 | let viewModel = useTopRatedMoviesViewModel() 7 | let dependency = useContext(Context.self) 8 | 9 | NavigationView { 10 | ZStack { 11 | switch viewModel.loadPhase { 12 | case .success(let movies): 13 | moviesList(data: movies, viewModel: viewModel) 14 | 15 | case .failure(let error): 16 | failure(error, viewModel: viewModel) 17 | 18 | case .pending, .running: 19 | ProgressView() 20 | } 21 | } 22 | .frame(maxWidth: .infinity, maxHeight: .infinity) 23 | .navigationTitle("Top Rated Movies") 24 | .background(Color(.secondarySystemBackground).ignoresSafeArea()) 25 | .sheet(item: viewModel.selectedMovie) { movie in 26 | Context.Provider(value: dependency) { 27 | MovieDetailPage(movie: movie) 28 | } 29 | } 30 | } 31 | .navigationViewStyle(StackNavigationViewStyle()) 32 | .task { 33 | await viewModel.load() 34 | } 35 | } 36 | 37 | func failure(_ error: Error, viewModel: TopRatedMoviesViewModel) -> some View { 38 | VStack(spacing: 24) { 39 | Text("Failed to load movies").font(.system(.title2)) 40 | 41 | Button("Retry") { 42 | Task { 43 | await viewModel.load() 44 | } 45 | } 46 | } 47 | } 48 | 49 | func moviesList(data movies: [Movie], viewModel: TopRatedMoviesViewModel) -> some View { 50 | ScrollView { 51 | LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 16), count: 2), spacing: 16) { 52 | Section( 53 | footer: Group { 54 | if viewModel.hasNextPage { 55 | ProgressView() 56 | .frame(maxWidth: .infinity, minHeight: 100) 57 | .task { 58 | await viewModel.loadNext() 59 | } 60 | } 61 | }, 62 | content: { 63 | ForEach(movies) { movie in 64 | movieCard(movie) { 65 | viewModel.selectedMovie.wrappedValue = movie 66 | } 67 | } 68 | } 69 | ) 70 | } 71 | .padding(8) 72 | } 73 | } 74 | 75 | func movieCard(_ movie: Movie, onPressed: @escaping () -> Void) -> some View { 76 | HookScope { 77 | let image = useMovieImage(for: movie.posterPath, size: .medium) 78 | 79 | Button(action: onPressed) { 80 | VStack(alignment: .leading, spacing: .zero) { 81 | ZStack { 82 | Color(.systemGroupedBackground) 83 | 84 | if let image = image { 85 | Image(uiImage: image) 86 | .resizable() 87 | .aspectRatio(contentMode: .fill) 88 | .clipped() 89 | } 90 | } 91 | .aspectRatio(CGSize(width: 3, height: 4), contentMode: .fit) 92 | 93 | VStack(alignment: .leading, spacing: 8) { 94 | Text(movie.title) 95 | .bold() 96 | .foregroundColor(Color(.label)) 97 | .lineLimit(2) 98 | .fixedSize(horizontal: false, vertical: true) 99 | 100 | HStack(alignment: .bottom) { 101 | Text(Int(movie.voteAverage * 10).description) 102 | .bold() 103 | .font(.callout) 104 | .foregroundColor(Color(.systemGreen)) 105 | + Text("%") 106 | .bold() 107 | .font(.caption2) 108 | .foregroundColor(Color(.systemGreen)) 109 | 110 | Text(DateFormatter.shared.string(from: movie.releaseDate)) 111 | .font(.callout) 112 | .foregroundColor(Color(.secondaryLabel)) 113 | } 114 | } 115 | .padding(8) 116 | .frame(height: 100, alignment: .top) 117 | .frame(maxWidth: .infinity) 118 | 119 | Spacer(minLength: .zero) 120 | } 121 | .background(Color(.systemBackground)) 122 | .cornerRadius(8) 123 | .shadow(radius: 4, y: 2) 124 | } 125 | } 126 | } 127 | } 128 | 129 | private extension DateFormatter { 130 | static let shared: DateFormatter = { 131 | let formatter = DateFormatter() 132 | formatter.dateStyle = .medium 133 | formatter.timeStyle = .none 134 | return formatter 135 | }() 136 | } 137 | -------------------------------------------------------------------------------- /Sources/Hooks/AsyncPhase.swift: -------------------------------------------------------------------------------- 1 | /// An immutable representation of the most recent asynchronous operation phase. 2 | @frozen 3 | public enum AsyncPhase { 4 | /// Represents a pending phase meaning that the operation has not been started. 5 | case pending 6 | 7 | /// Represents a running phase meaning that the operation has been started, but has not yet provided a result. 8 | case running 9 | 10 | /// Represents a success phase meaning that the operation provided a value with success. 11 | case success(Success) 12 | 13 | /// Represents a failure phase meaning that the operation provided an error with failure. 14 | case failure(Failure) 15 | 16 | /// Returns a Boolean value indicating whether this instance represents a `pending`. 17 | public var isPending: Bool { 18 | guard case .pending = self else { 19 | return false 20 | } 21 | return true 22 | } 23 | 24 | /// Returns a Boolean value indicating whether this instance represents a `running`. 25 | public var isRunning: Bool { 26 | guard case .running = self else { 27 | return false 28 | } 29 | return true 30 | } 31 | 32 | /// Returns a Boolean value indicating whether this instance represents a `success`. 33 | public var isSuccess: Bool { 34 | guard case .success = self else { 35 | return false 36 | } 37 | return true 38 | } 39 | 40 | /// Returns a Boolean value indicating whether this instance represents a `failure`. 41 | public var isFailure: Bool { 42 | guard case .failure = self else { 43 | return false 44 | } 45 | return true 46 | } 47 | 48 | /// Returns a success value if this instance is `success`, otherwise returns `nil`. 49 | public var value: Success? { 50 | guard case .success(let value) = self else { 51 | return nil 52 | } 53 | return value 54 | } 55 | 56 | /// Returns an error if this instance is `failure`, otherwise returns `nil`. 57 | public var error: Failure? { 58 | guard case .failure(let error) = self else { 59 | return nil 60 | } 61 | return error 62 | } 63 | 64 | /// Returns a result converted from the phase. 65 | /// If this instance represents a `pending` or a `running`, this returns nil. 66 | public var result: Result? { 67 | switch self { 68 | case .pending, .running: 69 | return nil 70 | 71 | case .success(let success): 72 | return .success(success) 73 | 74 | case .failure(let error): 75 | return .failure(error) 76 | } 77 | } 78 | 79 | /// Returns a new phase, mapping any success value using the given transformation. 80 | /// - Parameter transform: A closure that takes the success value of this instance. 81 | /// - Returns: An `AsyncPhase` instance with the result of evaluating `transform` as the new success value if this instance represents a success. 82 | public func map(_ transform: (Success) -> NewSuccess) -> AsyncPhase { 83 | flatMap { .success(transform($0)) } 84 | } 85 | 86 | /// Returns a new result, mapping any failure value using the given transformation. 87 | /// - Parameter transform: A closure that takes the failure value of the instance. 88 | /// - Returns: An `AsyncPhase` instance with the result of evaluating `transform` as the new failure value if this instance represents a failure. 89 | public func mapError(_ transform: (Failure) -> NewFailure) -> AsyncPhase { 90 | flatMapError { .failure(transform($0)) } 91 | } 92 | 93 | /// Returns a new result, mapping any success value using the given transformation and unwrapping the produced phase. 94 | /// - Parameter transform: A closure that takes the success value of the instance. 95 | /// - Returns: An `AsyncPhase` instance, either from the closure or the previous `.success`. 96 | public func flatMap(_ transform: (Success) -> AsyncPhase) -> AsyncPhase { 97 | switch self { 98 | case .pending: 99 | return .pending 100 | 101 | case .running: 102 | return .running 103 | 104 | case .success(let value): 105 | return transform(value) 106 | 107 | case .failure(let error): 108 | return .failure(error) 109 | } 110 | } 111 | 112 | /// Returns a new result, mapping any failure value using the given transformation and unwrapping the produced phase. 113 | /// - Parameter transform: A closure that takes the failure value of the instance. 114 | /// - Returns: An `AsyncPhase` instance, either from the closure or the previous `.failure`. 115 | public func flatMapError(_ transform: (Failure) -> AsyncPhase) -> AsyncPhase { 116 | switch self { 117 | case .pending: 118 | return .pending 119 | 120 | case .running: 121 | return .running 122 | 123 | case .success(let value): 124 | return .success(value) 125 | 126 | case .failure(let error): 127 | return transform(error) 128 | } 129 | } 130 | } 131 | 132 | extension AsyncPhase: Equatable where Success: Equatable, Failure: Equatable {} 133 | extension AsyncPhase: Hashable where Success: Hashable, Failure: Hashable {} 134 | -------------------------------------------------------------------------------- /Tests/HooksTests/Hook/UseEffectTests.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import XCTest 3 | 4 | @testable import Hooks 5 | 6 | final class UseEffectTests: XCTestCase { 7 | enum EffectOperation: Equatable { 8 | case effect(Int) 9 | case cleanup(Int) 10 | } 11 | 12 | func testEffectWithoutPreservationKey() { 13 | var effectCount = 0 14 | 15 | let tester = HookTester { 16 | useEffect { 17 | effectCount += 1 18 | return nil 19 | } 20 | } 21 | 22 | XCTAssertEqual(effectCount, 1) 23 | 24 | tester.update() 25 | 26 | XCTAssertEqual(effectCount, 2) 27 | 28 | tester.update() 29 | 30 | XCTAssertEqual(effectCount, 3) 31 | } 32 | 33 | func testEffectOnce() { 34 | var effectCount = 0 35 | 36 | let tester = HookTester { 37 | useEffect(.once) { 38 | effectCount += 1 39 | return nil 40 | } 41 | } 42 | 43 | XCTAssertEqual(effectCount, 1) 44 | 45 | tester.update() 46 | 47 | XCTAssertEqual(effectCount, 1) 48 | 49 | tester.update() 50 | 51 | XCTAssertEqual(effectCount, 1) 52 | } 53 | 54 | func testEffectPreserved() { 55 | var flag = false 56 | var effectCount = 0 57 | 58 | let tester = HookTester { 59 | useEffect(.preserved(by: flag)) { 60 | effectCount += 1 61 | return nil 62 | } 63 | } 64 | 65 | XCTAssertEqual(effectCount, 1) 66 | 67 | tester.update() 68 | 69 | XCTAssertEqual(effectCount, 1) 70 | 71 | flag.toggle() 72 | tester.update() 73 | 74 | XCTAssertEqual(effectCount, 2) 75 | 76 | tester.update() 77 | 78 | XCTAssertEqual(effectCount, 2) 79 | } 80 | 81 | func testEffectCleanup() { 82 | var cleanupCount = 0 83 | 84 | let tester = HookTester { 85 | useEffect(.once) { 86 | { cleanupCount += 1 } 87 | } 88 | } 89 | 90 | XCTAssertEqual(cleanupCount, 0) 91 | 92 | tester.dispose() 93 | 94 | XCTAssertEqual(cleanupCount, 1) 95 | 96 | tester.dispose() 97 | 98 | XCTAssertEqual(cleanupCount, 1) 99 | 100 | tester.update() 101 | tester.dispose() 102 | 103 | XCTAssertEqual(cleanupCount, 2) 104 | } 105 | 106 | func testEffectOperationsOrder() { 107 | var operations: [EffectOperation] = [] 108 | var step = 1 109 | 110 | let tester = HookTester { 111 | useEffect(.preserved(by: step)) { 112 | let effectStep = step 113 | operations.append(.effect(effectStep)) 114 | return { operations.append(.cleanup(effectStep)) } 115 | } 116 | } 117 | 118 | XCTAssertEqual(operations, [.effect(1)]) 119 | 120 | step += 1 121 | tester.update() 122 | 123 | XCTAssertEqual(operations, [.effect(1), .cleanup(1), .effect(2)]) 124 | 125 | tester.dispose() 126 | 127 | XCTAssertEqual(operations, [.effect(1), .cleanup(1), .effect(2), .cleanup(2)]) 128 | } 129 | 130 | func testLayoutEffectWithoutPreservationKey() { 131 | var effectCount = 0 132 | 133 | let tester = HookTester { 134 | useLayoutEffect { 135 | effectCount += 1 136 | return nil 137 | } 138 | } 139 | 140 | XCTAssertEqual(effectCount, 1) 141 | 142 | tester.update() 143 | 144 | XCTAssertEqual(effectCount, 2) 145 | 146 | tester.update() 147 | 148 | XCTAssertEqual(effectCount, 3) 149 | } 150 | 151 | func testLayoutEffectOnce() { 152 | var effectCount = 0 153 | 154 | let tester = HookTester { 155 | useLayoutEffect(.once) { 156 | effectCount += 1 157 | return nil 158 | } 159 | } 160 | 161 | XCTAssertEqual(effectCount, 1) 162 | 163 | tester.update() 164 | 165 | XCTAssertEqual(effectCount, 1) 166 | 167 | tester.update() 168 | 169 | XCTAssertEqual(effectCount, 1) 170 | } 171 | 172 | func testLayoutEffectPreserved() { 173 | var flag = false 174 | var effectCount = 0 175 | 176 | let tester = HookTester { 177 | useLayoutEffect(.preserved(by: flag)) { 178 | effectCount += 1 179 | return nil 180 | } 181 | } 182 | 183 | XCTAssertEqual(effectCount, 1) 184 | 185 | tester.update() 186 | 187 | XCTAssertEqual(effectCount, 1) 188 | 189 | flag.toggle() 190 | tester.update() 191 | 192 | XCTAssertEqual(effectCount, 2) 193 | 194 | tester.update() 195 | 196 | XCTAssertEqual(effectCount, 2) 197 | } 198 | 199 | func testLayoutEffectCleanup() { 200 | var cleanupCount = 0 201 | 202 | let tester = HookTester { 203 | useLayoutEffect(.once) { 204 | { cleanupCount += 1 } 205 | } 206 | } 207 | 208 | XCTAssertEqual(cleanupCount, 0) 209 | 210 | tester.dispose() 211 | 212 | XCTAssertEqual(cleanupCount, 1) 213 | 214 | tester.dispose() 215 | 216 | XCTAssertEqual(cleanupCount, 1) 217 | 218 | tester.update() 219 | tester.dispose() 220 | XCTAssertEqual(cleanupCount, 2) 221 | } 222 | 223 | func testLayoutEffectOperationsOrder() { 224 | var operations: [EffectOperation] = [] 225 | var step = 1 226 | 227 | let tester = HookTester { 228 | useLayoutEffect(.preserved(by: step)) { 229 | let effectStep = step 230 | operations.append(.effect(effectStep)) 231 | return { operations.append(.cleanup(effectStep)) } 232 | } 233 | } 234 | 235 | XCTAssertEqual(operations, [.effect(1)]) 236 | 237 | step += 1 238 | tester.update() 239 | 240 | XCTAssertEqual(operations, [.effect(1), .cleanup(1), .effect(2)]) 241 | 242 | tester.dispose() 243 | 244 | XCTAssertEqual(operations, [.effect(1), .cleanup(1), .effect(2), .cleanup(2)]) 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /Tests/HooksTests/AsyncPhaseTests.swift: -------------------------------------------------------------------------------- 1 | import Hooks 2 | import XCTest 3 | 4 | final class AsyncPhaseTests: XCTestCase { 5 | func testIsPending() { 6 | let phases: [AsyncPhase] = [ 7 | .pending, 8 | .running, 9 | .success(0), 10 | .failure(URLError(.badURL)), 11 | ] 12 | 13 | let expected = [ 14 | true, 15 | false, 16 | false, 17 | false, 18 | ] 19 | 20 | for (phase, expected) in zip(phases, expected) { 21 | XCTAssertEqual(phase.isPending, expected) 22 | } 23 | } 24 | 25 | func testIsRunning() { 26 | let phases: [AsyncPhase] = [ 27 | .pending, 28 | .running, 29 | .success(0), 30 | .failure(URLError(.badURL)), 31 | ] 32 | 33 | let expected = [ 34 | false, 35 | true, 36 | false, 37 | false, 38 | ] 39 | 40 | for (phase, expected) in zip(phases, expected) { 41 | XCTAssertEqual(phase.isRunning, expected) 42 | } 43 | } 44 | 45 | func testIsSuccess() { 46 | let phases: [AsyncPhase] = [ 47 | .pending, 48 | .running, 49 | .success(0), 50 | .failure(URLError(.badURL)), 51 | ] 52 | 53 | let expected = [ 54 | false, 55 | false, 56 | true, 57 | false, 58 | ] 59 | 60 | for (phase, expected) in zip(phases, expected) { 61 | XCTAssertEqual(phase.isSuccess, expected) 62 | } 63 | } 64 | 65 | func testIsFailure() { 66 | let phases: [AsyncPhase] = [ 67 | .pending, 68 | .running, 69 | .success(0), 70 | .failure(URLError(.badURL)), 71 | ] 72 | 73 | let expected = [ 74 | false, 75 | false, 76 | false, 77 | true, 78 | ] 79 | 80 | for (phase, expected) in zip(phases, expected) { 81 | XCTAssertEqual(phase.isFailure, expected) 82 | } 83 | } 84 | 85 | func testValue() { 86 | let phases: [AsyncPhase] = [ 87 | .pending, 88 | .running, 89 | .success(0), 90 | .failure(URLError(.badURL)), 91 | ] 92 | 93 | let expected: [Int?] = [ 94 | nil, 95 | nil, 96 | 0, 97 | nil, 98 | ] 99 | 100 | for (phase, expected) in zip(phases, expected) { 101 | XCTAssertEqual(phase.value, expected) 102 | } 103 | } 104 | 105 | func testError() { 106 | let phases: [AsyncPhase] = [ 107 | .pending, 108 | .running, 109 | .success(0), 110 | .failure(URLError(.badURL)), 111 | ] 112 | 113 | let expected: [URLError?] = [ 114 | nil, 115 | nil, 116 | nil, 117 | URLError(.badURL), 118 | ] 119 | 120 | for (phase, expected) in zip(phases, expected) { 121 | XCTAssertEqual(phase.error, expected) 122 | } 123 | } 124 | 125 | func testResult() { 126 | let phases: [AsyncPhase] = [ 127 | .pending, 128 | .running, 129 | .success(0), 130 | .failure(URLError(.badURL)), 131 | ] 132 | 133 | let expected: [Result?] = [ 134 | nil, 135 | nil, 136 | .success(0), 137 | .failure(URLError(.badURL)), 138 | ] 139 | 140 | for (phase, expected) in zip(phases, expected) { 141 | XCTAssertEqual(phase.result, expected) 142 | } 143 | } 144 | 145 | func testMap() { 146 | let phases: [AsyncPhase] = [ 147 | .pending, 148 | .running, 149 | .success(0), 150 | .failure(URLError(.badURL)), 151 | ] 152 | 153 | let expected: [AsyncPhase] = [ 154 | .pending, 155 | .running, 156 | .success(100), 157 | .failure(URLError(.badURL)), 158 | ] 159 | 160 | for (phase, expected) in zip(phases, expected) { 161 | XCTAssertEqual(phase.map { _ in 100 }, expected) 162 | } 163 | } 164 | 165 | func testMapError() { 166 | let phases: [AsyncPhase] = [ 167 | .pending, 168 | .running, 169 | .success(0), 170 | .failure(URLError(.badURL)), 171 | ] 172 | 173 | let expected: [AsyncPhase] = [ 174 | .pending, 175 | .running, 176 | .success(0), 177 | .failure(URLError(.cancelled)), 178 | ] 179 | 180 | for (phase, expected) in zip(phases, expected) { 181 | XCTAssertEqual( 182 | phase.mapError { _ in URLError(.cancelled) }, 183 | expected 184 | ) 185 | } 186 | } 187 | 188 | func testFlatMap() { 189 | let phases: [AsyncPhase] = [ 190 | .pending, 191 | .running, 192 | .success(0), 193 | .failure(URLError(.badURL)), 194 | ] 195 | 196 | let expected: [AsyncPhase] = [ 197 | .pending, 198 | .running, 199 | .failure(URLError(.callIsActive)), 200 | .failure(URLError(.badURL)), 201 | ] 202 | 203 | for (phase, expected) in zip(phases, expected) { 204 | XCTAssertEqual( 205 | phase.flatMap { _ in .failure(URLError(.callIsActive)) }, 206 | expected 207 | ) 208 | } 209 | } 210 | 211 | func testFlatMapError() { 212 | let phases: [AsyncPhase] = [ 213 | .pending, 214 | .running, 215 | .success(0), 216 | .failure(URLError(.badURL)), 217 | ] 218 | 219 | let expected: [AsyncPhase] = [ 220 | .pending, 221 | .running, 222 | .success(0), 223 | .success(100), 224 | ] 225 | 226 | for (phase, expected) in zip(phases, expected) { 227 | XCTAssertEqual( 228 | phase.flatMapError { _ in .success(100) }, 229 | expected 230 | ) 231 | } 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /Sources/Hooks/HookDispatcher.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | 4 | /// A class that manages list of states of hooks used inside `HookDispatcher.scoped(environment:_)`. 5 | public final class HookDispatcher: ObservableObject { 6 | internal private(set) static weak var current: HookDispatcher? 7 | 8 | /// A publisher that emits before the object has changed. 9 | public let objectWillChange = PassthroughSubject<(), Never>() 10 | 11 | private var records = LinkedList() 12 | private var scopedState: ScopedHookState? 13 | 14 | /// Creates a new `HookDispatcher`. 15 | public init() {} 16 | 17 | deinit { 18 | disposeAll() 19 | } 20 | 21 | /// Disposes all hooks that already managed with this instance. 22 | public func disposeAll() { 23 | for record in records.reversed() { 24 | record.element.dispose() 25 | } 26 | 27 | records = LinkedList() 28 | } 29 | 30 | /// Returns given hooks value with managing its state and update it if needed. 31 | /// - Parameter hook: A hook to be used. 32 | /// - Returns: A value that provided from the given hook. 33 | public func use(_ hook: H) -> H.Value { 34 | assertMainThread() 35 | 36 | guard let scopedState = scopedState else { 37 | fatalErrorHooksRules() 38 | } 39 | 40 | func makeCoordinator(state: H.State) -> HookCoordinator { 41 | HookCoordinator( 42 | state: state, 43 | environment: scopedState.environment, 44 | updateView: objectWillChange.send 45 | ) 46 | } 47 | 48 | func appendNew() -> H.Value { 49 | let state = hook.makeState() 50 | let coordinator = makeCoordinator(state: state) 51 | let record = HookRecord(hook: hook, coordinator: coordinator) 52 | 53 | scopedState.currentRecord = records.append(record) 54 | 55 | if hook.shouldDeferredUpdate { 56 | scopedState.deferredUpdateRecords.append(record) 57 | } 58 | else { 59 | hook.updateState(coordinator: coordinator) 60 | } 61 | 62 | return hook.value(coordinator: coordinator) 63 | } 64 | 65 | defer { 66 | scopedState.currentRecord = scopedState.currentRecord?.next 67 | } 68 | 69 | guard let record = scopedState.currentRecord else { 70 | return appendNew() 71 | } 72 | 73 | if let state = record.element.state(of: H.self) { 74 | let coordinator = makeCoordinator(state: state) 75 | let newRecord = HookRecord(hook: hook, coordinator: coordinator) 76 | let oldRecord = record.swap(element: newRecord) 77 | 78 | if oldRecord.shouldUpdate(newHook: hook) { 79 | if hook.shouldDeferredUpdate { 80 | scopedState.deferredUpdateRecords.append(newRecord) 81 | } 82 | else { 83 | hook.updateState(coordinator: coordinator) 84 | } 85 | } 86 | 87 | return hook.value(coordinator: coordinator) 88 | } 89 | else { 90 | scopedState.assertRecordingFailure(hook: hook, record: record.element) 91 | 92 | // Fallback process for wrong usage. 93 | 94 | sweepRemainingRecords() 95 | 96 | return appendNew() 97 | } 98 | } 99 | 100 | /// Executes the given `body` function that needs `HookDispatcher` instance with managing hooks state. 101 | /// - Parameters: 102 | /// - environment: A environment values that can be used for hooks used inside the `body`. 103 | /// - body: A function that needs `HookDispatcher` and is executed inside. 104 | /// - Throws: Rethrows an error if the given function throws. 105 | /// - Returns: A result value that the given `body` function returns. 106 | public func scoped( 107 | environment: EnvironmentValues, 108 | _ body: () throws -> Result 109 | ) rethrows -> Result { 110 | assertMainThread() 111 | 112 | let previous = Self.current 113 | 114 | Self.current = self 115 | 116 | let scopedState = ScopedHookState( 117 | environment: environment, 118 | currentRecord: records.first 119 | ) 120 | 121 | self.scopedState = scopedState 122 | 123 | let value = try body() 124 | 125 | scopedState.deferredUpdate() 126 | scopedState.assertConsumedState() 127 | sweepRemainingRecords() 128 | 129 | self.scopedState = nil 130 | 131 | Self.current = previous 132 | 133 | return value 134 | } 135 | } 136 | 137 | private extension HookDispatcher { 138 | func sweepRemainingRecords() { 139 | guard let scopedState = scopedState, let currentRecord = scopedState.currentRecord else { 140 | return 141 | } 142 | 143 | let remaining = records.dropSuffix(from: currentRecord) 144 | 145 | for record in remaining.reversed() { 146 | record.element.dispose() 147 | } 148 | 149 | scopedState.currentRecord = records.last 150 | } 151 | } 152 | 153 | private final class ScopedHookState { 154 | let environment: EnvironmentValues 155 | var currentRecord: LinkedList.Node? 156 | var deferredUpdateRecords = LinkedList() 157 | 158 | init( 159 | environment: EnvironmentValues, 160 | currentRecord: LinkedList.Node? 161 | ) { 162 | self.environment = environment 163 | self.currentRecord = currentRecord 164 | } 165 | 166 | func deferredUpdate() { 167 | for record in deferredUpdateRecords { 168 | record.element.updateState() 169 | } 170 | } 171 | 172 | func assertConsumedState() { 173 | guard !environment.hooksRulesAssertionDisabled else { 174 | return 175 | } 176 | 177 | assert( 178 | currentRecord == nil, 179 | """ 180 | Some Hooks are no longer used from the previous evaluation. 181 | Hooks relies on the order in which they are called. Do not call Hooks inside loops, conditions, or nested functions. 182 | 183 | - SeeAlso: https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level 184 | """ 185 | ) 186 | } 187 | 188 | func assertRecordingFailure(hook: H, record: HookRecordProtocol) { 189 | guard !environment.hooksRulesAssertionDisabled else { 190 | return 191 | } 192 | 193 | assertionFailure( 194 | """ 195 | The type of Hooks did not match with the type evaluated in the previous evaluation. 196 | Previous hook: \(record.hookName) 197 | Current hook: \(type(of: hook)) 198 | Hooks relies on the order in which they are called. Do not call Hooks inside loops, conditions, or nested functions. 199 | 200 | - SeeAlso: https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level 201 | """ 202 | ) 203 | } 204 | } 205 | 206 | private struct HookRecord: HookRecordProtocol { 207 | let hook: H 208 | let coordinator: HookCoordinator 209 | 210 | var hookName: String { 211 | String(describing: type(of: hook)) 212 | } 213 | 214 | func state(of hookType: H.Type) -> H.State? { 215 | coordinator.state as? H.State 216 | } 217 | 218 | func shouldUpdate(newHook: New) -> Bool { 219 | guard let newStrategy = newHook.updateStrategy else { 220 | return true 221 | } 222 | 223 | return hook.updateStrategy?.dependency != newStrategy.dependency 224 | } 225 | 226 | func updateState() { 227 | hook.updateState(coordinator: coordinator) 228 | } 229 | 230 | func dispose() { 231 | hook.dispose(state: coordinator.state) 232 | } 233 | } 234 | 235 | private protocol HookRecordProtocol { 236 | var hookName: String { get } 237 | 238 | func state(of hookType: H.Type) -> H.State? 239 | func shouldUpdate(newHook: New) -> Bool 240 | func updateState() 241 | func dispose() 242 | } 243 | -------------------------------------------------------------------------------- /Tests/HooksTests/HookDispatcherTests.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import XCTest 3 | 4 | @testable import Hooks 5 | 6 | final class HookDispatcherTests: XCTestCase { 7 | final class Counter { 8 | var count = 0 9 | } 10 | 11 | final class TestHook: Hook { 12 | let updateStrategy: HookUpdateStrategy? 13 | let shouldDeferredUpdate: Bool 14 | let disposeCounter: Counter 15 | var disposedAt: Int? 16 | 17 | init( 18 | updateStrategy: HookUpdateStrategy? = nil, 19 | shouldDeferredUpdate: Bool = false, 20 | disposeCounter: Counter? = nil 21 | ) { 22 | self.updateStrategy = updateStrategy 23 | self.shouldDeferredUpdate = shouldDeferredUpdate 24 | self.disposeCounter = disposeCounter ?? Counter() 25 | } 26 | 27 | func dispose(state: RefObject) { 28 | disposeCounter.count += 1 29 | disposedAt = disposeCounter.count 30 | } 31 | 32 | func updateState(coordinator: Coordinator) { 33 | coordinator.state.current += 1 34 | } 35 | 36 | func makeState() -> RefObject { 37 | RefObject(0) 38 | } 39 | 40 | func value(coordinator: Coordinator) -> Int { 41 | coordinator.state.current 42 | } 43 | } 44 | 45 | final class Test2Hook: Hook { 46 | let updateStrategy: HookUpdateStrategy? = nil 47 | let disposeCounter: Counter 48 | var disposedAt: Int? 49 | 50 | init(disposeCounter: Counter? = nil) { 51 | self.disposeCounter = disposeCounter ?? Counter() 52 | } 53 | 54 | func dispose(state: Void) { 55 | disposeCounter.count += 1 56 | disposedAt = disposeCounter.count 57 | } 58 | } 59 | 60 | var environment: EnvironmentValues { 61 | var environment = EnvironmentValues() 62 | environment.hooksRulesAssertionDisabled = true 63 | return environment 64 | } 65 | 66 | func testScoped() { 67 | let dispatcher = HookDispatcher() 68 | 69 | XCTAssertNil(HookDispatcher.current) 70 | 71 | dispatcher.scoped(environment: environment) { 72 | XCTAssertTrue(HookDispatcher.current === dispatcher) 73 | } 74 | 75 | XCTAssertNil(HookDispatcher.current) 76 | } 77 | 78 | func testUse() { 79 | let dispatcher = HookDispatcher() 80 | let hookWithoutPreservation = TestHook(updateStrategy: nil) 81 | let onceHook = TestHook(updateStrategy: .once) 82 | let deferredHook = TestHook(updateStrategy: nil, shouldDeferredUpdate: true) 83 | 84 | dispatcher.scoped(environment: environment) { 85 | let value1 = useHook(hookWithoutPreservation) 86 | let value2 = useHook(onceHook) 87 | let value3 = useHook(deferredHook) 88 | 89 | XCTAssertEqual(value1, 1) // Update always 90 | XCTAssertEqual(value2, 1) // Update once 91 | XCTAssertEqual(value3, 0) // Update is deferred 92 | } 93 | 94 | dispatcher.scoped(environment: environment) { 95 | let value1 = useHook(hookWithoutPreservation) 96 | let value2 = useHook(onceHook) 97 | let value3 = useHook(deferredHook) 98 | 99 | XCTAssertEqual(value1, 2) // Update always 100 | XCTAssertEqual(value2, 1) // Already updated once 101 | XCTAssertEqual(value3, 1) // Update is deferred 102 | } 103 | 104 | dispatcher.scoped(environment: environment) { 105 | let value1 = useHook(hookWithoutPreservation) 106 | let value2 = useHook(onceHook) 107 | let value3 = useHook(deferredHook) 108 | 109 | XCTAssertEqual(value1, 3) // Update always 110 | XCTAssertEqual(value2, 1) // Already updated once 111 | XCTAssertEqual(value3, 2) // Update is deferred 112 | } 113 | } 114 | 115 | func testMismatchTypedHookFound() { 116 | let dispatcher = HookDispatcher() 117 | let disposeCounter = Counter() 118 | let hook1 = TestHook(disposeCounter: disposeCounter) 119 | let hook2 = Test2Hook(disposeCounter: disposeCounter) 120 | let hook3 = TestHook(disposeCounter: disposeCounter) 121 | let hook4 = TestHook(disposeCounter: disposeCounter) 122 | 123 | dispatcher.scoped(environment: environment) { 124 | let value1 = useHook(hook1) 125 | useHook(hook2) 126 | let value3 = useHook(hook3) 127 | let value4 = useHook(hook4) 128 | 129 | XCTAssertEqual(value1, 1) 130 | XCTAssertEqual(value3, 1) 131 | XCTAssertEqual(value4, 1) 132 | } 133 | 134 | dispatcher.scoped(environment: environment) { 135 | let value1 = useHook(hook1) 136 | let value3 = useHook(hook3) 137 | let value4 = useHook(hook4) 138 | 139 | XCTAssertEqual(value1, 2) // Works correctly 140 | XCTAssertEqual(value3, 1) // State is initialized 141 | XCTAssertEqual(value4, 1) // State is initialized 142 | } 143 | 144 | // Disposed in reverse order 145 | XCTAssertNil(hook1.disposedAt) 146 | XCTAssertEqual(hook2.disposedAt, 3) 147 | XCTAssertEqual(hook3.disposedAt, 2) 148 | XCTAssertEqual(hook4.disposedAt, 1) 149 | XCTAssertEqual(disposeCounter.count, 3) 150 | 151 | dispatcher.scoped(environment: environment) { 152 | let value1 = useHook(hook1) 153 | let value3 = useHook(hook3) 154 | let value4 = useHook(hook4) 155 | 156 | XCTAssertEqual(value1, 3) // Works correctly 157 | XCTAssertEqual(value3, 2) // Works correctly 158 | XCTAssertEqual(value4, 2) // Works correctly 159 | } 160 | } 161 | 162 | func testNumberOfHooksMismatched() { 163 | let dispatcher = HookDispatcher() 164 | let disposeCounter = Counter() 165 | let hook1 = TestHook(disposeCounter: disposeCounter) 166 | let hook2 = TestHook(disposeCounter: disposeCounter) 167 | let hook3 = TestHook(disposeCounter: disposeCounter) 168 | 169 | dispatcher.scoped(environment: environment) { 170 | // There are 2 hooks 171 | let value1 = useHook(hook1) 172 | let value2 = useHook(hook2) 173 | let value3 = useHook(hook3) 174 | 175 | XCTAssertEqual(value1, 1) 176 | XCTAssertEqual(value2, 1) 177 | XCTAssertEqual(value3, 1) 178 | } 179 | 180 | dispatcher.scoped(environment: environment) { 181 | // There is 1 hook 182 | let value1 = useHook(hook1) 183 | 184 | XCTAssertEqual(value1, 2) // Works correctly 185 | } 186 | 187 | XCTAssertNil(hook1.disposedAt) 188 | XCTAssertEqual(hook2.disposedAt, 2) 189 | XCTAssertEqual(hook3.disposedAt, 1) 190 | XCTAssertEqual(disposeCounter.count, 2) 191 | 192 | dispatcher.scoped(environment: environment) { 193 | let value1 = useHook(hook1) 194 | let value2 = useHook(hook2) 195 | let value3 = useHook(hook3) 196 | 197 | XCTAssertEqual(value1, 3) // Works correctly 198 | XCTAssertEqual(value2, 1) // Previous state is initialized 199 | XCTAssertEqual(value3, 1) // Previous state is initialized 200 | } 201 | 202 | dispatcher.scoped(environment: environment) { 203 | let value1 = useHook(hook1) 204 | let value2 = useHook(hook2) 205 | let value3 = useHook(hook3) 206 | 207 | XCTAssertEqual(value1, 4) // Works correctly 208 | XCTAssertEqual(value2, 2) // Works correctly 209 | XCTAssertEqual(value3, 2) // Works correctly 210 | } 211 | } 212 | 213 | func testReversedDisposeWhenDeinit() { 214 | var dispatcher: HookDispatcher? = HookDispatcher() 215 | let disposeCounter = Counter() 216 | let hook1 = TestHook(disposeCounter: disposeCounter) 217 | let hook2 = TestHook(disposeCounter: disposeCounter) 218 | 219 | dispatcher? 220 | .scoped(environment: environment) { 221 | _ = useHook(hook1) 222 | _ = useHook(hook2) 223 | } 224 | 225 | XCTAssertEqual(disposeCounter.count, 0) 226 | 227 | dispatcher = nil 228 | 229 | XCTAssertEqual(disposeCounter.count, 2) 230 | XCTAssertEqual(hook1.disposedAt, 2) 231 | XCTAssertEqual(hook2.disposedAt, 1) 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /Examples/BasicUsage/ShowcasePage.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Hooks 3 | import SwiftUI 4 | 5 | typealias ColorSchemeContext = Context> 6 | 7 | struct ShowcasePage: HookView { 8 | var hookBody: some View { 9 | let colorScheme = useState(useEnvironment(\.colorScheme)) 10 | 11 | ColorSchemeContext.Provider(value: colorScheme) { 12 | ScrollView { 13 | VStack { 14 | Group { 15 | useStateRow 16 | useReducerRow 17 | useEffectRow 18 | useLayoutEffectRow 19 | useMemoRow 20 | useRefRow 21 | } 22 | 23 | Group { 24 | useAsyncRow 25 | useAsyncPerformRow 26 | usePublisherRow 27 | usePublisherSubscribeRow 28 | useEnvironmentRow 29 | useContextRow 30 | } 31 | } 32 | .padding(.vertical, 16) 33 | } 34 | .navigationTitle("Showcase") 35 | .background(Color(.systemBackground).ignoresSafeArea()) 36 | .colorScheme(colorScheme.wrappedValue) 37 | } 38 | } 39 | 40 | var useAsyncRow: some View { 41 | let phase = useAsync(.once) { () -> UIImage? in 42 | let url = URL(string: "https://source.unsplash.com/random")! 43 | let (data, _) = try await URLSession.shared.data(from: url) 44 | return UIImage(data: data) 45 | } 46 | 47 | return Row("useAsync") { 48 | Group { 49 | switch phase { 50 | case .pending, .running: 51 | ProgressView() 52 | 53 | case .failure(let error): 54 | Text(error.localizedDescription) 55 | 56 | case .success(let image): 57 | image.map { uiImage in 58 | Image(uiImage: uiImage) 59 | .resizable() 60 | .scaledToFit() 61 | } 62 | } 63 | } 64 | .frame(width: 100, height: 100) 65 | .clipped() 66 | } 67 | } 68 | 69 | var useAsyncPerformRow: some View { 70 | let (phase, fetch) = useAsyncPerform { () -> UIImage? in 71 | let url = URL(string: "https://source.unsplash.com/random")! 72 | let (data, _) = try await URLSession.shared.data(from: url) 73 | return UIImage(data: data) 74 | } 75 | 76 | return Row("useAsyncPerform") { 77 | HStack { 78 | Group { 79 | switch phase { 80 | case .pending, .running: 81 | ProgressView() 82 | 83 | case .failure(let error): 84 | Text(error.localizedDescription) 85 | 86 | case .success(let image): 87 | image.map { uiImage in 88 | Image(uiImage: uiImage) 89 | .resizable() 90 | .scaledToFit() 91 | } 92 | } 93 | } 94 | .frame(width: 100, height: 100) 95 | .clipped() 96 | 97 | Spacer() 98 | 99 | Button("Random") { 100 | Task { 101 | await fetch() 102 | } 103 | } 104 | } 105 | .task { 106 | await fetch() 107 | } 108 | } 109 | } 110 | 111 | var useStateRow: some View { 112 | let count = useState(0) 113 | 114 | return Row("useState") { 115 | Stepper(count.wrappedValue.description, value: count) 116 | } 117 | } 118 | 119 | var useReducerRow: some View { 120 | enum Action { 121 | case plus, minus, reset 122 | } 123 | 124 | func reducer(state: Int, action: Action) -> Int { 125 | switch action { 126 | case .plus: return state + 1 127 | case .minus: return state - 1 128 | case .reset: return 0 129 | } 130 | } 131 | 132 | let (count, dispatch) = useReducer(reducer, initialState: 0) 133 | 134 | return Row("useReducer") { 135 | Stepper( 136 | count.description, 137 | onIncrement: { dispatch(.plus) }, 138 | onDecrement: { dispatch(.minus) } 139 | ) 140 | 141 | Button("Reset") { dispatch(.reset) } 142 | } 143 | } 144 | 145 | var useEffectRow: some View { 146 | let isOn = useState(false) 147 | let count = useState(0) 148 | 149 | useEffect(.preserved(by: isOn.wrappedValue)) { 150 | guard isOn.wrappedValue else { return nil } 151 | 152 | let timer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { _ in 153 | count.wrappedValue += 1 154 | } 155 | return { 156 | timer.invalidate() 157 | } 158 | } 159 | 160 | return Row("useEffect") { 161 | Toggle(isOn: isOn) { 162 | Text("\(count.wrappedValue)") 163 | } 164 | } 165 | } 166 | 167 | var useLayoutEffectRow: some View { 168 | let flag = useState(false) 169 | let random = useState(0) 170 | 171 | useLayoutEffect(.preserved(by: flag.wrappedValue)) { 172 | random.wrappedValue = Int.random(in: 0...100000) 173 | return nil 174 | } 175 | 176 | return Row("useLayoutEffect") { 177 | Text("\(random.wrappedValue)") 178 | Spacer() 179 | Button("Random") { 180 | flag.wrappedValue.toggle() 181 | } 182 | } 183 | } 184 | 185 | var useMemoRow: some View { 186 | let flag = useState(false) 187 | let randomColor = useMemo(.preserved(by: flag.wrappedValue)) { 188 | Color(hue: .random(in: 0...1), saturation: 1, brightness: 1) 189 | } 190 | 191 | return Row("useMemo") { 192 | Circle().fill(randomColor).frame(width: 30, height: 30) 193 | Spacer() 194 | Button("Random") { 195 | flag.wrappedValue.toggle() 196 | } 197 | } 198 | } 199 | 200 | var useRefRow: some View { 201 | let flag = useState(false) 202 | let n1 = useRef(1) 203 | let n2 = useRef(1) 204 | let fibonacci = useState(2) 205 | 206 | useEffect(.preserved(by: flag.wrappedValue)) { 207 | n2.current = n1.current 208 | n1.current = fibonacci.wrappedValue 209 | fibonacci.wrappedValue = n1.current + n2.current 210 | return nil 211 | } 212 | 213 | return Row("useRef") { 214 | Text("Fibonacci = \(fibonacci.wrappedValue)") 215 | Spacer() 216 | Button("Next") { 217 | flag.wrappedValue.toggle() 218 | } 219 | } 220 | } 221 | 222 | var useEnvironmentRow: some View { 223 | let locale = useEnvironment(\.locale) 224 | 225 | return Row("useEnvironment") { 226 | Text("Current Locale = \(locale.identifier)") 227 | } 228 | } 229 | 230 | var usePublisherRow: some View { 231 | let phase = usePublisher(.once) { 232 | Timer.publish(every: 1, on: .main, in: .common) 233 | .autoconnect() 234 | .prepend(Date()) 235 | } 236 | 237 | return Row("usePublisher") { 238 | if case .success(let date) = phase { 239 | Text(DateFormatter.time.string(from: date)) 240 | } 241 | } 242 | } 243 | 244 | var usePublisherSubscribeRow: some View { 245 | let (phase, subscribe) = usePublisherSubscribe { 246 | Just(UUID()) 247 | .map(\.uuidString) 248 | .delay(for: .seconds(1), scheduler: DispatchQueue.main) 249 | } 250 | 251 | return Row("usePublisherSubscribe") { 252 | HStack { 253 | switch phase { 254 | case .running: 255 | ProgressView() 256 | 257 | case .success(let uuid): 258 | Text(uuid) 259 | 260 | case .pending: 261 | EmptyView() 262 | } 263 | 264 | Spacer() 265 | Button("Random", action: subscribe) 266 | } 267 | .frame(height: 50) 268 | } 269 | } 270 | 271 | var useContextRow: some View { 272 | let colorScheme = useContext(ColorSchemeContext.self) 273 | 274 | return Row("useContext") { 275 | Picker("Color Scheme", selection: colorScheme) { 276 | ForEach(ColorScheme.allCases, id: \.self) { scheme in 277 | Text("\(scheme)".description) 278 | } 279 | } 280 | .pickerStyle(SegmentedPickerStyle()) 281 | } 282 | } 283 | } 284 | 285 | struct ShowcasePage_Previews: PreviewProvider { 286 | static var previews: some View { 287 | ShowcasePage() 288 | } 289 | } 290 | 291 | private struct Row: View { 292 | let title: String 293 | let content: Content 294 | 295 | init(_ title: String, @ViewBuilder content: () -> Content) { 296 | self.title = title 297 | self.content = content() 298 | } 299 | 300 | var body: some View { 301 | VStack(alignment: .leading) { 302 | Text(title).bold() 303 | HStack { content }.padding(.vertical, 16) 304 | Divider() 305 | } 306 | .padding(.horizontal, 24) 307 | } 308 | } 309 | 310 | private extension DateFormatter { 311 | static let time: DateFormatter = { 312 | let formatter = DateFormatter() 313 | formatter.dateStyle = .none 314 | formatter.timeStyle = .medium 315 | return formatter 316 | }() 317 | } 318 | --------------------------------------------------------------------------------