From 765b296d7767c13f4920ee518f1701531c2eb0d4 Mon Sep 17 00:00:00 2001 From: Dominik Arnhof Date: Sat, 23 Nov 2024 18:10:32 +0100 Subject: [PATCH 1/2] add "Reactor" product with experimental @Observable support --- .../project.pbxproj | 11 ++ .../ObservableTestView.swift | 76 ++++++++++ Package.swift | 18 ++- .../AsyncReactor/AsyncReactor+SwiftUI.swift | 73 +-------- Sources/AsyncReactor/AsyncReactor.swift | 127 +--------------- Sources/Reactor/Reactor+SwiftUI.swift | 93 ++++++++++++ Sources/Reactor/Reactor.swift | 13 ++ Sources/ReactorBase/ReactorBase+SwiftUI.swift | 83 ++++++++++ Sources/ReactorBase/ReactorBase.swift | 142 ++++++++++++++++++ 9 files changed, 436 insertions(+), 200 deletions(-) create mode 100644 Example/AsyncReactorExample/ObservableTestView.swift create mode 100644 Sources/Reactor/Reactor+SwiftUI.swift create mode 100644 Sources/Reactor/Reactor.swift create mode 100644 Sources/ReactorBase/ReactorBase+SwiftUI.swift create mode 100644 Sources/ReactorBase/ReactorBase.swift diff --git a/Example/AsyncReactorExample.xcodeproj/project.pbxproj b/Example/AsyncReactorExample.xcodeproj/project.pbxproj index 02ac70e..fe97f58 100644 --- a/Example/AsyncReactorExample.xcodeproj/project.pbxproj +++ b/Example/AsyncReactorExample.xcodeproj/project.pbxproj @@ -14,6 +14,8 @@ 80D30FC62A1CDBC200C653B0 /* RepositoryDescriptionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D30FC52A1CDBC200C653B0 /* RepositoryDescriptionSheet.swift */; }; 80E8B9D12A1BAE2800E4B3CC /* RepositorySearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80E8B9D02A1BAE2800E4B3CC /* RepositorySearchView.swift */; }; 80E8B9D32A1BAEC200E4B3CC /* RepositoryDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80E8B9D22A1BAEC200E4B3CC /* RepositoryDetailView.swift */; }; + B4A9DDD22CF23EF2008CA694 /* Reactor in Frameworks */ = {isa = PBXBuildFile; productRef = B4A9DDD12CF23EF2008CA694 /* Reactor */; }; + B4C46AC02CEF7CA300CC2BE7 /* ObservableTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C46ABF2CEF7CA300CC2BE7 /* ObservableTestView.swift */; }; B4ECB0B02A1250AE00B0CAAE /* AsyncReactorExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4ECB0AF2A1250AE00B0CAAE /* AsyncReactorExampleApp.swift */; }; B4ECB0B22A1250AE00B0CAAE /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4ECB0B12A1250AE00B0CAAE /* ContentView.swift */; }; B4ECB0B42A1250AF00B0CAAE /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B4ECB0B32A1250AF00B0CAAE /* Assets.xcassets */; }; @@ -31,6 +33,7 @@ 80D30FC52A1CDBC200C653B0 /* RepositoryDescriptionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryDescriptionSheet.swift; sourceTree = ""; }; 80E8B9D02A1BAE2800E4B3CC /* RepositorySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositorySearchView.swift; sourceTree = ""; }; 80E8B9D22A1BAEC200E4B3CC /* RepositoryDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryDetailView.swift; sourceTree = ""; }; + B4C46ABF2CEF7CA300CC2BE7 /* ObservableTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableTestView.swift; sourceTree = ""; }; B4ECB0AC2A1250AE00B0CAAE /* AsyncReactorExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AsyncReactorExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; B4ECB0AF2A1250AE00B0CAAE /* AsyncReactorExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncReactorExampleApp.swift; sourceTree = ""; }; B4ECB0B12A1250AE00B0CAAE /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -46,6 +49,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + B4A9DDD22CF23EF2008CA694 /* Reactor in Frameworks */, B4ECB0C82A12520700B0CAAE /* Logging in Frameworks */, B4ECB0C12A1250E000B0CAAE /* AsyncReactor in Frameworks */, ); @@ -125,6 +129,7 @@ 809CDDE42A1B9591001D17BE /* Models */, B4ECB0AF2A1250AE00B0CAAE /* AsyncReactorExampleApp.swift */, B4ECB0B12A1250AE00B0CAAE /* ContentView.swift */, + B4C46ABF2CEF7CA300CC2BE7 /* ObservableTestView.swift */, B4ECB0B32A1250AF00B0CAAE /* Assets.xcassets */, B4ECB0B52A1250AF00B0CAAE /* AsyncReactorExample.entitlements */, B4ECB0B62A1250AF00B0CAAE /* Preview Content */, @@ -166,6 +171,7 @@ packageProductDependencies = ( B4ECB0C02A1250E000B0CAAE /* AsyncReactor */, B4ECB0C72A12520700B0CAAE /* Logging */, + B4A9DDD12CF23EF2008CA694 /* Reactor */, ); productName = AsyncReactorExample; productReference = B4ECB0AC2A1250AE00B0CAAE /* AsyncReactorExample.app */; @@ -234,6 +240,7 @@ 80D30FBE2A1BBD8000C653B0 /* RepositoryDetailReactor.swift in Sources */, 80E8B9D12A1BAE2800E4B3CC /* RepositorySearchView.swift in Sources */, 80D30FC42A1BC07B00C653B0 /* RepositoryItem.swift in Sources */, + B4C46AC02CEF7CA300CC2BE7 /* ObservableTestView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -458,6 +465,10 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + B4A9DDD12CF23EF2008CA694 /* Reactor */ = { + isa = XCSwiftPackageProductDependency; + productName = Reactor; + }; B4ECB0C02A1250E000B0CAAE /* AsyncReactor */ = { isa = XCSwiftPackageProductDependency; productName = AsyncReactor; diff --git a/Example/AsyncReactorExample/ObservableTestView.swift b/Example/AsyncReactorExample/ObservableTestView.swift new file mode 100644 index 0000000..ac885c7 --- /dev/null +++ b/Example/AsyncReactorExample/ObservableTestView.swift @@ -0,0 +1,76 @@ +// +// ObservableTestView.swift +// AsyncReactorExample +// +// Created by Dominik Arnhof on 21.11.24. +// + +import SwiftUI +import Reactor + +@available(iOS 17.0, *) +@Observable +class ObservableTestReactor: Reactor { + enum SyncAction { + case count + case enterText(String) + } + + @Observable + class State { + var count = 0 + var text = "test" + } + + private(set) var state = State() + + init(state: State = State()) { + self.state = state + print("reactor init") + } + + func action(_ action: SyncAction) { + switch action { + case .count: + state.count += 1 + case .enterText(let text): + state.text = text + } + } +} + +@available(iOS 17.0, *) +struct ObservableTestView: View { + @Environment(ObservableTestReactor.self) + private var reactor + + @ActionBinding(ObservableTestReactor.self, keyPath: \.text, action: ObservableTestReactor.SyncAction.enterText) + private var text: String + + var body: some View { + let _ = Self._printChanges() + VStack { + Text(reactor.count.formatted()) + + Button { + reactor.action(.count) + } label: { + Text("+") + } + + Text(reactor.text) + + TextField("Text", text: $text) + .textFieldStyle(.roundedBorder) + .padding() + } + } +} + +#Preview { + if #available(iOS 17.0, *) { + ReactorView(ObservableTestReactor()) { + ObservableTestView() + } + } +} diff --git a/Package.swift b/Package.swift index 632e232..07c446d 100644 --- a/Package.swift +++ b/Package.swift @@ -15,7 +15,12 @@ let package = Package( // Products define the executables and libraries a package produces, and make them visible to other packages. .library( name: "AsyncReactor", - targets: ["AsyncReactor"]), + targets: ["AsyncReactor"] + ), + .library( + name: "Reactor", + targets: ["Reactor"] + ) ], dependencies: [ // Dependencies declare other packages that this package depends on. @@ -24,10 +29,17 @@ let package = Package( targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "ReactorBase", + dependencies: [] + ), + .target( + name: "Reactor", + dependencies: ["ReactorBase"] + ), .target( name: "AsyncReactor", - dependencies: [], - path: "Sources" + dependencies: ["ReactorBase"] ), .testTarget( name: "AsyncReactorTests", diff --git a/Sources/AsyncReactor/AsyncReactor+SwiftUI.swift b/Sources/AsyncReactor/AsyncReactor+SwiftUI.swift index e7de3c3..19160d0 100644 --- a/Sources/AsyncReactor/AsyncReactor+SwiftUI.swift +++ b/Sources/AsyncReactor/AsyncReactor+SwiftUI.swift @@ -7,42 +7,7 @@ #if canImport(SwiftUI) import SwiftUI - -extension AsyncReactor { - @MainActor - public func bind(_ keyPath: KeyPath, cancelId: CancelId? = nil, action: @escaping (T) -> Action) -> Binding { - Binding { - self.state[keyPath: keyPath] - } set: { newValue in - Task { - if let cancelId { - await self.action(action(newValue), id: cancelId) - } else { - await self.action(action(newValue)) - } - } - } - } - - @MainActor - public func bind(_ keyPath: KeyPath, cancelId: CancelId? = nil, action: @escaping @autoclosure () -> Action) -> Binding { - bind(keyPath, cancelId: cancelId) { _ in action() } - } - - @MainActor - public func bind(_ keyPath: KeyPath, action: @escaping (T) -> SyncAction) -> Binding { - Binding { - self.state[keyPath: keyPath] - } set: { newValue in - self.action(action(newValue)) - } - } - - @MainActor - public func bind(_ keyPath: KeyPath, action: @escaping @autoclosure () -> SyncAction) -> Binding { - bind(keyPath) { _ in action() } - } -} +import ReactorBase /// Property wrapper to get a binding to a state keyPath and a associated Action /// Can be used and behaves like the `@State` property wrapper @@ -123,40 +88,4 @@ public struct ReactorView: View { .reactorLifecycle(definesLifecycle ? reactor : nil) } } - -private class LifecycleModel: ObservableObject { - let onDeinit: () -> Void - - init(onDeinit: @escaping () -> Void) { - self.onDeinit = onDeinit - } - - deinit { - onDeinit() - } -} - -struct ReactorLifecycleCancel: ViewModifier { - @StateObject - private var model: LifecycleModel - - init(reactor: (any AsyncReactor)?) { - _model = StateObject(wrappedValue: LifecycleModel(onDeinit: { - reactor?.cancelLifecycleTasks() - })) - } - - func body(content: Content) -> some View { - content - .task { - // empty task because when not modifying the content at all, SwiftUI seems to optimise away the modifier - } - } -} - -extension View { - public func reactorLifecycle(_ reactor: (any AsyncReactor)?) -> some View { - modifier(ReactorLifecycleCancel(reactor: reactor)) - } -} #endif diff --git a/Sources/AsyncReactor/AsyncReactor.swift b/Sources/AsyncReactor/AsyncReactor.swift index 7e957b8..8580ebd 100644 --- a/Sources/AsyncReactor/AsyncReactor.swift +++ b/Sources/AsyncReactor/AsyncReactor.swift @@ -6,130 +6,7 @@ // import Foundation +import ReactorBase -@MainActor @dynamicMemberLookup -public protocol AsyncReactor: ObservableObject { - associatedtype Action - associatedtype SyncAction = Never - associatedtype State - - var state: State { get } - - func action(_ action: Action) async - - func action(_ action: SyncAction) - - subscript(dynamicMember keyPath: KeyPath) -> Value { get } -} - -extension AsyncReactor { - @MainActor - public func send(_ action: Action) { - Task { await self.action(action) } - } -} - -extension AsyncReactor where SyncAction == Never { - public func action(_ action: SyncAction) { - - } -} - -// MARK: - DynamicMemberLookup - -extension AsyncReactor { - @MainActor - public subscript(dynamicMember keyPath: KeyPath) -> Value { - state[keyPath: keyPath] - } -} - -// MARK: - Cancellation Support - -public struct CancelId: Hashable { - let id: AnyHashable - let mode: Mode - - public init(id: AnyHashable, mode: Mode) { - self.id = id - self.mode = mode - } - - public struct Mode: OptionSet, Hashable { - public let rawValue: Int - - public init(rawValue: Int) { - self.rawValue = rawValue - } - - public static let lifecycle = Mode(rawValue: 1 << 0) - public static let inFlight = Mode(rawValue: 1 << 1) - } -} - -private struct TasksHolder { - @MainActor - static var tasks = [TaskKey: Task]() -} - -private struct TaskKey: Hashable { - let reactorId: ObjectIdentifier - let id: CancelId - - init(reactor: AnyObject, id: CancelId) { - reactorId = ObjectIdentifier(reactor) - self.id = id - } -} - -extension AsyncReactor { - @MainActor - public func action(_ action: Action, id: CancelId) async { - let key = TaskKey(reactor: self, id: id) - - if id.mode.contains(.inFlight) { - TasksHolder.tasks[key]?.cancel() - } - - let task = Task { - await self.action(action) - } - - TasksHolder.tasks[key] = task - - await task.value - - if !task.isCancelled { - TasksHolder.tasks.removeValue(forKey: key) - } - } - - public func send(_ action: Action, id: CancelId) { - Task { await self.action(action, id: id) } - } - - public func lifecycleTask(_ action: @escaping @Sendable () async -> Void) { - Task { @MainActor in - let key = TaskKey(reactor: self, id: .init(id: UUID(), mode: .lifecycle)) - - let task = Task.detached { - await action() - await MainActor.run { _ = TasksHolder.tasks.removeValue(forKey: key) } - } - - TasksHolder.tasks[key] = task - } - } - - public func cancelLifecycleTasks() { - Task { @MainActor in - let keys = TasksHolder.tasks.keys.filter { $0.id.mode.contains(.lifecycle) && $0.reactorId == ObjectIdentifier(self) } - - for key in keys { - TasksHolder.tasks[key]?.cancel() - TasksHolder.tasks.removeValue(forKey: key) - } - } - } -} +public protocol AsyncReactor: ObservableObject, ReactorBase {} diff --git a/Sources/Reactor/Reactor+SwiftUI.swift b/Sources/Reactor/Reactor+SwiftUI.swift new file mode 100644 index 0000000..1ea4a72 --- /dev/null +++ b/Sources/Reactor/Reactor+SwiftUI.swift @@ -0,0 +1,93 @@ +// +// Reactor+SwiftUI.swift +// AsyncReactor +// +// Created by Dominik Arnhof on 23.11.24. +// + +#if canImport(SwiftUI) +import SwiftUI +import ReactorBase + +/// Property wrapper to get a binding to a state keyPath and a associated Action +/// Can be used and behaves like the `@State` property wrapper +@available(iOS 17.0, tvOS 17.0, watchOS 10.0, macOS 14.0, *) +@MainActor +@propertyWrapper +public struct ActionBinding: DynamicProperty { + let target: Environment + + let keyPath: KeyPath + let action: (Value) -> Action + + let cancelId: CancelId? + + public init(_ reactorType: R.Type, keyPath: KeyPath, cancelId: CancelId? = nil, action: @escaping (Value) -> R.Action) where Action == R.Action { + target = Environment(R.self) + self.keyPath = keyPath + self.action = action + self.cancelId = cancelId + } + + public init(_ reactorType: R.Type, keyPath: KeyPath, cancelId: CancelId? = nil, action: @escaping @autoclosure () -> R.Action) where Action == R.Action { + self.init(reactorType, keyPath: keyPath, cancelId: cancelId, action: { _ in action() }) + } + + public init(_ reactorType: R.Type, keyPath: KeyPath, action: @escaping (Value) -> R.SyncAction) where Action == R.SyncAction { + target = Environment(R.self) + self.keyPath = keyPath + self.action = action + cancelId = nil + } + + public init(_ reactorType: R.Type, keyPath: KeyPath, action: @escaping @autoclosure () -> R.SyncAction) where Action == R.SyncAction { + self.init(reactorType, keyPath: keyPath, action: { _ in action() }) + } + + public var wrappedValue: Value { + get { projectedValue.wrappedValue } + nonmutating set { projectedValue.wrappedValue = newValue } + } + + public var projectedValue: Binding { + get { + func bindAction() -> Binding { + target.wrappedValue.bind(keyPath, cancelId: cancelId, action: action as! (Value) -> R.Action) + } + + func bindSyncAction() -> Binding { + target.wrappedValue.bind(keyPath, action: action as! (Value) -> R.SyncAction) + } + + if Action.self == R.SyncAction.self { + return bindSyncAction() + } else if Action.self == R.Action.self { + return bindAction() + } else { + fatalError("this should never happen :)") + } + } + } +} + +@available(iOS 17.0, tvOS 17.0, watchOS 10.0, macOS 14.0, *) +public struct ReactorView: View { + let content: Content + let definesLifecycle: Bool + + @State + private var reactor: R + + public init(_ reactor: @escaping @autoclosure () -> R, definesLifecycle: Bool = true, @ViewBuilder content: () -> Content) { + _reactor = State(initialValue: reactor()) + self.content = content() + self.definesLifecycle = definesLifecycle + } + + public var body: some View { + content + .environment(reactor) + .reactorLifecycle(definesLifecycle ? reactor : nil) + } +} +#endif diff --git a/Sources/Reactor/Reactor.swift b/Sources/Reactor/Reactor.swift new file mode 100644 index 0000000..9cf48f8 --- /dev/null +++ b/Sources/Reactor/Reactor.swift @@ -0,0 +1,13 @@ +// +// Reactor.swift +// AsyncReactor +// +// Created by Dominik Arnhof on 23.11.24. +// + +import Foundation +import ReactorBase + +@available(iOS 17.0, tvOS 17.0, watchOS 10.0, macOS 14.0, *) +@dynamicMemberLookup +public protocol Reactor: Observable, ReactorBase where State: Observable {} diff --git a/Sources/ReactorBase/ReactorBase+SwiftUI.swift b/Sources/ReactorBase/ReactorBase+SwiftUI.swift new file mode 100644 index 0000000..3b03173 --- /dev/null +++ b/Sources/ReactorBase/ReactorBase+SwiftUI.swift @@ -0,0 +1,83 @@ +// +// ReactorBase+SwiftUI.swift +// AsyncReactor +// +// Created by Dominik Arnhof on 23.11.24. +// + +#if canImport(SwiftUI) +import SwiftUI + +extension ReactorBase { + @MainActor + public func bind(_ keyPath: KeyPath, cancelId: CancelId? = nil, action: @escaping (T) -> Action) -> Binding { + Binding { + self.state[keyPath: keyPath] + } set: { newValue in + Task { + if let cancelId { + await self.action(action(newValue), id: cancelId) + } else { + await self.action(action(newValue)) + } + } + } + } + + @MainActor + public func bind(_ keyPath: KeyPath, cancelId: CancelId? = nil, action: @escaping @autoclosure () -> Action) -> Binding { + bind(keyPath, cancelId: cancelId) { _ in action() } + } + + @MainActor + public func bind(_ keyPath: KeyPath, action: @escaping (T) -> SyncAction) -> Binding { + Binding { + self.state[keyPath: keyPath] + } set: { newValue in + self.action(action(newValue)) + } + } + + @MainActor + public func bind(_ keyPath: KeyPath, action: @escaping @autoclosure () -> SyncAction) -> Binding { + bind(keyPath) { _ in action() } + } +} + +private class LifecycleModel: ObservableObject { + let onDeinit: () -> Void + + init(onDeinit: @escaping () -> Void) { + self.onDeinit = onDeinit + } + + deinit { + onDeinit() + } +} + +struct ReactorLifecycleCancel: ViewModifier { + @StateObject + private var model: LifecycleModel + + init(reactor: (any ReactorBase)?) { + _model = StateObject(wrappedValue: LifecycleModel(onDeinit: { + reactor?.cancelLifecycleTasks() + })) + } + + func body(content: Content) -> some View { + content + .task { + // empty task because when not modifying the content at all, SwiftUI seems to optimise away the modifier + } + } +} + +extension View { + public func reactorLifecycle(_ reactor: (any ReactorBase)?) -> some View { + modifier(ReactorLifecycleCancel(reactor: reactor)) + } +} +#endif + diff --git a/Sources/ReactorBase/ReactorBase.swift b/Sources/ReactorBase/ReactorBase.swift new file mode 100644 index 0000000..48f38f7 --- /dev/null +++ b/Sources/ReactorBase/ReactorBase.swift @@ -0,0 +1,142 @@ +// +// ReactorBase.swift +// AsyncReactor +// +// Created by Dominik Arnhof on 23.11.24. +// + +import Foundation + +@MainActor +@dynamicMemberLookup +public protocol ReactorBase: AnyObject { + associatedtype Action = Never + associatedtype SyncAction = Never + associatedtype State + + var state: State { get } + + func action(_ action: Action) async + + func action(_ action: SyncAction) + + subscript(dynamicMember keyPath: KeyPath) -> Value { get } +} + +extension ReactorBase { + @MainActor + public func send(_ action: Action) { + Task { await self.action(action) } + } +} + +extension ReactorBase where Action == Never { + public func action(_ action: Action) async { + + } +} + +extension ReactorBase where SyncAction == Never { + public func action(_ action: SyncAction) { + + } +} + +// MARK: - DynamicMemberLookup + +extension ReactorBase { + @MainActor + public subscript(dynamicMember keyPath: KeyPath) -> Value { + state[keyPath: keyPath] + } +} + +// MARK: - Cancellation Support + +public struct CancelId: Hashable { + let id: AnyHashable + let mode: Mode + + public init(id: AnyHashable, mode: Mode) { + self.id = id + self.mode = mode + } + + public struct Mode: OptionSet, Hashable { + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + public static let lifecycle = Mode(rawValue: 1 << 0) + public static let inFlight = Mode(rawValue: 1 << 1) + } +} + +struct TasksHolder { + @MainActor + static var tasks = [TaskKey: Task]() +} + +struct TaskKey: Hashable { + let reactorId: ObjectIdentifier + let id: CancelId + + init(reactor: AnyObject, id: CancelId) { + reactorId = ObjectIdentifier(reactor) + self.id = id + } +} + +extension ReactorBase { + @MainActor + public func action(_ action: Action, id: CancelId) async { + let key = TaskKey(reactor: self, id: id) + + if id.mode.contains(.inFlight) { + TasksHolder.tasks[key]?.cancel() + } + + let task = Task { + await self.action(action) + } + + TasksHolder.tasks[key] = task + + await task.value + + if !task.isCancelled { + TasksHolder.tasks.removeValue(forKey: key) + } + } + + public func send(_ action: Action, id: CancelId) { + Task { await self.action(action, id: id) } + } + + public func lifecycleTask(_ action: @escaping @Sendable () async -> Void) { + Task { @MainActor in + let key = TaskKey(reactor: self, id: .init(id: UUID(), mode: .lifecycle)) + + let task = Task.detached { + await action() + await MainActor.run { _ = TasksHolder.tasks.removeValue(forKey: key) } + } + + TasksHolder.tasks[key] = task + } + } + + public func cancelLifecycleTasks() { + Task { @MainActor in + let keys = TasksHolder.tasks.keys.filter { $0.id.mode.contains(.lifecycle) && $0.reactorId == ObjectIdentifier(self) } + + for key in keys { + TasksHolder.tasks[key]?.cancel() + TasksHolder.tasks.removeValue(forKey: key) + } + } + } +} + From ab5597acdf04e6e6a44d0d1a18a9cd86a72b76bd Mon Sep 17 00:00:00 2001 From: Alexander Kauer Date: Sun, 2 Mar 2025 13:37:14 +0100 Subject: [PATCH 2/2] User Xcode folders for example project --- .../project.pbxproj | 120 ++---------------- 1 file changed, 9 insertions(+), 111 deletions(-) diff --git a/Example/AsyncReactorExample.xcodeproj/project.pbxproj b/Example/AsyncReactorExample.xcodeproj/project.pbxproj index fe97f58..1cddb22 100644 --- a/Example/AsyncReactorExample.xcodeproj/project.pbxproj +++ b/Example/AsyncReactorExample.xcodeproj/project.pbxproj @@ -3,47 +3,24 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ - 809CDDE62A1B959D001D17BE /* Repository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 809CDDE52A1B959D001D17BE /* Repository.swift */; }; - 80D30FBE2A1BBD8000C653B0 /* RepositoryDetailReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D30FBD2A1BBD8000C653B0 /* RepositoryDetailReactor.swift */; }; - 80D30FC22A1BBF9A00C653B0 /* RepositoryList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D30FC12A1BBF9A00C653B0 /* RepositoryList.swift */; }; - 80D30FC42A1BC07B00C653B0 /* RepositoryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D30FC32A1BC07B00C653B0 /* RepositoryItem.swift */; }; - 80D30FC62A1CDBC200C653B0 /* RepositoryDescriptionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D30FC52A1CDBC200C653B0 /* RepositoryDescriptionSheet.swift */; }; - 80E8B9D12A1BAE2800E4B3CC /* RepositorySearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80E8B9D02A1BAE2800E4B3CC /* RepositorySearchView.swift */; }; - 80E8B9D32A1BAEC200E4B3CC /* RepositoryDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80E8B9D22A1BAEC200E4B3CC /* RepositoryDetailView.swift */; }; B4A9DDD22CF23EF2008CA694 /* Reactor in Frameworks */ = {isa = PBXBuildFile; productRef = B4A9DDD12CF23EF2008CA694 /* Reactor */; }; - B4C46AC02CEF7CA300CC2BE7 /* ObservableTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C46ABF2CEF7CA300CC2BE7 /* ObservableTestView.swift */; }; - B4ECB0B02A1250AE00B0CAAE /* AsyncReactorExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4ECB0AF2A1250AE00B0CAAE /* AsyncReactorExampleApp.swift */; }; - B4ECB0B22A1250AE00B0CAAE /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4ECB0B12A1250AE00B0CAAE /* ContentView.swift */; }; - B4ECB0B42A1250AF00B0CAAE /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B4ECB0B32A1250AF00B0CAAE /* Assets.xcassets */; }; - B4ECB0B82A1250AF00B0CAAE /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B4ECB0B72A1250AF00B0CAAE /* Preview Assets.xcassets */; }; B4ECB0C12A1250E000B0CAAE /* AsyncReactor in Frameworks */ = {isa = PBXBuildFile; productRef = B4ECB0C02A1250E000B0CAAE /* AsyncReactor */; }; - B4ECB0C42A1251B500B0CAAE /* RepositorySearchReactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4ECB0C22A1251B500B0CAAE /* RepositorySearchReactor.swift */; }; B4ECB0C82A12520700B0CAAE /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = B4ECB0C72A12520700B0CAAE /* Logging */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 809CDDE52A1B959D001D17BE /* Repository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Repository.swift; sourceTree = ""; }; - 80D30FBD2A1BBD8000C653B0 /* RepositoryDetailReactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryDetailReactor.swift; sourceTree = ""; }; - 80D30FC12A1BBF9A00C653B0 /* RepositoryList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryList.swift; sourceTree = ""; }; - 80D30FC32A1BC07B00C653B0 /* RepositoryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryItem.swift; sourceTree = ""; }; - 80D30FC52A1CDBC200C653B0 /* RepositoryDescriptionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryDescriptionSheet.swift; sourceTree = ""; }; - 80E8B9D02A1BAE2800E4B3CC /* RepositorySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositorySearchView.swift; sourceTree = ""; }; - 80E8B9D22A1BAEC200E4B3CC /* RepositoryDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryDetailView.swift; sourceTree = ""; }; - B4C46ABF2CEF7CA300CC2BE7 /* ObservableTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableTestView.swift; sourceTree = ""; }; B4ECB0AC2A1250AE00B0CAAE /* AsyncReactorExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AsyncReactorExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; - B4ECB0AF2A1250AE00B0CAAE /* AsyncReactorExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncReactorExampleApp.swift; sourceTree = ""; }; - B4ECB0B12A1250AE00B0CAAE /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; - B4ECB0B32A1250AF00B0CAAE /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - B4ECB0B52A1250AF00B0CAAE /* AsyncReactorExample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = AsyncReactorExample.entitlements; sourceTree = ""; }; - B4ECB0B72A1250AF00B0CAAE /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; B4ECB0BE2A1250D700B0CAAE /* AsyncReactor */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = AsyncReactor; path = ..; sourceTree = ""; }; - B4ECB0C22A1251B500B0CAAE /* RepositorySearchReactor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RepositorySearchReactor.swift; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 82F565F82D74885200C72CA5 /* AsyncReactorExample */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = AsyncReactorExample; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ B4ECB0A92A1250AE00B0CAAE /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -58,57 +35,11 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 809CDDE42A1B9591001D17BE /* Models */ = { - isa = PBXGroup; - children = ( - 809CDDE52A1B959D001D17BE /* Repository.swift */, - ); - path = Models; - sourceTree = ""; - }; - 80E8B9CA2A1BAC2200E4B3CC /* Features */ = { - isa = PBXGroup; - children = ( - 80E8B9CB2A1BAC4900E4B3CC /* Repository */, - ); - path = Features; - sourceTree = ""; - }; - 80E8B9CB2A1BAC4900E4B3CC /* Repository */ = { - isa = PBXGroup; - children = ( - 80E8B9D42A1BBB1D00E4B3CC /* Detail */, - 80E8B9CD2A1BAC5F00E4B3CC /* Search */, - ); - path = Repository; - sourceTree = ""; - }; - 80E8B9CD2A1BAC5F00E4B3CC /* Search */ = { - isa = PBXGroup; - children = ( - B4ECB0C22A1251B500B0CAAE /* RepositorySearchReactor.swift */, - 80E8B9D02A1BAE2800E4B3CC /* RepositorySearchView.swift */, - 80D30FC12A1BBF9A00C653B0 /* RepositoryList.swift */, - 80D30FC32A1BC07B00C653B0 /* RepositoryItem.swift */, - ); - path = Search; - sourceTree = ""; - }; - 80E8B9D42A1BBB1D00E4B3CC /* Detail */ = { - isa = PBXGroup; - children = ( - 80E8B9D22A1BAEC200E4B3CC /* RepositoryDetailView.swift */, - 80D30FBD2A1BBD8000C653B0 /* RepositoryDetailReactor.swift */, - 80D30FC52A1CDBC200C653B0 /* RepositoryDescriptionSheet.swift */, - ); - path = Detail; - sourceTree = ""; - }; B4ECB0A32A1250AE00B0CAAE = { isa = PBXGroup; children = ( B4ECB0BE2A1250D700B0CAAE /* AsyncReactor */, - B4ECB0AE2A1250AE00B0CAAE /* AsyncReactorExample */, + 82F565F82D74885200C72CA5 /* AsyncReactorExample */, B4ECB0AD2A1250AE00B0CAAE /* Products */, B4ECB0BF2A1250E000B0CAAE /* Frameworks */, ); @@ -122,29 +53,6 @@ name = Products; sourceTree = ""; }; - B4ECB0AE2A1250AE00B0CAAE /* AsyncReactorExample */ = { - isa = PBXGroup; - children = ( - 80E8B9CA2A1BAC2200E4B3CC /* Features */, - 809CDDE42A1B9591001D17BE /* Models */, - B4ECB0AF2A1250AE00B0CAAE /* AsyncReactorExampleApp.swift */, - B4ECB0B12A1250AE00B0CAAE /* ContentView.swift */, - B4C46ABF2CEF7CA300CC2BE7 /* ObservableTestView.swift */, - B4ECB0B32A1250AF00B0CAAE /* Assets.xcassets */, - B4ECB0B52A1250AF00B0CAAE /* AsyncReactorExample.entitlements */, - B4ECB0B62A1250AF00B0CAAE /* Preview Content */, - ); - path = AsyncReactorExample; - sourceTree = ""; - }; - B4ECB0B62A1250AF00B0CAAE /* Preview Content */ = { - isa = PBXGroup; - children = ( - B4ECB0B72A1250AF00B0CAAE /* Preview Assets.xcassets */, - ); - path = "Preview Content"; - sourceTree = ""; - }; B4ECB0BF2A1250E000B0CAAE /* Frameworks */ = { isa = PBXGroup; children = ( @@ -167,6 +75,9 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + 82F565F82D74885200C72CA5 /* AsyncReactorExample */, + ); name = AsyncReactorExample; packageProductDependencies = ( B4ECB0C02A1250E000B0CAAE /* AsyncReactor */, @@ -218,8 +129,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - B4ECB0B82A1250AF00B0CAAE /* Preview Assets.xcassets in Resources */, - B4ECB0B42A1250AF00B0CAAE /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -230,17 +139,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - B4ECB0B22A1250AE00B0CAAE /* ContentView.swift in Sources */, - 80D30FC22A1BBF9A00C653B0 /* RepositoryList.swift in Sources */, - 80D30FC62A1CDBC200C653B0 /* RepositoryDescriptionSheet.swift in Sources */, - 809CDDE62A1B959D001D17BE /* Repository.swift in Sources */, - B4ECB0B02A1250AE00B0CAAE /* AsyncReactorExampleApp.swift in Sources */, - 80E8B9D32A1BAEC200E4B3CC /* RepositoryDetailView.swift in Sources */, - B4ECB0C42A1251B500B0CAAE /* RepositorySearchReactor.swift in Sources */, - 80D30FBE2A1BBD8000C653B0 /* RepositoryDetailReactor.swift in Sources */, - 80E8B9D12A1BAE2800E4B3CC /* RepositorySearchView.swift in Sources */, - 80D30FC42A1BC07B00C653B0 /* RepositoryItem.swift in Sources */, - B4C46AC02CEF7CA300CC2BE7 /* ObservableTestView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; };