Skip to content

Commit 765b296

Browse files
committed
add "Reactor" product with experimental @observable support
1 parent 3229ec7 commit 765b296

File tree

9 files changed

+436
-200
lines changed

9 files changed

+436
-200
lines changed

Example/AsyncReactorExample.xcodeproj/project.pbxproj

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
80D30FC62A1CDBC200C653B0 /* RepositoryDescriptionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D30FC52A1CDBC200C653B0 /* RepositoryDescriptionSheet.swift */; };
1515
80E8B9D12A1BAE2800E4B3CC /* RepositorySearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80E8B9D02A1BAE2800E4B3CC /* RepositorySearchView.swift */; };
1616
80E8B9D32A1BAEC200E4B3CC /* RepositoryDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80E8B9D22A1BAEC200E4B3CC /* RepositoryDetailView.swift */; };
17+
B4A9DDD22CF23EF2008CA694 /* Reactor in Frameworks */ = {isa = PBXBuildFile; productRef = B4A9DDD12CF23EF2008CA694 /* Reactor */; };
18+
B4C46AC02CEF7CA300CC2BE7 /* ObservableTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C46ABF2CEF7CA300CC2BE7 /* ObservableTestView.swift */; };
1719
B4ECB0B02A1250AE00B0CAAE /* AsyncReactorExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4ECB0AF2A1250AE00B0CAAE /* AsyncReactorExampleApp.swift */; };
1820
B4ECB0B22A1250AE00B0CAAE /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4ECB0B12A1250AE00B0CAAE /* ContentView.swift */; };
1921
B4ECB0B42A1250AF00B0CAAE /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B4ECB0B32A1250AF00B0CAAE /* Assets.xcassets */; };
@@ -31,6 +33,7 @@
3133
80D30FC52A1CDBC200C653B0 /* RepositoryDescriptionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryDescriptionSheet.swift; sourceTree = "<group>"; };
3234
80E8B9D02A1BAE2800E4B3CC /* RepositorySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositorySearchView.swift; sourceTree = "<group>"; };
3335
80E8B9D22A1BAEC200E4B3CC /* RepositoryDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryDetailView.swift; sourceTree = "<group>"; };
36+
B4C46ABF2CEF7CA300CC2BE7 /* ObservableTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableTestView.swift; sourceTree = "<group>"; };
3437
B4ECB0AC2A1250AE00B0CAAE /* AsyncReactorExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AsyncReactorExample.app; sourceTree = BUILT_PRODUCTS_DIR; };
3538
B4ECB0AF2A1250AE00B0CAAE /* AsyncReactorExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncReactorExampleApp.swift; sourceTree = "<group>"; };
3639
B4ECB0B12A1250AE00B0CAAE /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
@@ -46,6 +49,7 @@
4649
isa = PBXFrameworksBuildPhase;
4750
buildActionMask = 2147483647;
4851
files = (
52+
B4A9DDD22CF23EF2008CA694 /* Reactor in Frameworks */,
4953
B4ECB0C82A12520700B0CAAE /* Logging in Frameworks */,
5054
B4ECB0C12A1250E000B0CAAE /* AsyncReactor in Frameworks */,
5155
);
@@ -125,6 +129,7 @@
125129
809CDDE42A1B9591001D17BE /* Models */,
126130
B4ECB0AF2A1250AE00B0CAAE /* AsyncReactorExampleApp.swift */,
127131
B4ECB0B12A1250AE00B0CAAE /* ContentView.swift */,
132+
B4C46ABF2CEF7CA300CC2BE7 /* ObservableTestView.swift */,
128133
B4ECB0B32A1250AF00B0CAAE /* Assets.xcassets */,
129134
B4ECB0B52A1250AF00B0CAAE /* AsyncReactorExample.entitlements */,
130135
B4ECB0B62A1250AF00B0CAAE /* Preview Content */,
@@ -166,6 +171,7 @@
166171
packageProductDependencies = (
167172
B4ECB0C02A1250E000B0CAAE /* AsyncReactor */,
168173
B4ECB0C72A12520700B0CAAE /* Logging */,
174+
B4A9DDD12CF23EF2008CA694 /* Reactor */,
169175
);
170176
productName = AsyncReactorExample;
171177
productReference = B4ECB0AC2A1250AE00B0CAAE /* AsyncReactorExample.app */;
@@ -234,6 +240,7 @@
234240
80D30FBE2A1BBD8000C653B0 /* RepositoryDetailReactor.swift in Sources */,
235241
80E8B9D12A1BAE2800E4B3CC /* RepositorySearchView.swift in Sources */,
236242
80D30FC42A1BC07B00C653B0 /* RepositoryItem.swift in Sources */,
243+
B4C46AC02CEF7CA300CC2BE7 /* ObservableTestView.swift in Sources */,
237244
);
238245
runOnlyForDeploymentPostprocessing = 0;
239246
};
@@ -458,6 +465,10 @@
458465
/* End XCRemoteSwiftPackageReference section */
459466

460467
/* Begin XCSwiftPackageProductDependency section */
468+
B4A9DDD12CF23EF2008CA694 /* Reactor */ = {
469+
isa = XCSwiftPackageProductDependency;
470+
productName = Reactor;
471+
};
461472
B4ECB0C02A1250E000B0CAAE /* AsyncReactor */ = {
462473
isa = XCSwiftPackageProductDependency;
463474
productName = AsyncReactor;
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
//
2+
// ObservableTestView.swift
3+
// AsyncReactorExample
4+
//
5+
// Created by Dominik Arnhof on 21.11.24.
6+
//
7+
8+
import SwiftUI
9+
import Reactor
10+
11+
@available(iOS 17.0, *)
12+
@Observable
13+
class ObservableTestReactor: Reactor {
14+
enum SyncAction {
15+
case count
16+
case enterText(String)
17+
}
18+
19+
@Observable
20+
class State {
21+
var count = 0
22+
var text = "test"
23+
}
24+
25+
private(set) var state = State()
26+
27+
init(state: State = State()) {
28+
self.state = state
29+
print("reactor init")
30+
}
31+
32+
func action(_ action: SyncAction) {
33+
switch action {
34+
case .count:
35+
state.count += 1
36+
case .enterText(let text):
37+
state.text = text
38+
}
39+
}
40+
}
41+
42+
@available(iOS 17.0, *)
43+
struct ObservableTestView: View {
44+
@Environment(ObservableTestReactor.self)
45+
private var reactor
46+
47+
@ActionBinding(ObservableTestReactor.self, keyPath: \.text, action: ObservableTestReactor.SyncAction.enterText)
48+
private var text: String
49+
50+
var body: some View {
51+
let _ = Self._printChanges()
52+
VStack {
53+
Text(reactor.count.formatted())
54+
55+
Button {
56+
reactor.action(.count)
57+
} label: {
58+
Text("+")
59+
}
60+
61+
Text(reactor.text)
62+
63+
TextField("Text", text: $text)
64+
.textFieldStyle(.roundedBorder)
65+
.padding()
66+
}
67+
}
68+
}
69+
70+
#Preview {
71+
if #available(iOS 17.0, *) {
72+
ReactorView(ObservableTestReactor()) {
73+
ObservableTestView()
74+
}
75+
}
76+
}

Package.swift

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ let package = Package(
1515
// Products define the executables and libraries a package produces, and make them visible to other packages.
1616
.library(
1717
name: "AsyncReactor",
18-
targets: ["AsyncReactor"]),
18+
targets: ["AsyncReactor"]
19+
),
20+
.library(
21+
name: "Reactor",
22+
targets: ["Reactor"]
23+
)
1924
],
2025
dependencies: [
2126
// Dependencies declare other packages that this package depends on.
@@ -24,10 +29,17 @@ let package = Package(
2429
targets: [
2530
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
2631
// Targets can depend on other targets in this package, and on products in packages this package depends on.
32+
.target(
33+
name: "ReactorBase",
34+
dependencies: []
35+
),
36+
.target(
37+
name: "Reactor",
38+
dependencies: ["ReactorBase"]
39+
),
2740
.target(
2841
name: "AsyncReactor",
29-
dependencies: [],
30-
path: "Sources"
42+
dependencies: ["ReactorBase"]
3143
),
3244
.testTarget(
3345
name: "AsyncReactorTests",

Sources/AsyncReactor/AsyncReactor+SwiftUI.swift

Lines changed: 1 addition & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -7,42 +7,7 @@
77

88
#if canImport(SwiftUI)
99
import SwiftUI
10-
11-
extension AsyncReactor {
12-
@MainActor
13-
public func bind<T>(_ keyPath: KeyPath<State, T>, cancelId: CancelId? = nil, action: @escaping (T) -> Action) -> Binding<T> {
14-
Binding {
15-
self.state[keyPath: keyPath]
16-
} set: { newValue in
17-
Task {
18-
if let cancelId {
19-
await self.action(action(newValue), id: cancelId)
20-
} else {
21-
await self.action(action(newValue))
22-
}
23-
}
24-
}
25-
}
26-
27-
@MainActor
28-
public func bind<T>(_ keyPath: KeyPath<State, T>, cancelId: CancelId? = nil, action: @escaping @autoclosure () -> Action) -> Binding<T> {
29-
bind(keyPath, cancelId: cancelId) { _ in action() }
30-
}
31-
32-
@MainActor
33-
public func bind<T>(_ keyPath: KeyPath<State, T>, action: @escaping (T) -> SyncAction) -> Binding<T> {
34-
Binding {
35-
self.state[keyPath: keyPath]
36-
} set: { newValue in
37-
self.action(action(newValue))
38-
}
39-
}
40-
41-
@MainActor
42-
public func bind<T>(_ keyPath: KeyPath<State, T>, action: @escaping @autoclosure () -> SyncAction) -> Binding<T> {
43-
bind(keyPath) { _ in action() }
44-
}
45-
}
10+
import ReactorBase
4611

4712
/// Property wrapper to get a binding to a state keyPath and a associated Action
4813
/// Can be used and behaves like the `@State` property wrapper
@@ -123,40 +88,4 @@ public struct ReactorView<Content: View, R: AsyncReactor>: View {
12388
.reactorLifecycle(definesLifecycle ? reactor : nil)
12489
}
12590
}
126-
127-
private class LifecycleModel: ObservableObject {
128-
let onDeinit: () -> Void
129-
130-
init(onDeinit: @escaping () -> Void) {
131-
self.onDeinit = onDeinit
132-
}
133-
134-
deinit {
135-
onDeinit()
136-
}
137-
}
138-
139-
struct ReactorLifecycleCancel: ViewModifier {
140-
@StateObject
141-
private var model: LifecycleModel
142-
143-
init(reactor: (any AsyncReactor)?) {
144-
_model = StateObject(wrappedValue: LifecycleModel(onDeinit: {
145-
reactor?.cancelLifecycleTasks()
146-
}))
147-
}
148-
149-
func body(content: Content) -> some View {
150-
content
151-
.task {
152-
// empty task because when not modifying the content at all, SwiftUI seems to optimise away the modifier
153-
}
154-
}
155-
}
156-
157-
extension View {
158-
public func reactorLifecycle(_ reactor: (any AsyncReactor)?) -> some View {
159-
modifier(ReactorLifecycleCancel(reactor: reactor))
160-
}
161-
}
16291
#endif

Sources/AsyncReactor/AsyncReactor.swift

Lines changed: 2 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -6,130 +6,7 @@
66
//
77

88
import Foundation
9+
import ReactorBase
910

10-
@MainActor
1111
@dynamicMemberLookup
12-
public protocol AsyncReactor: ObservableObject {
13-
associatedtype Action
14-
associatedtype SyncAction = Never
15-
associatedtype State
16-
17-
var state: State { get }
18-
19-
func action(_ action: Action) async
20-
21-
func action(_ action: SyncAction)
22-
23-
subscript<Value>(dynamicMember keyPath: KeyPath<State, Value>) -> Value { get }
24-
}
25-
26-
extension AsyncReactor {
27-
@MainActor
28-
public func send(_ action: Action) {
29-
Task { await self.action(action) }
30-
}
31-
}
32-
33-
extension AsyncReactor where SyncAction == Never {
34-
public func action(_ action: SyncAction) {
35-
36-
}
37-
}
38-
39-
// MARK: - DynamicMemberLookup
40-
41-
extension AsyncReactor {
42-
@MainActor
43-
public subscript<Value>(dynamicMember keyPath: KeyPath<State, Value>) -> Value {
44-
state[keyPath: keyPath]
45-
}
46-
}
47-
48-
// MARK: - Cancellation Support
49-
50-
public struct CancelId: Hashable {
51-
let id: AnyHashable
52-
let mode: Mode
53-
54-
public init(id: AnyHashable, mode: Mode) {
55-
self.id = id
56-
self.mode = mode
57-
}
58-
59-
public struct Mode: OptionSet, Hashable {
60-
public let rawValue: Int
61-
62-
public init(rawValue: Int) {
63-
self.rawValue = rawValue
64-
}
65-
66-
public static let lifecycle = Mode(rawValue: 1 << 0)
67-
public static let inFlight = Mode(rawValue: 1 << 1)
68-
}
69-
}
70-
71-
private struct TasksHolder {
72-
@MainActor
73-
static var tasks = [TaskKey: Task<Void, Never>]()
74-
}
75-
76-
private struct TaskKey: Hashable {
77-
let reactorId: ObjectIdentifier
78-
let id: CancelId
79-
80-
init(reactor: AnyObject, id: CancelId) {
81-
reactorId = ObjectIdentifier(reactor)
82-
self.id = id
83-
}
84-
}
85-
86-
extension AsyncReactor {
87-
@MainActor
88-
public func action(_ action: Action, id: CancelId) async {
89-
let key = TaskKey(reactor: self, id: id)
90-
91-
if id.mode.contains(.inFlight) {
92-
TasksHolder.tasks[key]?.cancel()
93-
}
94-
95-
let task = Task {
96-
await self.action(action)
97-
}
98-
99-
TasksHolder.tasks[key] = task
100-
101-
await task.value
102-
103-
if !task.isCancelled {
104-
TasksHolder.tasks.removeValue(forKey: key)
105-
}
106-
}
107-
108-
public func send(_ action: Action, id: CancelId) {
109-
Task { await self.action(action, id: id) }
110-
}
111-
112-
public func lifecycleTask(_ action: @escaping @Sendable () async -> Void) {
113-
Task { @MainActor in
114-
let key = TaskKey(reactor: self, id: .init(id: UUID(), mode: .lifecycle))
115-
116-
let task = Task.detached {
117-
await action()
118-
await MainActor.run { _ = TasksHolder.tasks.removeValue(forKey: key) }
119-
}
120-
121-
TasksHolder.tasks[key] = task
122-
}
123-
}
124-
125-
public func cancelLifecycleTasks() {
126-
Task { @MainActor in
127-
let keys = TasksHolder.tasks.keys.filter { $0.id.mode.contains(.lifecycle) && $0.reactorId == ObjectIdentifier(self) }
128-
129-
for key in keys {
130-
TasksHolder.tasks[key]?.cancel()
131-
TasksHolder.tasks.removeValue(forKey: key)
132-
}
133-
}
134-
}
135-
}
12+
public protocol AsyncReactor: ObservableObject, ReactorBase {}

0 commit comments

Comments
 (0)