Skip to content

Commit b3d2d78

Browse files
committed
WithViewStore debug state diffs (#706)
* WithViewStore debug state diffs * More debug checks * fix * wip
1 parent 8fec7de commit b3d2d78

File tree

2 files changed

+203
-150
lines changed

2 files changed

+203
-150
lines changed

Sources/ComposableArchitecture/SwiftUI/WithViewStore.swift

Lines changed: 173 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
11
#if canImport(Combine) && canImport(SwiftUI)
2-
import Combine
3-
import SwiftUI
4-
5-
/// A structure that transforms a store into an observable view store in order to compute views from
6-
/// 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)).
2+
import Combine
3+
import SwiftUI
4+
5+
/// A structure that transforms a store into an observable view store in order to compute views from
6+
/// 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)).
2121
///
2222
/// Due to a bug in SwiftUI, there are times that use of this view can interfere with some core
2323
/// views provided by SwiftUI. The known problematic views are:
@@ -33,108 +33,162 @@
3333
/// initializer that takes a binding (see
3434
/// [here](https://gist.github.com/mbrandonw/dee2ceac2c316a1619cfdf1dc7945f66)).
3535
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
36-
public struct WithViewStore<State, Action, Content> {
37-
private let content: (ViewStore<State, Action>) -> Content
38-
private var prefix: String?
39-
@ObservedObject private var viewStore: ViewStore<State, Action>
40-
41-
/// Prints debug information to the console whenever the view is computed.
42-
///
43-
/// - Parameter prefix: A string with which to prefix all debug messages.
44-
/// - Returns: A structure that prints debug messages for all computations.
45-
public func debug(_ prefix: String = "") -> Self {
46-
var view = self
47-
view.prefix = prefix
48-
return view
49-
}
36+
public struct WithViewStore<State, Action, Content> {
37+
private let content: (ViewStore<State, Action>) -> Content
38+
#if DEBUG
39+
private let file: StaticString
40+
private let line: UInt
41+
private var prefix: String?
42+
private var previousState: (State) -> State?
43+
#endif
44+
@ObservedObject private var viewStore: ViewStore<State, Action>
45+
46+
fileprivate init(
47+
store: Store<State, Action>,
48+
removeDuplicates isDuplicate: @escaping (State, State) -> Bool,
49+
file: StaticString = #fileID,
50+
line: UInt = #line,
51+
content: @escaping (ViewStore<State, Action>) -> Content
52+
) {
53+
self.content = content
54+
#if DEBUG
55+
self.file = file
56+
self.line = line
57+
var previousState: State? = nil
58+
self.previousState = { currentState in
59+
defer { previousState = currentState }
60+
return previousState
61+
}
62+
#endif
63+
self.viewStore = ViewStore(store, removeDuplicates: isDuplicate)
5064
}
5165

52-
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
53-
extension WithViewStore: View where Content: View {
54-
/// Initializes a structure that transforms a store into an observable view store in order to
55-
/// compute views from store state.
56-
57-
/// - Parameters:
58-
/// - store: A store.
59-
/// - isDuplicate: A function to determine when two `State` values are equal. When values are
60-
/// equal, repeat view computations are removed,
61-
/// - content: A function that can generate content from a view store.
62-
public init(
63-
_ store: Store<State, Action>,
64-
removeDuplicates isDuplicate: @escaping (State, State) -> Bool,
65-
@ViewBuilder content: @escaping (ViewStore<State, Action>) -> Content
66-
) {
67-
self.content = content
68-
self.viewStore = ViewStore(store, removeDuplicates: isDuplicate)
69-
}
66+
/// Prints debug information to the console whenever the view is computed.
67+
///
68+
/// - Parameter prefix: A string with which to prefix all debug messages.
69+
/// - Returns: A structure that prints debug messages for all computations.
70+
public func debug(_ prefix: String = "") -> Self {
71+
var view = self
72+
#if DEBUG
73+
view.prefix = prefix
74+
#endif
75+
return view
76+
}
7077

71-
public var body: Content {
72-
#if DEBUG
73-
if let prefix = self.prefix {
74-
print(
75-
"""
76-
\(prefix.isEmpty ? "" : "\(prefix): ")\
77-
Evaluating WithViewStore<\(State.self), \(Action.self), ...>.body
78-
"""
79-
)
78+
fileprivate var _body: Content {
79+
#if DEBUG
80+
if let prefix = self.prefix {
81+
let difference = self.previousState(self.viewStore.state)
82+
.map {
83+
debugDiff($0, self.viewStore.state).map { "(Changed state)\n\($0)" }
84+
?? "(No difference in state detected)"
85+
}
86+
?? "(Initial state)\n\(debugOutput(self.viewStore.state, indent: 2))"
87+
func typeName(_ type: Any.Type) -> String {
88+
var name = String(reflecting: type)
89+
if let index = name.firstIndex(of: ".") {
90+
name.removeSubrange(...index)
91+
}
92+
return name
8093
}
81-
#endif
82-
return self.content(self.viewStore)
83-
}
94+
print(
95+
"""
96+
\(prefix.isEmpty ? "" : "\(prefix): ")\
97+
WithViewStore<\(typeName(State.self)), \(typeName(Action.self)), _>\
98+
@\(self.file):\(self.line) \(difference)
99+
"""
100+
)
101+
}
102+
#endif
103+
return self.content(self.viewStore)
84104
}
105+
}
85106

86107
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
87-
extension WithViewStore where Content: View, State: Equatable {
88-
/// Initializes a structure that transforms a store into an observable view store in order to
89-
/// compute views from equatable store state.
90-
///
91-
/// - Parameters:
92-
/// - store: A store of equatable state.
93-
/// - content: A function that can generate content from a view store.
94-
public init(
95-
_ store: Store<State, Action>,
96-
@ViewBuilder content: @escaping (ViewStore<State, Action>) -> Content
97-
) {
98-
self.init(store, removeDuplicates: ==, content: content)
99-
}
108+
extension WithViewStore: View where Content: View {
109+
/// Initializes a structure that transforms a store into an observable view store in order to
110+
/// compute views from store state.
111+
///
112+
/// - Parameters:
113+
/// - store: A store.
114+
/// - isDuplicate: A function to determine when two `State` values are equal. When values are
115+
/// equal, repeat view computations are removed,
116+
/// - content: A function that can generate content from a view store.
117+
public init(
118+
_ store: Store<State, Action>,
119+
removeDuplicates isDuplicate: @escaping (State, State) -> Bool,
120+
file: StaticString = #fileID,
121+
line: UInt = #line,
122+
@ViewBuilder content: @escaping (ViewStore<State, Action>) -> Content
123+
) {
124+
self.init(
125+
store: store,
126+
removeDuplicates: isDuplicate,
127+
file: file,
128+
line: line,
129+
content: content
130+
)
100131
}
101132

102-
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
103-
extension WithViewStore where Content: View, State == Void {
104-
/// Initializes a structure that transforms a store into an observable view store in order to
105-
/// compute views from equatable store state.
106-
///
107-
/// - Parameters:
108-
/// - store: A store of equatable state.
109-
/// - content: A function that can generate content from a view store.
110-
public init(
111-
_ store: Store<State, Action>,
112-
@ViewBuilder content: @escaping (ViewStore<State, Action>) -> Content
113-
) {
114-
self.init(store, removeDuplicates: ==, content: content)
115-
}
133+
public var body: Content {
134+
self._body
135+
}
136+
}
137+
138+
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
139+
extension WithViewStore where State: Equatable, Content: View {
140+
/// Initializes a structure that transforms a store into an observable view store in order to
141+
/// compute views from equatable store state.
142+
///
143+
/// - Parameters:
144+
/// - store: A store of equatable state.
145+
/// - content: A function that can generate content from a view store.
146+
public init(
147+
_ store: Store<State, Action>,
148+
file: StaticString = #fileID,
149+
line: UInt = #line,
150+
@ViewBuilder content: @escaping (ViewStore<State, Action>) -> Content
151+
) {
152+
self.init(store, removeDuplicates: ==, file: file, line: line, content: content)
116153
}
154+
}
155+
156+
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
157+
extension WithViewStore where State == Void, Content: View {
158+
/// Initializes a structure that transforms a store into an observable view store in order to
159+
/// compute views from equatable store state.
160+
///
161+
/// - Parameters:
162+
/// - store: A store of equatable state.
163+
/// - content: A function that can generate content from a view store.
164+
public init(
165+
_ store: Store<State, Action>,
166+
file: StaticString = #fileID,
167+
line: UInt = #line,
168+
@ViewBuilder content: @escaping (ViewStore<State, Action>) -> Content
169+
) {
170+
self.init(store, removeDuplicates: ==, file: file, line: line, content: content)
171+
}
172+
}
117173

118174
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
119-
extension WithViewStore: DynamicViewContent where State: Collection, Content: DynamicViewContent {
120-
public typealias Data = State
175+
extension WithViewStore: DynamicViewContent where State: Collection, Content: DynamicViewContent {
176+
public typealias Data = State
121177

122-
public var data: State {
123-
self.viewStore.state
124-
}
178+
public var data: State {
179+
self.viewStore.state
125180
}
181+
}
126182
#endif
127183

128184
#if canImport(Combine) && canImport(SwiftUI) && compiler(>=5.3)
129185
import SwiftUI
130186

131-
/// A structure that transforms a store into an observable view store in order to compute scenes
132-
/// from store state.
133187
@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *)
134188
extension WithViewStore: Scene where Content: Scene {
135189
/// Initializes a structure that transforms a store into an observable view store in order to
136-
/// compute scenes from store state.
137-
190+
/// compute views from store state.
191+
///
138192
/// - Parameters:
139193
/// - store: A store.
140194
/// - isDuplicate: A function to determine when two `State` values are equal. When values are
@@ -143,56 +197,57 @@
143197
public init(
144198
_ store: Store<State, Action>,
145199
removeDuplicates isDuplicate: @escaping (State, State) -> Bool,
200+
file: StaticString = #fileID,
201+
line: UInt = #line,
146202
@SceneBuilder content: @escaping (ViewStore<State, Action>) -> Content
147203
) {
148-
self.content = content
149-
self.viewStore = ViewStore(store, removeDuplicates: isDuplicate)
204+
self.init(
205+
store: store,
206+
removeDuplicates: isDuplicate,
207+
file: file,
208+
line: line,
209+
content: content
210+
)
150211
}
151212

152213
public var body: Content {
153-
#if DEBUG
154-
if let prefix = self.prefix {
155-
print(
156-
"""
157-
\(prefix.isEmpty ? "" : "\(prefix): ")\
158-
Evaluating WithViewStore<\(State.self), \(Action.self), ...>.body
159-
"""
160-
)
161-
}
162-
#endif
163-
return self.content(self.viewStore)
214+
self._body
164215
}
165216
}
166217

167218
@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *)
168-
extension WithViewStore where Content: Scene, State: Equatable {
219+
extension WithViewStore where State: Equatable, Content: Scene {
169220
/// Initializes a structure that transforms a store into an observable view store in order to
170-
/// compute views from equatable store state.
221+
/// compute scenes from equatable store state.
171222
///
172223
/// - Parameters:
173224
/// - store: A store of equatable state.
174225
/// - content: A function that can generate content from a view store.
175226
public init(
176227
_ store: Store<State, Action>,
228+
file: StaticString = #fileID,
229+
line: UInt = #line,
177230
@SceneBuilder content: @escaping (ViewStore<State, Action>) -> Content
178231
) {
179-
self.init(store, removeDuplicates: ==, content: content)
232+
self.init(store, removeDuplicates: ==, file: file, line: line, content: content)
180233
}
181234
}
182235

183236
@available(iOS 14, macOS 11, tvOS 14, watchOS 7, *)
184-
extension WithViewStore where Content: Scene, State == Void {
237+
extension WithViewStore where State == Void, Content: Scene {
185238
/// Initializes a structure that transforms a store into an observable view store in order to
186-
/// compute views from equatable store state.
239+
/// compute scenes from equatable store state.
187240
///
188241
/// - Parameters:
189242
/// - store: A store of equatable state.
190243
/// - content: A function that can generate content from a view store.
191244
public init(
192245
_ store: Store<State, Action>,
246+
file: StaticString = #fileID,
247+
line: UInt = #line,
193248
@SceneBuilder content: @escaping (ViewStore<State, Action>) -> Content
194249
) {
195-
self.init(store, removeDuplicates: ==, content: content)
250+
self.init(store, removeDuplicates: ==, file: file, line: line, content: content)
196251
}
197252
}
198253

0 commit comments

Comments
 (0)