Skip to content

Commit 24d45b4

Browse files
mbrandonwstephencelis
authored andcommitted
Lifecycle case study (#222)
* lifecycle * wip * wip * wip * wip * Xcode 11.3 compat * move * Update Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-Lifecycle.swift Co-authored-by: Stephen Celis <[email protected]> * tests * iOS 14 fix See: https://gist.github.com/stephencelis/3ac40c2aba73c45f76b35a0cce0864fd Co-authored-by: Stephen Celis <[email protected]>
1 parent fe19a30 commit 24d45b4

File tree

5 files changed

+246
-0
lines changed

5 files changed

+246
-0
lines changed

Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
objects = {
88

99
/* Begin PBXBuildFile section */
10+
CA0C0C4724B89BEC00CBDD8A /* 04-HigherOrderReducers-LifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA0C0C4624B89BEC00CBDD8A /* 04-HigherOrderReducers-LifecycleTests.swift */; };
1011
CA0C51FB245389CC00A04EAB /* 04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA0C51FA245389CC00A04EAB /* 04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift */; };
1112
CA25E5D224463AD700DA666A /* 01-GettingStarted-Bindings-Basics.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA25E5D124463AD700DA666A /* 01-GettingStarted-Bindings-Basics.swift */; };
1213
CA27C0B7245780CE00CB1E59 /* 03-Effects-SystemEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA27C0B6245780CE00CB1E59 /* 03-Effects-SystemEnvironment.swift */; };
1314
CA34170824A4E89500FAF950 /* 01-GettingStarted-AnimationsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA34170724A4E89500FAF950 /* 01-GettingStarted-AnimationsTests.swift */; };
15+
CA3E4C5B24B4FA0E00447C0B /* 04-HigherOrderReducers-Lifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA3E4C5A24B4FA0E00447C0B /* 04-HigherOrderReducers-Lifecycle.swift */; };
1416
CA410EE0247A15FE00E41798 /* 02-Effects-WebSocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA410EDF247A15FE00E41798 /* 02-Effects-WebSocket.swift */; };
1517
CA410EE2247C73B400E41798 /* 02-Effects-WebSocketTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA410EE1247C73B400E41798 /* 02-Effects-WebSocketTests.swift */; };
1618
CA50BE6024A8F46500FE7DBA /* 01-GettingStarted-AlertsAndActionSheetsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA50BE5F24A8F46500FE7DBA /* 01-GettingStarted-AlertsAndActionSheetsTests.swift */; };
@@ -128,10 +130,12 @@
128130
/* End PBXCopyFilesBuildPhase section */
129131

130132
/* Begin PBXFileReference section */
133+
CA0C0C4624B89BEC00CBDD8A /* 04-HigherOrderReducers-LifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-HigherOrderReducers-LifecycleTests.swift"; sourceTree = "<group>"; };
131134
CA0C51FA245389CC00A04EAB /* 04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift"; sourceTree = "<group>"; };
132135
CA25E5D124463AD700DA666A /* 01-GettingStarted-Bindings-Basics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-Bindings-Basics.swift"; sourceTree = "<group>"; };
133136
CA27C0B6245780CE00CB1E59 /* 03-Effects-SystemEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-Effects-SystemEnvironment.swift"; sourceTree = "<group>"; };
134137
CA34170724A4E89500FAF950 /* 01-GettingStarted-AnimationsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-AnimationsTests.swift"; sourceTree = "<group>"; };
138+
CA3E4C5A24B4FA0E00447C0B /* 04-HigherOrderReducers-Lifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-HigherOrderReducers-Lifecycle.swift"; sourceTree = "<group>"; };
135139
CA410EDF247A15FE00E41798 /* 02-Effects-WebSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-WebSocket.swift"; sourceTree = "<group>"; };
136140
CA410EE1247C73B400E41798 /* 02-Effects-WebSocketTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-WebSocketTests.swift"; sourceTree = "<group>"; };
137141
CA50BE5F24A8F46500FE7DBA /* 01-GettingStarted-AlertsAndActionSheetsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-AlertsAndActionSheetsTests.swift"; sourceTree = "<group>"; };
@@ -335,6 +339,7 @@
335339
DC072321244663B1003A8B65 /* 03-Navigation-Sheet-LoadThenPresent.swift */,
336340
DCC68EAA244666AF0037F998 /* 03-Navigation-Sheet-PresentAndLoad.swift */,
337341
DCAC2A4E2452352E0094DEF5 /* 04-HigherOrderReducers-ElmLikeSubscriptions.swift */,
342+
CA3E4C5A24B4FA0E00447C0B /* 04-HigherOrderReducers-Lifecycle.swift */,
338343
DCE63B70245CC0B90080A23D /* 04-HigherOrderReducers-Recursion.swift */,
339344
DCC68EE22447C8540037F998 /* 04-HigherOrderReducers-ReusableFavoriting.swift */,
340345
DC2E370C24573ACB00B94699 /* 04-HigherOrderReducers-StrictReducers.swift */,
@@ -358,6 +363,7 @@
358363
CA410EE1247C73B400E41798 /* 02-Effects-WebSocketTests.swift */,
359364
DC634B242448D15B00DAA016 /* 04-HigherOrderReducers-ReusableFavoritingTests.swift */,
360365
CA0C51FA245389CC00A04EAB /* 04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift */,
366+
CA0C0C4624B89BEC00CBDD8A /* 04-HigherOrderReducers-LifecycleTests.swift */,
361367
);
362368
path = SwiftUICaseStudiesTests;
363369
sourceTree = "<group>";
@@ -595,6 +601,7 @@
595601
CA6AC2652451135C00C71CB3 /* CircularProgressView.swift in Sources */,
596602
CA6AC2642451135C00C71CB3 /* ReusableComponents-Download.swift in Sources */,
597603
CA6AC2662451135C00C71CB3 /* DownloadComponent.swift in Sources */,
604+
CA3E4C5B24B4FA0E00447C0B /* 04-HigherOrderReducers-Lifecycle.swift in Sources */,
598605
CAA9ADC22446587C0003A984 /* 02-Effects-Basics.swift in Sources */,
599606
DC89C41B24460F95006900B9 /* 00-RootView.swift in Sources */,
600607
DCC68EDD2447A5B00037F998 /* 01-GettingStarted-OptionalState.swift in Sources */,
@@ -623,6 +630,7 @@
623630
CAA9ADC824465D950003A984 /* 02-Effects-CancellationTests.swift in Sources */,
624631
CA410EE2247C73B400E41798 /* 02-Effects-WebSocketTests.swift in Sources */,
625632
CA34170824A4E89500FAF950 /* 01-GettingStarted-AnimationsTests.swift in Sources */,
633+
CA0C0C4724B89BEC00CBDD8A /* 04-HigherOrderReducers-LifecycleTests.swift in Sources */,
626634
DC07231724465D1E003A8B65 /* 02-Effects-TimersTests.swift in Sources */,
627635
CA50BE6024A8F46500FE7DBA /* 01-GettingStarted-AlertsAndActionSheetsTests.swift in Sources */,
628636
CAA9ADC424465AB00003A984 /* 02-Effects-BasicsTests.swift in Sources */,

Examples/CaseStudies/SwiftUICaseStudies/00-Core.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ struct RootState {
1313
var effectsCancellation = EffectsCancellationState()
1414
var effectsTimers = TimersState()
1515
var episodes = EpisodesState(episodes: .mocks)
16+
var lifecycle = LifecycleDemoState()
1617
var loadThenNavigate = LoadThenNavigateState()
1718
var loadThenNavigateList = LoadThenNavigateListState()
1819
var loadThenPresent = LoadThenPresentState()
@@ -40,6 +41,7 @@ enum RootAction {
4041
case effectsBasics(EffectsBasicsAction)
4142
case effectsCancellation(EffectsCancellationAction)
4243
case episodes(EpisodesAction)
44+
case lifecycle(LifecycleDemoAction)
4345
case loadThenNavigate(LoadThenNavigateAction)
4446
case loadThenNavigateList(LoadThenNavigateListAction)
4547
case loadThenPresent(LoadThenPresentAction)
@@ -149,6 +151,12 @@ let rootReducer = Reducer<RootState, RootAction, RootEnvironment>.combine(
149151
action: /RootAction.episodes,
150152
environment: { .init(favorite: $0.favorite, mainQueue: $0.mainQueue) }
151153
),
154+
lifecycleDemoReducer
155+
.pullback(
156+
state: \.lifecycle,
157+
action: /RootAction.lifecycle,
158+
environment: { .init(mainQueue: $0.mainQueue )}
159+
),
152160
loadThenNavigateReducer
153161
.pullback(
154162
state: \.loadThenNavigate,

Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,16 @@ struct RootView: View {
226226
)
227227
)
228228

229+
NavigationLink(
230+
"Lifecycle",
231+
destination: LifecycleDemoView(
232+
store: self.store.scope(
233+
state: { $0.lifecycle },
234+
action: RootAction.lifecycle
235+
)
236+
)
237+
)
238+
229239
NavigationLink(
230240
"Strict reducers",
231241
destination: DieRollView(
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import ComposableArchitecture
2+
import ReactiveSwift
3+
import SwiftUI
4+
5+
private let readMe = """
6+
This demonstrates how to trigger effects when a view appears, and cancel effects when a view \
7+
disappears. This can be helpful for starting up a feature's long living effects, such as timers, \
8+
location managers, etc. when that feature is first presented.
9+
10+
To accomplish this we define a higher-order reducer that enhances any reducer with two additional \
11+
actions, `.onAppear` and `.onDisappear`, and a way to automate running effects when those actions \
12+
are sent to the store.
13+
"""
14+
15+
extension Reducer {
16+
public func lifecycle(
17+
onAppear: @escaping (Environment) -> Effect<Action, Never>,
18+
onDisappear: @escaping (Environment) -> Effect<Never, Never>
19+
) -> Reducer<State?, LifecycleAction<Action>, Environment> {
20+
21+
return .init { state, lifecycleAction, environment in
22+
switch lifecycleAction {
23+
case .onAppear:
24+
return onAppear(environment).map(LifecycleAction.action)
25+
26+
case .onDisappear:
27+
return onDisappear(environment).fireAndForget()
28+
29+
case let .action(action):
30+
guard state != nil else { return .none }
31+
return self.run(&state!, action, environment)
32+
.map(LifecycleAction.action)
33+
}
34+
}
35+
}
36+
}
37+
38+
public enum LifecycleAction<Action> {
39+
case onAppear
40+
case onDisappear
41+
case action(Action)
42+
}
43+
44+
extension LifecycleAction: Equatable where Action: Equatable {}
45+
46+
struct LifecycleDemoState: Equatable {
47+
var count: Int?
48+
}
49+
50+
enum LifecycleDemoAction: Equatable {
51+
case timer(LifecycleAction<TimerAction>)
52+
case toggleTimerButtonTapped
53+
}
54+
55+
struct LifecycleDemoEnvironment {
56+
var mainQueue: DateScheduler
57+
}
58+
59+
let lifecycleDemoReducer: Reducer<LifecycleDemoState, LifecycleDemoAction, LifecycleDemoEnvironment>
60+
= .combine(
61+
timerReducer.pullback(
62+
state: \.count,
63+
action: /LifecycleDemoAction.timer,
64+
environment: { TimerEnvironment(mainQueue: $0.mainQueue) }
65+
),
66+
Reducer { state, action, environment in
67+
switch action {
68+
case .timer:
69+
return .none
70+
71+
case .toggleTimerButtonTapped:
72+
state.count = state.count == nil ? 0 : nil
73+
return .none
74+
}
75+
}
76+
)
77+
78+
struct LifecycleDemoView: View {
79+
let store: Store<LifecycleDemoState, LifecycleDemoAction>
80+
81+
var body: some View {
82+
WithViewStore(self.store) { viewStore in
83+
VStack {
84+
Button("Toggle Timer") { viewStore.send(.toggleTimerButtonTapped) }
85+
86+
IfLetStore(
87+
self.store.scope(state: { $0.count }, action: LifecycleDemoAction.timer),
88+
then: TimerView.init(store:)
89+
)
90+
91+
Spacer()
92+
}
93+
.navigationBarTitle("Lifecycle")
94+
}
95+
}
96+
}
97+
98+
private struct TimerId: Hashable {}
99+
100+
enum TimerAction {
101+
case decrementButtonTapped
102+
case incrementButtonTapped
103+
case tick
104+
}
105+
106+
struct TimerEnvironment {
107+
var mainQueue: DateScheduler
108+
}
109+
110+
private let timerReducer = Reducer<Int, TimerAction, TimerEnvironment> { state, action, TimerEnvironment in
111+
switch action {
112+
case .decrementButtonTapped:
113+
state -= 1
114+
return .none
115+
116+
case .incrementButtonTapped:
117+
state += 1
118+
return .none
119+
120+
case .tick:
121+
state += 1
122+
return .none
123+
}
124+
}
125+
.lifecycle(
126+
onAppear: {
127+
Effect.timer(id: TimerId(), every: .seconds(1), tolerance: .seconds(0), on: $0.mainQueue)
128+
.map { _ in TimerAction.tick }
129+
},
130+
onDisappear: { _ in
131+
.cancel(id: TimerId())
132+
})
133+
134+
private struct TimerView: View {
135+
let store: Store<Int, LifecycleAction<TimerAction>>
136+
137+
var body: some View {
138+
WithViewStore(self.store) { viewStore in
139+
Section {
140+
Text("Count: \(viewStore.state)")
141+
.onAppear { viewStore.send(.onAppear) }
142+
.onDisappear { viewStore.send(.onDisappear) }
143+
144+
Button("Decrement") { viewStore.send(.action(.decrementButtonTapped))}
145+
146+
Button("Increment") { viewStore.send(.action(.incrementButtonTapped)) }
147+
}
148+
}
149+
}
150+
}
151+
152+
struct Lifecycle_Previews: PreviewProvider {
153+
static var previews: some View {
154+
Group {
155+
NavigationView {
156+
LifecycleDemoView(
157+
store: .init(
158+
initialState: .init(),
159+
reducer: lifecycleDemoReducer,
160+
environment: .init(
161+
mainQueue: QueueScheduler.main
162+
)
163+
)
164+
)
165+
}
166+
}
167+
}
168+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import Combine
2+
import ComposableArchitecture
3+
import ReactiveSwift
4+
import XCTest
5+
6+
@testable import SwiftUICaseStudies
7+
8+
class LifecycleTests: XCTestCase {
9+
func testLifecycle() {
10+
let scheduler = TestScheduler()
11+
12+
let store = TestStore(
13+
initialState: .init(),
14+
reducer: lifecycleDemoReducer,
15+
environment: .init(
16+
mainQueue: scheduler
17+
)
18+
)
19+
20+
store.assert(
21+
.send(.toggleTimerButtonTapped) {
22+
$0.count = 0
23+
},
24+
25+
.send(.timer(.onAppear)),
26+
27+
.do { scheduler.advance(by: .seconds(1)) },
28+
.receive(.timer(.action(.tick))) {
29+
$0.count = 1
30+
},
31+
32+
.do { scheduler.advance(by: .seconds(1)) },
33+
.receive(.timer(.action(.tick))) {
34+
$0.count = 2
35+
},
36+
37+
.send(.timer(.action(.incrementButtonTapped))) {
38+
$0.count = 3
39+
},
40+
41+
.send(.timer(.action(.decrementButtonTapped))) {
42+
$0.count = 2
43+
},
44+
45+
.send(.toggleTimerButtonTapped) {
46+
$0.count = nil
47+
},
48+
49+
.send(.timer(.onDisappear))
50+
)
51+
}
52+
}

0 commit comments

Comments
 (0)