Skip to content

Commit d4c0ff3

Browse files
Add an async/await aware .send method to View Store. (#673)
* wip * case study * docs * clean up * more compiler directives. * xcode 12 fixes Co-authored-by: Stephen Celis <[email protected]>
1 parent 60fbb66 commit d4c0ff3

File tree

6 files changed

+361
-1
lines changed

6 files changed

+361
-1
lines changed

Examples/CaseStudies/CaseStudies.xcodeproj/project.pbxproj

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
CAA9ADC824465D950003A984 /* 02-Effects-CancellationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADC724465D950003A984 /* 02-Effects-CancellationTests.swift */; };
3030
CAA9ADCA2446605B0003A984 /* 02-Effects-LongLiving.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADC92446605B0003A984 /* 02-Effects-LongLiving.swift */; };
3131
CAA9ADCC2446615B0003A984 /* 02-Effects-LongLivingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA9ADCB2446615B0003A984 /* 02-Effects-LongLivingTests.swift */; };
32+
CABC4F3926AEE00C00D5FA2C /* 02-Effects-Refreshable.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABC4F3826AEE00C00D5FA2C /* 02-Effects-Refreshable.swift */; };
33+
CABC4F3B26AEE20200D5FA2C /* 02-Effects-RefreshableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABC4F3A26AEE20200D5FA2C /* 02-Effects-RefreshableTests.swift */; };
3234
CAE962FD24A7F7BE00EFC025 /* 01-GettingStarted-AlertsAndActionSheets.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAE962FC24A7F7BE00EFC025 /* 01-GettingStarted-AlertsAndActionSheets.swift */; };
3335
CAF069D024ACC5AF00A1AAEF /* 00-Core.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF069CF24ACC5AF00A1AAEF /* 00-Core.swift */; };
3436
CAF88E7324B8E26D00539345 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF88E7224B8E26D00539345 /* AppDelegate.swift */; };
@@ -170,6 +172,8 @@
170172
CAA9ADC724465D950003A984 /* 02-Effects-CancellationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-CancellationTests.swift"; sourceTree = "<group>"; };
171173
CAA9ADC92446605B0003A984 /* 02-Effects-LongLiving.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-LongLiving.swift"; sourceTree = "<group>"; };
172174
CAA9ADCB2446615B0003A984 /* 02-Effects-LongLivingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-LongLivingTests.swift"; sourceTree = "<group>"; };
175+
CABC4F3826AEE00C00D5FA2C /* 02-Effects-Refreshable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-Refreshable.swift"; sourceTree = "<group>"; };
176+
CABC4F3A26AEE20200D5FA2C /* 02-Effects-RefreshableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "02-Effects-RefreshableTests.swift"; sourceTree = "<group>"; };
173177
CAE962FC24A7F7BE00EFC025 /* 01-GettingStarted-AlertsAndActionSheets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "01-GettingStarted-AlertsAndActionSheets.swift"; sourceTree = "<group>"; };
174178
CAF069CF24ACC5AF00A1AAEF /* 00-Core.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "00-Core.swift"; sourceTree = "<group>"; };
175179
CAF88E7024B8E26D00539345 /* tvOSCaseStudies.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = tvOSCaseStudies.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -401,6 +405,7 @@
401405
CAA9ADC12446587C0003A984 /* 02-Effects-Basics.swift */,
402406
CAA9ADC524465C810003A984 /* 02-Effects-Cancellation.swift */,
403407
CAA9ADC92446605B0003A984 /* 02-Effects-LongLiving.swift */,
408+
CABC4F3826AEE00C00D5FA2C /* 02-Effects-Refreshable.swift */,
404409
DC89C45424465C44006900B9 /* 02-Effects-Timers.swift */,
405410
CA410EDF247A15FE00E41798 /* 02-Effects-WebSocket.swift */,
406411
CA27C0B6245780CE00CB1E59 /* 03-Effects-SystemEnvironment.swift */,
@@ -434,11 +439,12 @@
434439
CAA9ADC324465AB00003A984 /* 02-Effects-BasicsTests.swift */,
435440
CAA9ADC724465D950003A984 /* 02-Effects-CancellationTests.swift */,
436441
CAA9ADCB2446615B0003A984 /* 02-Effects-LongLivingTests.swift */,
442+
CABC4F3A26AEE20200D5FA2C /* 02-Effects-RefreshableTests.swift */,
437443
DC07231624465D1E003A8B65 /* 02-Effects-TimersTests.swift */,
438444
CA410EE1247C73B400E41798 /* 02-Effects-WebSocketTests.swift */,
445+
CA0C0C4624B89BEC00CBDD8A /* 04-HigherOrderReducers-LifecycleTests.swift */,
439446
DC634B242448D15B00DAA016 /* 04-HigherOrderReducers-ReusableFavoritingTests.swift */,
440447
CA0C51FA245389CC00A04EAB /* 04-HigherOrderReducers-ReusableOfflineDownloadsTests.swift */,
441-
CA0C0C4624B89BEC00CBDD8A /* 04-HigherOrderReducers-LifecycleTests.swift */,
442448
);
443449
path = SwiftUICaseStudiesTests;
444450
sourceTree = "<group>";
@@ -763,6 +769,7 @@
763769
CAA9ADC22446587C0003A984 /* 02-Effects-Basics.swift in Sources */,
764770
DC89C41B24460F95006900B9 /* 00-RootView.swift in Sources */,
765771
DCC68EDD2447A5B00037F998 /* 01-GettingStarted-OptionalState.swift in Sources */,
772+
CABC4F3926AEE00C00D5FA2C /* 02-Effects-Refreshable.swift in Sources */,
766773
DCC68EAB244666AF0037F998 /* 03-Navigation-Sheet-PresentAndLoad.swift in Sources */,
767774
CAE962FD24A7F7BE00EFC025 /* 01-GettingStarted-AlertsAndActionSheets.swift in Sources */,
768775
CA25E5D224463AD700DA666A /* 01-GettingStarted-Bindings-Basics.swift in Sources */,
@@ -788,6 +795,7 @@
788795
DC634B252448D15B00DAA016 /* 04-HigherOrderReducers-ReusableFavoritingTests.swift in Sources */,
789796
CAA9ADC824465D950003A984 /* 02-Effects-CancellationTests.swift in Sources */,
790797
CA410EE2247C73B400E41798 /* 02-Effects-WebSocketTests.swift in Sources */,
798+
CABC4F3B26AEE20200D5FA2C /* 02-Effects-RefreshableTests.swift in Sources */,
791799
CA34170824A4E89500FAF950 /* 01-GettingStarted-AnimationsTests.swift in Sources */,
792800
CA0C0C4724B89BEC00CBDD8A /* 04-HigherOrderReducers-LifecycleTests.swift in Sources */,
793801
DC07231724465D1E003A8B65 /* 02-Effects-TimersTests.swift in Sources */,

Examples/CaseStudies/SwiftUICaseStudies/00-Core.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ struct RootState {
2727
var nested = NestedState.mock
2828
var optionalBasics = OptionalBasicsState()
2929
var presentAndLoad = PresentAndLoadState()
30+
var refreshable = RefreshableState()
3031
var shared = SharedState()
3132
var timers = TimersState()
3233
var twoCounters = TwoCountersState()
@@ -57,6 +58,7 @@ enum RootAction {
5758
case optionalBasics(OptionalBasicsAction)
5859
case onAppear
5960
case presentAndLoad(PresentAndLoadAction)
61+
case refreshable(RefreshableAction)
6062
case shared(SharedStateAction)
6163
case timers(TimersAction)
6264
case twoCounters(TwoCountersAction)
@@ -237,6 +239,14 @@ let rootReducer = Reducer<RootState, RootAction, RootEnvironment>.combine(
237239
action: /RootAction.presentAndLoad,
238240
environment: { .init(mainQueue: $0.mainQueue) }
239241
),
242+
refreshableReducer
243+
.pullback(
244+
state: \.refreshable,
245+
action: /RootAction.refreshable,
246+
environment: {
247+
.init(fact: $0.fact, mainQueue: $0.mainQueue)
248+
}
249+
),
240250
sharedStateReducer
241251
.pullback(
242252
state: \.shared,

Examples/CaseStudies/SwiftUICaseStudies/00-RootView.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,18 @@ struct RootView: View {
121121
)
122122
)
123123

124+
#if compiler(>=5.5)
125+
NavigationLink(
126+
"Refreshable",
127+
destination: RefreshableView(
128+
store: self.store.scope(
129+
state: \.refreshable,
130+
action: RootAction.refreshable
131+
)
132+
)
133+
)
134+
#endif
135+
124136
NavigationLink(
125137
"Timers",
126138
destination: TimersView(
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import ComposableArchitecture
2+
import SwiftUI
3+
4+
private var readMe = """
5+
This application demonstrates how to make use of SwiftUI's `refreshable` API in the Composable \
6+
Architecture. Use the "-" and "+" buttons to count up and down, and then pull down to request \
7+
a fact about that number.
8+
9+
There is an overload of the `.send` method that allows you to suspend and await while a piece \
10+
of state is true. You can use this method to communicate to SwiftUI that you are \
11+
currently fetching data so that it knows to continue showing the loading indicator.
12+
"""
13+
14+
struct RefreshableState: Equatable {
15+
var count = 0
16+
var fact: String?
17+
var isLoading = false
18+
}
19+
20+
enum RefreshableAction: Equatable {
21+
case cancelButtonTapped
22+
case decrementButtonTapped
23+
case factResponse(Result<String, FactClient.Error>)
24+
case incrementButtonTapped
25+
case refresh
26+
}
27+
28+
struct RefreshableEnvironment {
29+
var fact: FactClient
30+
var mainQueue: AnySchedulerOf<DispatchQueue>
31+
}
32+
33+
let refreshableReducer = Reducer<
34+
RefreshableState,
35+
RefreshableAction,
36+
RefreshableEnvironment
37+
> { state, action, environment in
38+
39+
struct CancelId: Hashable {}
40+
41+
switch action {
42+
case .cancelButtonTapped:
43+
state.isLoading = false
44+
return .cancel(id: CancelId())
45+
46+
case .decrementButtonTapped:
47+
state.count -= 1
48+
return .none
49+
50+
case let .factResponse(.success(fact)):
51+
state.isLoading = false
52+
state.fact = fact
53+
return .none
54+
55+
case .factResponse(.failure):
56+
state.isLoading = false
57+
// TODO: do some error handling
58+
return .none
59+
60+
case .incrementButtonTapped:
61+
state.count += 1
62+
return .none
63+
64+
case .refresh:
65+
state.fact = nil
66+
state.isLoading = true
67+
return environment.fact.fetch(state.count)
68+
.delay(for: .seconds(2), scheduler: environment.mainQueue.animation())
69+
.catchToEffect()
70+
.map(RefreshableAction.factResponse)
71+
.cancellable(id: CancelId())
72+
}
73+
}
74+
75+
#if compiler(>=5.5)
76+
struct RefreshableView: View {
77+
let store: Store<RefreshableState, RefreshableAction>
78+
79+
var body: some View {
80+
WithViewStore(self.store) { viewStore in
81+
List {
82+
Text(template: readMe, .body)
83+
84+
HStack {
85+
Button("-") { viewStore.send(.decrementButtonTapped) }
86+
Text("\(viewStore.count)")
87+
Button("+") { viewStore.send(.incrementButtonTapped) }
88+
}
89+
.buttonStyle(.plain)
90+
91+
if let fact = viewStore.fact {
92+
Text(fact)
93+
.bold()
94+
}
95+
if viewStore.isLoading {
96+
Button("Cancel") {
97+
viewStore.send(.cancelButtonTapped, animation: .default)
98+
}
99+
}
100+
}
101+
.refreshable {
102+
await viewStore.send(.refresh, while: \.isLoading)
103+
}
104+
}
105+
}
106+
}
107+
108+
struct Refreshable_Previews: PreviewProvider {
109+
static var previews: some View {
110+
RefreshableView(
111+
store: .init(
112+
initialState: .init(),
113+
reducer: refreshableReducer,
114+
environment: .init(
115+
fact: .live,
116+
mainQueue: .main
117+
)
118+
)
119+
)
120+
}
121+
}
122+
#endif
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import ComposableArchitecture
2+
import XCTest
3+
4+
@testable import SwiftUICaseStudies
5+
6+
class RefreshableTests: XCTestCase {
7+
func testHappyPath() {
8+
let store = TestStore(
9+
initialState: .init(),
10+
reducer: refreshableReducer,
11+
environment: .init(
12+
fact: .init { .init(value: "\($0) is a good number.") },
13+
mainQueue: .immediate
14+
)
15+
)
16+
17+
store.send(.incrementButtonTapped) {
18+
$0.count = 1
19+
}
20+
store.send(.refresh) {
21+
$0.isLoading = true
22+
}
23+
store.receive(.factResponse(.success("1 is a good number."))) {
24+
$0.isLoading = false
25+
$0.fact = "1 is a good number."
26+
}
27+
}
28+
29+
func testUnhappyPath() {
30+
let store = TestStore(
31+
initialState: .init(),
32+
reducer: refreshableReducer,
33+
environment: .init(
34+
fact: .init { _ in .init(error: .init()) },
35+
mainQueue: .immediate
36+
)
37+
)
38+
39+
store.send(.incrementButtonTapped) {
40+
$0.count = 1
41+
}
42+
store.send(.refresh) {
43+
$0.isLoading = true
44+
}
45+
store.receive(.factResponse(.failure(.init()))) {
46+
$0.isLoading = false
47+
}
48+
}
49+
50+
func testCancellation() {
51+
let mainQueue = DispatchQueue.test
52+
53+
let store = TestStore(
54+
initialState: .init(),
55+
reducer: refreshableReducer,
56+
environment: .init(
57+
fact: .init { .init(value: "\($0) is a good number.") },
58+
mainQueue: mainQueue.eraseToAnyScheduler()
59+
)
60+
)
61+
62+
store.send(.incrementButtonTapped) {
63+
$0.count = 1
64+
}
65+
store.send(.refresh) {
66+
$0.isLoading = true
67+
}
68+
store.send(.cancelButtonTapped) {
69+
$0.isLoading = false
70+
}
71+
}
72+
}

0 commit comments

Comments
 (0)