Skip to content

Commit 4510dac

Browse files
committed
Deprecate dynamic member lookup on view stores in favor of ViewStore.binding (#810)
* wip * wip
1 parent c043a75 commit 4510dac

File tree

5 files changed

+88
-32
lines changed

5 files changed

+88
-32
lines changed

Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Forms.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,17 +55,17 @@ struct BindingFormView: View {
5555
Form {
5656
Section(header: Text(template: readMe, .caption)) {
5757
HStack {
58-
TextField("Type here", text: viewStore.$text)
58+
TextField("Type here", text: viewStore.binding(\.$text))
5959
.disableAutocorrection(true)
6060
.foregroundColor(viewStore.toggleIsOn ? .gray : .primary)
6161

6262
Text(alternate(viewStore.text))
6363
}
6464
.disabled(viewStore.toggleIsOn)
6565

66-
Toggle("Disable other controls", isOn: viewStore.$toggleIsOn)
66+
Toggle("Disable other controls", isOn: viewStore.binding(\.$toggleIsOn))
6767

68-
Stepper(value: viewStore.$stepCount, in: 0...100) {
68+
Stepper(value: viewStore.binding(\.$stepCount), in: 0...100) {
6969
Text("Max slider value: \(viewStore.stepCount)")
7070
.font(Font.body.monospacedDigit())
7171
}
@@ -75,7 +75,7 @@ struct BindingFormView: View {
7575
Text("Slider value: \(Int(viewStore.sliderValue))")
7676
.font(Font.body.monospacedDigit())
7777

78-
Slider(value: viewStore.$sliderValue, in: 0...Double(viewStore.stepCount))
78+
Slider(value: viewStore.binding(\.$sliderValue), in: 0...Double(viewStore.stepCount))
7979
}
8080
.disabled(viewStore.toggleIsOn)
8181
}

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,10 @@ let focusDemoReducer = Reducer<
5454
Text(template: readMe, .caption)
5555

5656
VStack {
57-
TextField("Username", text: viewStore.$username)
57+
TextField("Username", text: viewStore.binding(\.$username))
5858
.focused($focusedField, equals: .username)
5959

60-
SecureField("Password", text: viewStore.$password)
60+
SecureField("Password", text: viewStore.binding(\.$password))
6161
.focused($focusedField, equals: .password)
6262

6363
Button("Sign In") {
@@ -68,7 +68,7 @@ let focusDemoReducer = Reducer<
6868
Spacer()
6969
}
7070
.padding()
71-
.synchronize(viewStore.$focusedField, self.$focusedField)
71+
.synchronize(viewStore.binding(\.$focusedField), self.$focusedField)
7272
}
7373
.navigationBarTitle("Focus demo")
7474
}

Sources/ComposableArchitecture/Internal/Deprecations.swift

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,26 @@ import CasePaths
33

44
import SwiftUI
55

6+
// NB: Deprecated after 0.27.1:
7+
8+
extension ViewStore {
9+
@available(
10+
*, deprecated,
11+
message:
12+
"Dynamic member lookup is no longer supported for bindable state. Instead of dot-chaining on the view store, e.g. 'viewStore.$value', invoke the 'binding' method on view store with a key path to the value, e.g. 'viewStore.binding(\\.$value)'. For more on this change, see: https://github.com/pointfreeco/swift-composable-architecture/pull/810"
13+
)
14+
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
15+
public subscript<Value>(
16+
dynamicMember keyPath: WritableKeyPath<State, BindableState<Value>>
17+
) -> Binding<Value>
18+
where Action: BindableAction, Action.State == State, Value: Equatable {
19+
self.binding(
20+
get: { $0[keyPath: keyPath].wrappedValue },
21+
send: { .binding(.set(keyPath, $0)) }
22+
)
23+
}
24+
}
25+
626
// NB: Deprecated after 0.25.0:
727

828
#if compiler(>=5.4)
@@ -59,7 +79,7 @@ import SwiftUI
5979
@available(
6080
*, deprecated,
6181
message:
62-
"For improved safety, bindable properties must now be wrapped explicitly in 'BindableState'. Bindings are now derived via dynamic member lookup to that 'BindableState' (for example, 'viewStore.$value'). For dynamic member lookup to be available, the view store's 'Action' type must also conform to 'BindableAction'."
82+
"For improved safety, bindable properties must now be wrapped explicitly in 'BindableState'. Bindings are now derived via 'ViewStore.binding' with a key path to that 'BindableState' (for example, 'viewStore.binding(\\.$value)'). For dynamic member lookup to be available, the view store's 'Action' type must also conform to 'BindableAction'."
6383
)
6484
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
6585
public func binding<LocalState>(
@@ -127,7 +147,7 @@ import SwiftUI
127147
@available(
128148
*, deprecated,
129149
message:
130-
"For improved safety, bindable properties must now be wrapped explicitly in 'BindableState'. Bindings are now derived via dynamic member lookup to that 'BindableState' (for example, 'viewStore.$value'). For dynamic member lookup to be available, the view store's 'Action' type must also conform to 'BindableAction'. Upgrade to Xcode 12.5 or greater for access to 'BindableState' and 'BindableAction'."
150+
"For improved safety, bindable properties must now be wrapped explicitly in 'BindableState'. Bindings are now derived via 'ViewStore.binding' with a key path to that 'BindableState' (for example, 'viewStore.binding(\\.$value)'). For dynamic member lookup to be available, the view store's 'Action' type must also conform to 'BindableAction'. Upgrade to Xcode 12.5 or greater for access to 'BindableState' and 'BindableAction'."
131151
)
132152
public func binding<LocalState>(
133153
keyPath: WritableKeyPath<State, LocalState>,

Sources/ComposableArchitecture/SwiftUI/Binding.swift

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,11 @@
123123
/// .binding()
124124
/// ```
125125
///
126-
/// Binding actions are constructed and sent to the store through the projected value of the
127-
/// bindable state property wrapper in a syntax that is as familiar and succinct as vanilla
128-
/// SwiftUI:
126+
/// Binding actions are constructed and sent to the store by calling ``ViewStore/binding(_:)``
127+
/// with a key path to the bindable state:
129128
///
130129
/// ```swift
131-
/// TextField("Display name", text: viewStore.$displayName)
130+
/// TextField("Display name", text: viewStore.binding(\.$displayName))
132131
/// ```
133132
///
134133
/// Should you need to layer additional functionality over these bindings, your reducer can
@@ -172,13 +171,13 @@
172171
self.wrappedValue = wrappedValue
173172
}
174173

175-
/// A projection can be used to derive bindings from a view store via dynamic member lookup.
174+
/// A projection that can be used to derive bindings from a view store.
176175
///
177176
/// Use the projected value to derive bindings from a view store with properties annotated with
178177
/// `@BindableState`. To get the `projectedValue`, prefix the property with `$`:
179178
///
180179
/// ```swift
181-
/// TextField("Display name", text: viewStore.$displayName)
180+
/// TextField("Display name", text: viewStore.binding(\.$displayName))
182181
/// ```
183182
///
184183
/// See ``BindableState`` for more details.
@@ -275,23 +274,23 @@
275274
}
276275
}
277276

278-
#if canImport(SwiftUI)
279-
extension ViewStore {
280-
/// Returns a binding to the resulting bindable state of a given key path.
281-
///
282-
/// - Parameter keyPath: A key path to a specific bindable state.
283-
/// - Returns: A new binding.
284-
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
285-
public subscript<Value>(
286-
dynamicMember keyPath: WritableKeyPath<State, BindableState<Value>>
287-
) -> Binding<Value>
288-
where Action: BindableAction, Action.State == State, Value: Equatable {
289-
self.binding(
290-
get: { $0[keyPath: keyPath].wrappedValue },
291-
send: { .binding(.set(keyPath, $0)) }
292-
)
293-
}
277+
#if canImport(SwiftUI)
278+
extension ViewStore {
279+
/// Returns a binding to the resulting bindable state of a given key path.
280+
///
281+
/// - Parameter keyPath: A key path to a specific bindable state.
282+
/// - Returns: A new binding.
283+
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
284+
public func binding<Value>(
285+
_ keyPath: WritableKeyPath<State, BindableState<Value>>
286+
) -> Binding<Value>
287+
where Action: BindableAction, Action.State == State, Value: Equatable {
288+
self.binding(
289+
get: { $0[keyPath: keyPath].wrappedValue },
290+
send: { .binding(.set(keyPath, $0)) }
291+
)
294292
}
293+
}
295294
#endif
296295

297296
/// An action that describes simple mutations to some root state at a writable key path.
@@ -436,7 +435,7 @@
436435
/// WithViewStore(
437436
/// self.store.scope(state: \.view, action: AppAction.view)
438437
/// ) { viewStore in
439-
/// Stepper("\(viewStore.count)", viewStore.$count)
438+
/// Stepper("\(viewStore.count)", viewStore.binding(\.$count))
440439
/// Button("Get number fact") { viewStore.send(.factButtonTapped) }
441440
/// if let fact = viewStore.fact {
442441
/// Text(fact)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import ComposableArchitecture
2+
import XCTest
3+
4+
final class BindingTests: XCTestCase {
5+
func testNestedBindableState() {
6+
struct State: Equatable {
7+
@BindableState var nested = Nested()
8+
9+
struct Nested: Equatable {
10+
var field = ""
11+
}
12+
}
13+
14+
enum Action: BindableAction, Equatable {
15+
case binding(BindingAction<State>)
16+
}
17+
18+
let reducer = Reducer<State, Action, ()> { state, action, _ in
19+
switch action {
20+
case .binding(\.$nested.field):
21+
state.nested.field += "!"
22+
return .none
23+
default:
24+
return .none
25+
}
26+
}
27+
.binding()
28+
29+
let store = Store(initialState: .init(), reducer: reducer, environment: ())
30+
31+
let viewStore = ViewStore(store)
32+
33+
viewStore.binding(\.$nested.field).wrappedValue = "Hello"
34+
35+
XCTAssertNoDifference(viewStore.state, .init(nested: .init(field: "Hello!")))
36+
}
37+
}

0 commit comments

Comments
 (0)