Skip to content

Commit 85050a2

Browse files
authored
Fix WithViewStore issues with Views using escaping closures (#1015)
* Use a new instance of `ViewStore` in `WithViewStore`'s `body` * Use explicit `self` for style coherence * Remove `viewCancellable` additional reference in `newInstance()` * Revert "Remove `viewCancellable` additional reference in `newInstance()`" This reverts commit cb6a22a. * Change `viewCancellable` capture list * Update `GeometryReader`'s workarounds
1 parent 2a4b853 commit 85050a2

File tree

7 files changed

+153
-184
lines changed

7 files changed

+153
-184
lines changed

Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Animations.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,8 @@ struct AnimationsView: View {
108108
let store: Store<AnimationsState, AnimationsAction>
109109

110110
var body: some View {
111-
GeometryReader { proxy in
112-
WithViewStore(self.store) { viewStore in
111+
WithViewStore(self.store) { viewStore in
112+
GeometryReader { proxy in
113113
VStack(alignment: .leading) {
114114
ZStack(alignment: .center) {
115115
Text(template: readMe, .body)

Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Timers.swift

Lines changed: 50 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -52,72 +52,65 @@ let timersReducer = Reducer<TimersState, TimersAction, TimersEnvironment> {
5252
// MARK: - Timer feature view
5353

5454
struct TimersView: View {
55-
// NB: We are using an explicit `ObservedObject` for the view store here instead of
56-
// `WithViewStore` due to a SwiftUI bug where `GeometryReader`s inside `WithViewStore` will
57-
// not properly update.
58-
//
59-
// Feedback filed: https://gist.github.com/mbrandonw/cc5da3d487bcf7c4f21c27019a440d18
60-
@ObservedObject var viewStore: ViewStore<TimersState, TimersAction>
61-
62-
init(store: Store<TimersState, TimersAction>) {
63-
self.viewStore = ViewStore(store)
64-
}
55+
let store: Store<TimersState, TimersAction>
6556

6657
var body: some View {
67-
VStack {
68-
Text(template: readMe, .body)
69-
70-
ZStack {
71-
Circle()
72-
.fill(
73-
AngularGradient(
74-
gradient: Gradient(
75-
colors: [
76-
.blue.opacity(0.3),
77-
.blue,
78-
.blue,
79-
.green,
80-
.green,
81-
.yellow,
82-
.yellow,
83-
.red,
84-
.red,
85-
.purple,
86-
.purple,
87-
.purple.opacity(0.3),
88-
]
89-
),
90-
center: .center
58+
WithViewStore(store) { viewStore in
59+
VStack {
60+
Text(template: readMe, .body)
61+
62+
ZStack {
63+
Circle()
64+
.fill(
65+
AngularGradient(
66+
gradient: Gradient(
67+
colors: [
68+
.blue.opacity(0.3),
69+
.blue,
70+
.blue,
71+
.green,
72+
.green,
73+
.yellow,
74+
.yellow,
75+
.red,
76+
.red,
77+
.purple,
78+
.purple,
79+
.purple.opacity(0.3),
80+
]
81+
),
82+
center: .center
83+
)
9184
)
92-
)
93-
.rotationEffect(.degrees(-90))
94-
95-
GeometryReader { proxy in
96-
Path { path in
97-
path.move(to: CGPoint(x: proxy.size.width / 2, y: proxy.size.height / 2))
98-
path.addLine(to: CGPoint(x: proxy.size.width / 2, y: 0))
85+
.rotationEffect(.degrees(-90))
86+
87+
GeometryReader { proxy in
88+
Path { path in
89+
path.move(to: CGPoint(x: proxy.size.width / 2, y: proxy.size.height / 2))
90+
path.addLine(to: CGPoint(x: proxy.size.width / 2, y: 0))
91+
}
92+
.stroke(Color.black, lineWidth: 3)
93+
.rotationEffect(.degrees(Double(viewStore.secondsElapsed) * 360 / 60))
9994
}
100-
.stroke(Color.black, lineWidth: 3)
101-
.rotationEffect(.degrees(Double(self.viewStore.secondsElapsed) * 360 / 60))
10295
}
103-
}
104-
.frame(width: 280, height: 280)
105-
.padding(.bottom, 16)
96+
.frame(width: 280, height: 280)
97+
.padding(.bottom, 16)
10698

107-
Button(action: { self.viewStore.send(.toggleTimerButtonTapped) }) {
108-
HStack {
109-
Text(self.viewStore.isTimerActive ? "Stop" : "Start")
99+
Button(action: { viewStore.send(.toggleTimerButtonTapped) }) {
100+
HStack {
101+
Text(viewStore.isTimerActive ? "Stop" : "Start")
102+
}
103+
.foregroundColor(.white)
104+
.padding()
105+
.background(viewStore.isTimerActive ? Color.red : .blue)
106+
.cornerRadius(16)
110107
}
111-
.foregroundColor(.white)
112-
.padding()
113-
.background(self.viewStore.isTimerActive ? Color.red : .blue)
114-
.cornerRadius(16)
115-
}
116108

117-
Spacer()
109+
Spacer()
110+
}
111+
.padding()
112+
.navigationBarTitle("Timers")
118113
}
119-
.padding()
120-
.navigationBarTitle("Timers")
121114
}
122115
}
123116

Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ElmLikeSubscriptions.swift

Lines changed: 51 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -77,72 +77,65 @@ let clockReducer = Reducer<ClockState, ClockAction, ClockEnvironment>.combine(
7777
)
7878

7979
struct ClockView: View {
80-
// NB: We are using an explicit `ObservedObject` for the view store here instead of
81-
// `WithViewStore` due to a SwiftUI bug where `GeometryReader`s inside `WithViewStore` will
82-
// not properly update.
83-
//
84-
// Feedback filed: https://gist.github.com/mbrandonw/cc5da3d487bcf7c4f21c27019a440d18
85-
@ObservedObject var viewStore: ViewStore<ClockState, ClockAction>
86-
87-
init(store: Store<ClockState, ClockAction>) {
88-
self.viewStore = ViewStore(store)
89-
}
90-
80+
let store: Store<ClockState, ClockAction>
81+
9182
var body: some View {
92-
VStack {
93-
Text(template: readMe, .body)
94-
95-
ZStack {
96-
Circle()
97-
.fill(
98-
AngularGradient(
99-
gradient: Gradient(
100-
colors: [
101-
.blue.opacity(0.3),
102-
.blue,
103-
.blue,
104-
.green,
105-
.green,
106-
.yellow,
107-
.yellow,
108-
.red,
109-
.red,
110-
.purple,
111-
.purple,
112-
.purple.opacity(0.3),
113-
]
114-
),
115-
center: .center
83+
WithViewStore(store) { viewStore in
84+
VStack {
85+
Text(template: readMe, .body)
86+
87+
ZStack {
88+
Circle()
89+
.fill(
90+
AngularGradient(
91+
gradient: Gradient(
92+
colors: [
93+
.blue.opacity(0.3),
94+
.blue,
95+
.blue,
96+
.green,
97+
.green,
98+
.yellow,
99+
.yellow,
100+
.red,
101+
.red,
102+
.purple,
103+
.purple,
104+
.purple.opacity(0.3),
105+
]
106+
),
107+
center: .center
108+
)
116109
)
117-
)
118-
.rotationEffect(.degrees(-90))
119-
120-
GeometryReader { proxy in
121-
Path { path in
122-
path.move(to: CGPoint(x: proxy.size.width / 2, y: proxy.size.height / 2))
123-
path.addLine(to: CGPoint(x: proxy.size.width / 2, y: 0))
110+
.rotationEffect(.degrees(-90))
111+
112+
GeometryReader { proxy in
113+
Path { path in
114+
path.move(to: CGPoint(x: proxy.size.width / 2, y: proxy.size.height / 2))
115+
path.addLine(to: CGPoint(x: proxy.size.width / 2, y: 0))
116+
}
117+
.stroke(Color.black, lineWidth: 3)
118+
.rotationEffect(.degrees(Double(viewStore.secondsElapsed) * 360 / 60))
124119
}
125-
.stroke(Color.black, lineWidth: 3)
126-
.rotationEffect(.degrees(Double(self.viewStore.secondsElapsed) * 360 / 60))
127120
}
128-
}
129-
.frame(width: 280, height: 280)
130-
.padding(.bottom, 64)
121+
.frame(width: 280, height: 280)
122+
.padding(.bottom, 64)
131123

132-
Button(action: { self.viewStore.send(.toggleTimerButtonTapped) }) {
133-
HStack {
134-
Text(self.viewStore.isTimerActive ? "Stop" : "Start")
124+
Button(action: { viewStore.send(.toggleTimerButtonTapped) }) {
125+
HStack {
126+
Text(viewStore.isTimerActive ? "Stop" : "Start")
127+
}
128+
.foregroundColor(.white)
129+
.padding()
130+
.background(viewStore.isTimerActive ? Color.red : .blue)
131+
.cornerRadius(16)
135132
}
136-
.foregroundColor(.white)
137-
.padding()
138-
.background(self.viewStore.isTimerActive ? Color.red : .blue)
139-
.cornerRadius(16)
140-
}
141133

142-
Spacer()
134+
Spacer()
135+
}
136+
.padding()
137+
.navigationBarTitle("Elm-like subscriptions")
143138
}
144-
.padding()
145-
.navigationBarTitle("Elm-like subscriptions")
146139
}
147140
}
148141

Examples/TicTacToe/tic-tac-toe/Sources/GameSwiftUI/GameView.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ public struct GameView: View {
2929
}
3030

3131
public var body: some View {
32-
GeometryReader { proxy in
33-
WithViewStore(self.store.scope(state: ViewState.init)) { viewStore in
32+
WithViewStore(self.store.scope(state: ViewState.init)) { viewStore in
33+
GeometryReader { proxy in
3434
VStack(spacing: 0.0) {
3535
VStack {
3636
Text(viewStore.title)

Examples/VoiceMemos/VoiceMemos/VoiceMemo.swift

Lines changed: 36 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -96,55 +96,45 @@ let voiceMemoReducer = Reducer<
9696
}
9797

9898
struct VoiceMemoView: View {
99-
// NB: We are using an explicit `ObservedObject` for the view store here instead of
100-
// `WithViewStore` due to a SwiftUI bug where `GeometryReader`s inside `WithViewStore` will
101-
// not properly update.
102-
//
103-
// Feedback filed: https://gist.github.com/mbrandonw/cc5da3d487bcf7c4f21c27019a440d18
104-
@ObservedObject var viewStore: ViewStore<VoiceMemo, VoiceMemoAction>
105-
106-
init(store: Store<VoiceMemo, VoiceMemoAction>) {
107-
self.viewStore = ViewStore(store)
108-
}
99+
let store: Store<VoiceMemo, VoiceMemoAction>
109100

110101
var body: some View {
111-
HStack {
112-
TextField(
113-
"Untitled, \(self.viewStore.date.formatted(date: .numeric, time: .shortened))",
114-
text: self.viewStore.binding(
115-
get: \.title, send: VoiceMemoAction.titleTextFieldChanged)
116-
)
117-
118-
Spacer()
119-
120-
dateComponentsFormatter.string(from: self.currentTime).map {
121-
Text($0)
122-
.font(.footnote.monospacedDigit())
123-
.foregroundColor(Color(.systemGray))
124-
}
125-
126-
Button(action: { self.viewStore.send(.playButtonTapped) }) {
127-
Image(systemName: self.viewStore.mode.isPlaying ? "stop.circle" : "play.circle")
128-
.font(.system(size: 22))
102+
WithViewStore(store) { viewStore in
103+
let currentTime = viewStore.mode.progress.map { $0 * viewStore.duration } ?? viewStore.duration
104+
HStack {
105+
TextField(
106+
"Untitled, \(viewStore.date.formatted(date: .numeric, time: .shortened))",
107+
text: viewStore.binding(
108+
get: \.title, send: VoiceMemoAction.titleTextFieldChanged)
109+
)
110+
111+
Spacer()
112+
113+
dateComponentsFormatter.string(from: currentTime).map {
114+
Text($0)
115+
.font(.footnote.monospacedDigit())
116+
.foregroundColor(Color(.systemGray))
117+
}
118+
119+
Button(action: { viewStore.send(.playButtonTapped) }) {
120+
Image(systemName: viewStore.mode.isPlaying ? "stop.circle" : "play.circle")
121+
.font(.system(size: 22))
122+
}
129123
}
124+
.buttonStyle(.borderless)
125+
.frame(maxHeight: .infinity, alignment: .center)
126+
.padding(.horizontal)
127+
.listRowBackground(viewStore.mode.isPlaying ? Color(.systemGray6) : .clear)
128+
.listRowInsets(EdgeInsets())
129+
.background(
130+
Color(.systemGray5)
131+
.frame(maxWidth: viewStore.mode.isPlaying ? .infinity : 0)
132+
.animation(
133+
viewStore.mode.isPlaying ? .linear(duration: viewStore.duration) : nil,
134+
value: viewStore.mode.isPlaying
135+
),
136+
alignment: .leading
137+
)
130138
}
131-
.buttonStyle(.borderless)
132-
.frame(maxHeight: .infinity, alignment: .center)
133-
.padding(.horizontal)
134-
.listRowBackground(self.viewStore.mode.isPlaying ? Color(.systemGray6) : .clear)
135-
.listRowInsets(EdgeInsets())
136-
.background(
137-
Color(.systemGray5)
138-
.frame(maxWidth: self.viewStore.mode.isPlaying ? .infinity : 0)
139-
.animation(
140-
self.viewStore.mode.isPlaying ? .linear(duration: self.viewStore.duration) : nil,
141-
value: self.viewStore.mode.isPlaying
142-
),
143-
alignment: .leading
144-
)
145-
}
146-
147-
var currentTime: TimeInterval {
148-
self.viewStore.mode.progress.map { $0 * self.viewStore.duration } ?? self.viewStore.duration
149139
}
150140
}

Sources/ComposableArchitecture/SwiftUI/WithViewStore.swift

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,6 @@ import SwiftUI
44

55
/// A structure that transforms a store into an observable view store in order to compute views from
66
/// store state.
7-
///
8-
/// Due to a bug in SwiftUI, there are times that use of this view can interfere with some core
9-
/// views provided by SwiftUI. The known problematic views are:
10-
///
11-
/// * If a `GeometryReader` or `ScrollViewReader` is used inside a ``WithViewStore`` it will not
12-
/// receive state updates correctly. To work around you either need to reorder the views so that
13-
/// the `GeometryReader` or `ScrollViewReader` wraps ``WithViewStore``, or, if that is not
14-
/// possible, then you must hold onto an explicit
15-
/// `@ObservedObject var viewStore: ViewStore<State, Action>` in your view in lieu of using this
16-
/// helper (see [here](https://gist.github.com/mbrandonw/cc5da3d487bcf7c4f21c27019a440d18)).
17-
/// * If you create a `Stepper` via the `Stepper.init(onIncrement:onDecrement:label:)` initializer
18-
/// inside a ``WithViewStore`` it will behave erratically. To work around you should use the
19-
/// initializer that takes a binding (see
20-
/// [here](https://gist.github.com/mbrandonw/dee2ceac2c316a1619cfdf1dc7945f66)).
217
public struct WithViewStore<State, Action, Content> {
228
private let content: (ViewStore<State, Action>) -> Content
239
#if DEBUG
@@ -88,7 +74,7 @@ public struct WithViewStore<State, Action, Content> {
8874
)
8975
}
9076
#endif
91-
return self.content(self.viewStore)
77+
return self.content(ViewStore(self.viewStore))
9278
}
9379
}
9480

0 commit comments

Comments
 (0)