Skip to content

Commit ac6decb

Browse files
authored
feat(brownie): add iOS selector support to @usestore (#179)
* feat: implement iOS selectors * fix(codegen): add Equatable protocol to generated Swift types
1 parent 79e8101 commit ac6decb

File tree

7 files changed

+207
-80
lines changed

7 files changed

+207
-80
lines changed

apps/TesterIntegrated/swift/App.swift

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,6 @@ struct MyApp: App {
5555
}
5656

5757
struct FullScreenView: View {
58-
@UseStore<BrownfieldStore> var store
59-
6058
var body: some View {
6159
NavigationView {
6260
VStack {
@@ -66,19 +64,8 @@ struct MyApp: App {
6664
.padding()
6765
.multilineTextAlignment(.center)
6866

69-
Text("Count: \(Int(store.state.counter))")
70-
71-
TextField("Name", text: Binding(get: { store.state.user.name }, set: { data in
72-
store.set { $0.user.name = data }
73-
}))
74-
.textFieldStyle(.roundedBorder)
75-
.padding(.horizontal)
76-
77-
Button("Increment") {
78-
store.set { $0.counter += 1 }
79-
}
80-
.buttonStyle(.borderedProminent)
81-
.padding(.bottom)
67+
CounterView()
68+
UserView()
8269

8370
NavigationLink("Push React Native Screen") {
8471
ReactNativeView(moduleName: "ReactNative")
@@ -94,26 +81,49 @@ struct MyApp: App {
9481
}
9582
}
9683

84+
struct CounterView: View {
85+
@UseStore(\BrownfieldStore.counter) var counter
86+
87+
var body: some View {
88+
VStack {
89+
Text("Count: \(Int(counter))")
90+
Stepper(value: $counter, label: { Text("Increment") })
91+
92+
.buttonStyle(.borderedProminent)
93+
.padding(.bottom)
94+
}
95+
}
96+
}
97+
98+
struct UserView: View {
99+
@UseStore(\BrownfieldStore.user.name) var name
100+
101+
var body: some View {
102+
TextField("Name", text: $name)
103+
.textFieldStyle(.roundedBorder)
104+
.padding(.horizontal)
105+
}
106+
}
107+
97108
struct NativeView: View {
98-
@UseStore<BrownfieldStore> var store
109+
@UseStore(\BrownfieldStore.counter) var counter
110+
@UseStore(\BrownfieldStore.user) var user
99111

100112
var body: some View {
101113
VStack {
102114
Text("Native Side")
103115
.font(.headline)
104116
.padding(.top)
105117

106-
Text("User: \(store.state.user.name)")
107-
Text("Count: \(Int(store.state.counter))")
118+
Text("User: \(user.name)")
119+
Text("Count: \(Int(counter))")
108120

109-
TextField("Name", text: Binding(get: { store.state.user.name }, set: { data in
110-
store.set { $0.user.name = data }
111-
}))
121+
TextField("Name", text: $user.name)
112122
.textFieldStyle(.roundedBorder)
113123
.padding(.horizontal)
114124

115125
Button("Increment") {
116-
store.set { $0.counter += 1 }
126+
$counter.set { $0 + 1 }
117127
}
118128
.buttonStyle(.borderedProminent)
119129

apps/TesterIntegrated/swift/Generated/BrownfieldStore.swift

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,28 @@ import Brownie
55
//
66
// let brownfieldStore = try? JSONDecoder().decode(BrownfieldStore.self, from: jsonData)
77

8+
//
9+
// Hashable or Equatable:
10+
// The compiler will not be able to synthesize the implementation of Hashable or Equatable
11+
// for types that require the use of JSONAny, nor will the implementation of Hashable be
12+
// synthesized for types that have collections (such as arrays or dictionaries).
13+
814
import Foundation
915

1016
// MARK: - BrownfieldStore
11-
struct BrownfieldStore: Codable {
17+
struct BrownfieldStore: Codable, Equatable {
1218
var counter: Double
1319
var user: User
1420
}
1521

22+
//
23+
// Hashable or Equatable:
24+
// The compiler will not be able to synthesize the implementation of Hashable or Equatable
25+
// for types that require the use of JSONAny, nor will the implementation of Hashable be
26+
// synthesized for types that have collections (such as arrays or dictionaries).
27+
1628
// MARK: - User
17-
struct User: Codable {
29+
struct User: Codable, Equatable {
1830
var name: String
1931
}
2032

apps/TesterIntegrated/swift/Generated/SettingsStore.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,21 @@ import Brownie
55
//
66
// let settingsStore = try? JSONDecoder().decode(SettingsStore.self, from: jsonData)
77

8+
//
9+
// Hashable or Equatable:
10+
// The compiler will not be able to synthesize the implementation of Hashable or Equatable
11+
// for types that require the use of JSONAny, nor will the implementation of Hashable be
12+
// synthesized for types that have collections (such as arrays or dictionaries).
13+
814
import Foundation
915

1016
// MARK: - SettingsStore
11-
struct SettingsStore: Codable {
17+
struct SettingsStore: Codable, Equatable {
1218
var notificationsEnabled, privacyMode: Bool
1319
var theme: Theme
1420
}
1521

16-
enum Theme: String, Codable {
22+
enum Theme: String, Codable, Equatable {
1723
case dark = "dark"
1824
case light = "light"
1925
}

docs/docs/brownie/swift-usage.mdx

Lines changed: 87 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -44,77 +44,110 @@ struct MyApp: App {
4444

4545
### @UseStore Property Wrapper
4646

47-
The `@UseStore` property wrapper provides reactive access to the store:
47+
The `@UseStore` property wrapper provides reactive access to a selected slice of state using KeyPath selectors. This ensures your view only re-renders when the selected value changes.
4848

4949
```swift
5050
import Brownie
5151
import SwiftUI
5252

53-
struct ContentView: View {
54-
@UseStore<BrownfieldStore> var store
53+
struct CounterView: View {
54+
@UseStore(\BrownfieldStore.counter) var counter
5555

5656
var body: some View {
5757
VStack {
58-
Text("Count: \(Int(store.state.counter))")
58+
Text("Count: \(Int(counter))")
5959

6060
Button("Increment") {
61-
store.set { $0.counter += 1 }
61+
$counter.set { $0 + 1 }
6262
}
6363
}
6464
}
6565
}
6666
```
6767

68+
### Selectors
69+
70+
Every `@UseStore` requires a KeyPath selector. This:
71+
72+
- Forces explicit state selection
73+
- Prevents unnecessary re-renders (only updates when selected value changes)
74+
- Provides type-safe access to state
75+
76+
```swift
77+
// Select primitive
78+
@UseStore(\BrownfieldStore.counter) var counter // counter is Double
79+
80+
// Select nested object
81+
@UseStore(\BrownfieldStore.user) var user // user is User
82+
```
83+
84+
:::info Equatable Requirement
85+
Selected values must conform to `Equatable` for change detection.
86+
:::
87+
6888
### Updating State
6989

70-
Use the `set` method with a closure to mutate state:
90+
The projected value (`$`) returns a standard SwiftUI `Binding<Value>`:
7191

7292
```swift
73-
// Update single property
74-
store.set { $0.counter += 1 }
93+
// Use with any SwiftUI control that accepts Binding
94+
Stepper(value: $counter) { Text("Count: \(Int(counter))") }
95+
Slider(value: $counter, in: 0...100)
96+
Toggle("Enabled", isOn: $isEnabled)
7597

76-
// Update nested property
77-
store.set { $0.user.name = "John" }
98+
// Set with closure (Brownie extension on Binding)
99+
$counter.set { $0 + 1 }
78100

79-
// Update multiple properties
80-
store.set {
81-
$0.counter = 0
82-
$0.user.name = "Reset"
83-
}
101+
// Access nested properties via Binding subscript
102+
TextField("Name", text: $user.name)
84103
```
85104

86-
### TextField Binding
105+
### Multiple Selectors
87106

88-
For two-way binding with TextField, create a `Binding`:
107+
Use multiple `@UseStore` declarations for different state slices. Each only triggers re-renders when its selected value changes:
89108

90109
```swift
91-
struct ContentView: View {
92-
@UseStore<BrownfieldStore> var store
110+
struct MyView: View {
111+
@UseStore(\BrownfieldStore.counter) var counter
112+
@UseStore(\BrownfieldStore.user) var user
93113

94114
var body: some View {
95-
TextField("Name", text: Binding(
96-
get: { store.state.user.name },
97-
set: { store.set { $0.user.name = $0 } }
98-
))
99-
.textFieldStyle(.roundedBorder)
115+
VStack {
116+
Text("Count: \(Int(counter))")
117+
Text("User: \(user.name)")
118+
119+
Button("Increment") {
120+
$counter.set { $0 + 1 }
121+
}
122+
}
100123
}
101124
}
102125
```
103126

104-
### Reading State
127+
### TextField Binding
105128

106-
Access state via the `state` property or keypaths:
129+
Use the binding directly or select a nested property:
107130

108131
```swift
109-
// Via state property
110-
let counter = store.state.counter
111-
let name = store.state.user.name
132+
// Option 1: Select the nested property directly
133+
struct UserView: View {
134+
@UseStore(\BrownfieldStore.user.name) var name
112135

113-
// Via keypath subscript
114-
let counter = store[\.counter]
136+
var body: some View {
137+
TextField("Name", text: $name)
138+
.textFieldStyle(.roundedBorder)
139+
}
140+
}
115141

116-
// Via get method
117-
let counter = store.get(\.counter)
142+
// Option 2: Select parent and access nested binding
143+
struct UserView: View {
144+
@UseStore(\BrownfieldStore.user) var user
145+
146+
var body: some View {
147+
TextField("Name", text: $user.name)
148+
.textFieldStyle(.roundedBorder)
149+
}
150+
}
118151
```
119152

120153
## UIKit
@@ -214,6 +247,27 @@ cancelSubscription = store.subscribe(\.counter) { [weak self] counter in
214247

215248
## API Reference
216249

250+
### @UseStore
251+
252+
Property wrapper for SwiftUI with required KeyPath selector:
253+
254+
```swift
255+
@UseStore(\BrownfieldStore.counter) var counter
256+
```
257+
258+
| Property | Type | Description |
259+
| ---------------- | ---------------- | ----------------------------------- |
260+
| `wrappedValue` | `Value` | Selected value (read-only) |
261+
| `projectedValue` | `Binding<Value>` | Standard SwiftUI binding via `$var` |
262+
263+
### Binding Extension
264+
265+
Brownie adds a `set` method to `Binding` for closure-based updates:
266+
267+
```swift
268+
$counter.set { $0 + 1 } // increment using current value
269+
```
270+
217271
### Store&lt;State&gt;
218272

219273
| Method | Description |
@@ -233,13 +287,3 @@ cancelSubscription = store.subscribe(\.counter) { [weak self] counter in
233287
| `StoreManager.get(key:as:)` | Retrieve typed store by key |
234288
| `shared.snapshot(key:)` | Get raw snapshot dictionary |
235289
| `shared.removeStore(key:)` | Remove and cleanup store |
236-
237-
### @UseStore
238-
239-
Property wrapper for SwiftUI that auto-discovers store by type's `storeName`.
240-
241-
```swift
242-
@UseStore<BrownfieldStore> var store
243-
```
244-
245-
Requires generated type to conform to `BrownieStoreProtocol`.

packages/brownie/ArchitectureOverview.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -298,10 +298,24 @@ packages/brownie/
298298
- `store(key:as:)` - Retrieve typed store
299299
- `snapshot(key:)` - Get snapshot via C++ bridge
300300

301-
**@UseStore** - SwiftUI property wrapper:
301+
**@UseStore** - SwiftUI property wrapper with selector support:
302302

303-
- Uses `BrownieStoreProtocol` to automatically derive store key via `storeName`
304-
- `wrappedValue` - Access to full `Store<State>`
303+
```swift
304+
@UseStore(\BrownfieldStore.counter) var counter
305+
// counter -> Double (wrappedValue, read-only)
306+
// $counter -> Binding<Double> (projectedValue, standard SwiftUI binding)
307+
// $counter.set { $0 + 1 } (Binding extension for closure updates)
308+
```
309+
310+
- Requires `WritableKeyPath` selector - forces explicit state selection
311+
- `Value` must conform to `Equatable` for change detection
312+
- Uses `removeDuplicates()` internally - only re-renders when selected value changes
313+
- `wrappedValue` - Selected value (read-only)
314+
- `projectedValue` - Standard `Binding<Value>` for SwiftUI controls
315+
316+
**Binding Extension** - Adds closure-based setter:
317+
318+
- `set(_:)` - Set value via closure that receives current value
305319

306320
## JS API
307321

0 commit comments

Comments
 (0)