From 9d5b90be273a9c559f75c512f2cbc9511850748d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Mon, 12 Jan 2026 10:52:53 +0100 Subject: [PATCH 1/2] feat: implement iOS selectors --- apps/TesterIntegrated/swift/App.swift | 63 +++++---- .../swift/Generated/BrownfieldStore.swift | 4 +- docs/docs/brownie/swift-usage.mdx | 123 +++++++++++------- packages/brownie/ArchitectureOverview.md | 21 ++- packages/brownie/ios/BrownieStore.swift | 68 ++++++++-- 5 files changed, 199 insertions(+), 80 deletions(-) diff --git a/apps/TesterIntegrated/swift/App.swift b/apps/TesterIntegrated/swift/App.swift index 23cb3127..88cf2f05 100644 --- a/apps/TesterIntegrated/swift/App.swift +++ b/apps/TesterIntegrated/swift/App.swift @@ -12,7 +12,7 @@ let initialState = BrownfieldStore( Toggles testing playground for side by side brownie mode. Default: false */ -let isSideBySideMode = false +let isSideBySideMode = true @main struct MyApp: App { @@ -55,8 +55,6 @@ struct MyApp: App { } struct FullScreenView: View { - @UseStore var store - var body: some View { NavigationView { VStack { @@ -66,19 +64,8 @@ struct MyApp: App { .padding() .multilineTextAlignment(.center) - Text("Count: \(Int(store.state.counter))") - - TextField("Name", text: Binding(get: { store.state.user.name }, set: { data in - store.set { $0.user.name = data } - })) - .textFieldStyle(.roundedBorder) - .padding(.horizontal) - - Button("Increment") { - store.set { $0.counter += 1 } - } - .buttonStyle(.borderedProminent) - .padding(.bottom) + CounterView() + UserView() NavigationLink("Push React Native Screen") { ReactNativeView(moduleName: "ReactNative") @@ -94,8 +81,37 @@ struct MyApp: App { } } + struct CounterView: View { + @UseStore(\BrownfieldStore.counter) var counter + + var body: some View { + VStack { + Text("Count: \(Int(counter))") + Button("Increment") { + $counter.set { $0 + 1 } + } + .buttonStyle(.borderedProminent) + .padding(.bottom) + } + } + } + + struct UserView: View { + @UseStore(\BrownfieldStore.user) var user + + var body: some View { + TextField("Name", text: Binding( + get: { user.name }, + set: { $user.set(User(name: $0)) } + )) + .textFieldStyle(.roundedBorder) + .padding(.horizontal) + } + } + struct NativeView: View { - @UseStore var store + @UseStore(\BrownfieldStore.counter) var counter + @UseStore(\BrownfieldStore.user) var user var body: some View { VStack { @@ -103,17 +119,18 @@ struct MyApp: App { .font(.headline) .padding(.top) - Text("User: \(store.state.user.name)") - Text("Count: \(Int(store.state.counter))") + Text("User: \(user.name)") + Text("Count: \(Int(counter))") - TextField("Name", text: Binding(get: { store.state.user.name }, set: { data in - store.set { $0.user.name = data } - })) + TextField("Name", text: Binding( + get: { user.name }, + set: { $user.set(User(name: $0)) } + )) .textFieldStyle(.roundedBorder) .padding(.horizontal) Button("Increment") { - store.set { $0.counter += 1 } + $counter.set { $0 + 1 } } .buttonStyle(.borderedProminent) diff --git a/apps/TesterIntegrated/swift/Generated/BrownfieldStore.swift b/apps/TesterIntegrated/swift/Generated/BrownfieldStore.swift index 989dc687..f9914f77 100644 --- a/apps/TesterIntegrated/swift/Generated/BrownfieldStore.swift +++ b/apps/TesterIntegrated/swift/Generated/BrownfieldStore.swift @@ -8,13 +8,13 @@ import Brownie import Foundation // MARK: - BrownfieldStore -struct BrownfieldStore: Codable { +struct BrownfieldStore: Codable, Equatable { var counter: Double var user: User } // MARK: - User -struct User: Codable { +struct User: Codable, Equatable { var name: String } diff --git a/docs/docs/brownie/swift-usage.mdx b/docs/docs/brownie/swift-usage.mdx index 9e1251be..1e03cf75 100644 --- a/docs/docs/brownie/swift-usage.mdx +++ b/docs/docs/brownie/swift-usage.mdx @@ -44,77 +44,100 @@ struct MyApp: App { ### @UseStore Property Wrapper -The `@UseStore` property wrapper provides reactive access to the store: +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. ```swift import Brownie import SwiftUI -struct ContentView: View { - @UseStore var store +struct CounterView: View { + @UseStore(\BrownfieldStore.counter) var counter var body: some View { VStack { - Text("Count: \(Int(store.state.counter))") + Text("Count: \(Int(counter))") Button("Increment") { - store.set { $0.counter += 1 } + $counter.set { $0 + 1 } } } } } ``` +### Selectors + +Every `@UseStore` requires a KeyPath selector. This: + +- Forces explicit state selection +- Prevents unnecessary re-renders (only updates when selected value changes) +- Provides type-safe access to state + +```swift +// Select primitive +@UseStore(\BrownfieldStore.counter) var counter // counter is Double + +// Select nested object +@UseStore(\BrownfieldStore.user) var user // user is User +``` + +:::info Equatable Requirement +Selected values must conform to `Equatable` for change detection. Add `Equatable` to your generated types. +::: + ### Updating State -Use the `set` method with a closure to mutate state: +Use the projected value (`$`) to access setter methods: ```swift -// Update single property -store.set { $0.counter += 1 } +// Set value directly +$counter.set(10) -// Update nested property -store.set { $0.user.name = "John" } +// Set with closure (receives current value) +$counter.set { $0 + 1 } -// Update multiple properties -store.set { - $0.counter = 0 - $0.user.name = "Reset" -} +// For nested types, replace the whole object +$user.set(User(name: "John")) ``` -### TextField Binding +### Multiple Selectors -For two-way binding with TextField, create a `Binding`: +Use multiple `@UseStore` declarations for different state slices. Each only triggers re-renders when its selected value changes: ```swift -struct ContentView: View { - @UseStore var store +struct MyView: View { + @UseStore(\BrownfieldStore.counter) var counter + @UseStore(\BrownfieldStore.user) var user var body: some View { - TextField("Name", text: Binding( - get: { store.state.user.name }, - set: { store.set { $0.user.name = $0 } } - )) - .textFieldStyle(.roundedBorder) + VStack { + Text("Count: \(Int(counter))") + Text("User: \(user.name)") + + Button("Increment") { + $counter.set { $0 + 1 } + } + } } } ``` -### Reading State +### TextField Binding -Access state via the `state` property or keypaths: +For two-way binding with TextField, create a `Binding`: ```swift -// Via state property -let counter = store.state.counter -let name = store.state.user.name - -// Via keypath subscript -let counter = store[\.counter] +struct UserView: View { + @UseStore(\BrownfieldStore.user) var user -// Via get method -let counter = store.get(\.counter) + var body: some View { + TextField("Name", text: Binding( + get: { user.name }, + set: { $user.set(User(name: $0)) } + )) + .textFieldStyle(.roundedBorder) + } +} ``` ## UIKit @@ -214,6 +237,28 @@ cancelSubscription = store.subscribe(\.counter) { [weak self] counter in ## API Reference +### @UseStore + +Property wrapper for SwiftUI with required KeyPath selector: + +```swift +@UseStore(\BrownfieldStore.counter) var counter +``` + +| Property | Type | Description | +| ---------------- | -------------- | ---------------------------- | +| `wrappedValue` | `Value` | Selected value (read-only) | +| `projectedValue` | `StoreBinding` | Setter access via `$counter` | + +### StoreBinding + +Provides setter methods via projected value (`$counter`): + +| Method | Description | +| --------- | ---------------------------------------------- | +| `set(_:)` | Set value directly | +| `set(_:)` | Set value with closure receiving current value | + ### Store<State> | Method | Description | @@ -233,13 +278,3 @@ cancelSubscription = store.subscribe(\.counter) { [weak self] counter in | `StoreManager.get(key:as:)` | Retrieve typed store by key | | `shared.snapshot(key:)` | Get raw snapshot dictionary | | `shared.removeStore(key:)` | Remove and cleanup store | - -### @UseStore - -Property wrapper for SwiftUI that auto-discovers store by type's `storeName`. - -```swift -@UseStore var store -``` - -Requires generated type to conform to `BrownieStoreProtocol`. diff --git a/packages/brownie/ArchitectureOverview.md b/packages/brownie/ArchitectureOverview.md index bfb2bfea..13416d04 100644 --- a/packages/brownie/ArchitectureOverview.md +++ b/packages/brownie/ArchitectureOverview.md @@ -298,10 +298,25 @@ packages/brownie/ - `store(key:as:)` - Retrieve typed store - `snapshot(key:)` - Get snapshot via C++ bridge -**@UseStore** - SwiftUI property wrapper: +**@UseStore** - SwiftUI property wrapper with selector support: -- Uses `BrownieStoreProtocol` to automatically derive store key via `storeName` -- `wrappedValue` - Access to full `Store` +```swift +@UseStore(\BrownfieldStore.counter) var counter +// counter -> Double (wrappedValue, read-only) +// $counter.set(5) (projectedValue, direct value) +// $counter.set { $0 + 1 } (projectedValue, closure receives current value) +``` + +- Requires `WritableKeyPath` selector - forces explicit state selection +- `Value` must conform to `Equatable` for change detection +- Uses `removeDuplicates()` internally - only re-renders when selected value changes +- `wrappedValue` - Selected value (read-only) +- `projectedValue` - `StoreBinding` with `set` methods for updates + +**StoreBinding** - Setter wrapper returned via `$projectedValue`: + +- `set(_:)` - Set value directly +- `set(_:)` - Set value via closure that receives current value ## JS API diff --git a/packages/brownie/ios/BrownieStore.swift b/packages/brownie/ios/BrownieStore.swift index 4a440bf5..29815ef0 100644 --- a/packages/brownie/ios/BrownieStore.swift +++ b/packages/brownie/ios/BrownieStore.swift @@ -20,20 +20,72 @@ public extension EnvironmentValues { } } +/// Provides setter methods for the selected store value via projectedValue ($counter). +public struct StoreBinding { + private let store: Store + private let keyPath: WritableKeyPath + + init(store: Store, keyPath: WritableKeyPath) { + self.store = store + self.keyPath = keyPath + } + + /// Set value directly + public func set(_ value: Value) { + store.set(keyPath, to: value) + } + + /// Set value using closure that receives current value + public func set(_ updater: (Value) -> Value) { + let currentValue = store.get(keyPath) + let newValue = updater(currentValue) + store.set(keyPath, to: newValue) + } +} + @MainActor @propertyWrapper -public struct UseStore: DynamicProperty { - @StateObject private var store: Store +public struct UseStore: DynamicProperty { + @StateObject private var observer: SelectorObserver + private let keyPath: WritableKeyPath - public init() { + public init(_ keyPath: WritableKeyPath) { + self.keyPath = keyPath let key = State.storeName - let foundStore = StoreManager.shared.store(key: key, as: State.self) - guard let foundStore else { fatalError("Store not found for key: \(key)") } - self._store = StateObject(wrappedValue: foundStore) + guard let foundStore = StoreManager.shared.store(key: key, as: State.self) else { + fatalError("Store not found for key: \(key)") + } + self._observer = StateObject(wrappedValue: SelectorObserver(store: foundStore, keyPath: keyPath)) } - public var wrappedValue: Store { - store + public var wrappedValue: Value { + observer.value + } + + public var projectedValue: StoreBinding { + StoreBinding(store: observer.store, keyPath: keyPath) + } +} + +/// Internal observer that only publishes when selected value changes. +@MainActor +class SelectorObserver: ObservableObject { + let store: Store + private let keyPath: KeyPath + @Published private(set) var value: Value + private var cancellable: AnyCancellable? + + init(store: Store, keyPath: KeyPath) { + self.store = store + self.keyPath = keyPath + self.value = store.state[keyPath: keyPath] + + self.cancellable = store.$state + .map { $0[keyPath: keyPath] } + .removeDuplicates() + .sink { [weak self] newValue in + self?.value = newValue + } } } From b735f95b4f82228accbb48c7d9ba4ea4ea72b098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Mon, 12 Jan 2026 10:59:00 +0100 Subject: [PATCH 2/2] fix(codegen): add Equatable protocol to generated Swift types --- apps/TesterIntegrated/swift/App.swift | 23 +++----- .../swift/Generated/BrownfieldStore.swift | 12 ++++ .../swift/Generated/SettingsStore.swift | 10 +++- docs/docs/brownie/swift-usage.mdx | 55 +++++++++++-------- packages/brownie/ArchitectureOverview.md | 9 ++- packages/brownie/ios/BrownieStore.swift | 29 +++------- packages/brownie/scripts/generators/swift.ts | 2 + 7 files changed, 74 insertions(+), 66 deletions(-) diff --git a/apps/TesterIntegrated/swift/App.swift b/apps/TesterIntegrated/swift/App.swift index 88cf2f05..4dad4cf5 100644 --- a/apps/TesterIntegrated/swift/App.swift +++ b/apps/TesterIntegrated/swift/App.swift @@ -12,7 +12,7 @@ let initialState = BrownfieldStore( Toggles testing playground for side by side brownie mode. Default: false */ -let isSideBySideMode = true +let isSideBySideMode = false @main struct MyApp: App { @@ -87,9 +87,8 @@ struct MyApp: App { var body: some View { VStack { Text("Count: \(Int(counter))") - Button("Increment") { - $counter.set { $0 + 1 } - } + Stepper(value: $counter, label: { Text("Increment") }) + .buttonStyle(.borderedProminent) .padding(.bottom) } @@ -97,15 +96,12 @@ struct MyApp: App { } struct UserView: View { - @UseStore(\BrownfieldStore.user) var user + @UseStore(\BrownfieldStore.user.name) var name var body: some View { - TextField("Name", text: Binding( - get: { user.name }, - set: { $user.set(User(name: $0)) } - )) - .textFieldStyle(.roundedBorder) - .padding(.horizontal) + TextField("Name", text: $name) + .textFieldStyle(.roundedBorder) + .padding(.horizontal) } } @@ -122,10 +118,7 @@ struct MyApp: App { Text("User: \(user.name)") Text("Count: \(Int(counter))") - TextField("Name", text: Binding( - get: { user.name }, - set: { $user.set(User(name: $0)) } - )) + TextField("Name", text: $user.name) .textFieldStyle(.roundedBorder) .padding(.horizontal) diff --git a/apps/TesterIntegrated/swift/Generated/BrownfieldStore.swift b/apps/TesterIntegrated/swift/Generated/BrownfieldStore.swift index f9914f77..cfb0e9c3 100644 --- a/apps/TesterIntegrated/swift/Generated/BrownfieldStore.swift +++ b/apps/TesterIntegrated/swift/Generated/BrownfieldStore.swift @@ -5,6 +5,12 @@ import Brownie // // let brownfieldStore = try? JSONDecoder().decode(BrownfieldStore.self, from: jsonData) +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + import Foundation // MARK: - BrownfieldStore @@ -13,6 +19,12 @@ struct BrownfieldStore: Codable, Equatable { var user: User } +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + // MARK: - User struct User: Codable, Equatable { var name: String diff --git a/apps/TesterIntegrated/swift/Generated/SettingsStore.swift b/apps/TesterIntegrated/swift/Generated/SettingsStore.swift index ecdd4c88..3098585e 100644 --- a/apps/TesterIntegrated/swift/Generated/SettingsStore.swift +++ b/apps/TesterIntegrated/swift/Generated/SettingsStore.swift @@ -5,15 +5,21 @@ import Brownie // // let settingsStore = try? JSONDecoder().decode(SettingsStore.self, from: jsonData) +// +// Hashable or Equatable: +// The compiler will not be able to synthesize the implementation of Hashable or Equatable +// for types that require the use of JSONAny, nor will the implementation of Hashable be +// synthesized for types that have collections (such as arrays or dictionaries). + import Foundation // MARK: - SettingsStore -struct SettingsStore: Codable { +struct SettingsStore: Codable, Equatable { var notificationsEnabled, privacyMode: Bool var theme: Theme } -enum Theme: String, Codable { +enum Theme: String, Codable, Equatable { case dark = "dark" case light = "light" } diff --git a/docs/docs/brownie/swift-usage.mdx b/docs/docs/brownie/swift-usage.mdx index 1e03cf75..dc2494cf 100644 --- a/docs/docs/brownie/swift-usage.mdx +++ b/docs/docs/brownie/swift-usage.mdx @@ -82,22 +82,24 @@ Every `@UseStore` requires a KeyPath selector. This: ``` :::info Equatable Requirement -Selected values must conform to `Equatable` for change detection. Add `Equatable` to your generated types. +Selected values must conform to `Equatable` for change detection. ::: ### Updating State -Use the projected value (`$`) to access setter methods: +The projected value (`$`) returns a standard SwiftUI `Binding`: ```swift -// Set value directly -$counter.set(10) +// Use with any SwiftUI control that accepts Binding +Stepper(value: $counter) { Text("Count: \(Int(counter))") } +Slider(value: $counter, in: 0...100) +Toggle("Enabled", isOn: $isEnabled) -// Set with closure (receives current value) +// Set with closure (Brownie extension on Binding) $counter.set { $0 + 1 } -// For nested types, replace the whole object -$user.set(User(name: "John")) +// Access nested properties via Binding subscript +TextField("Name", text: $user.name) ``` ### Multiple Selectors @@ -124,18 +126,26 @@ struct MyView: View { ### TextField Binding -For two-way binding with TextField, create a `Binding`: +Use the binding directly or select a nested property: ```swift +// Option 1: Select the nested property directly +struct UserView: View { + @UseStore(\BrownfieldStore.user.name) var name + + var body: some View { + TextField("Name", text: $name) + .textFieldStyle(.roundedBorder) + } +} + +// Option 2: Select parent and access nested binding struct UserView: View { @UseStore(\BrownfieldStore.user) var user var body: some View { - TextField("Name", text: Binding( - get: { user.name }, - set: { $user.set(User(name: $0)) } - )) - .textFieldStyle(.roundedBorder) + TextField("Name", text: $user.name) + .textFieldStyle(.roundedBorder) } } ``` @@ -245,19 +255,18 @@ Property wrapper for SwiftUI with required KeyPath selector: @UseStore(\BrownfieldStore.counter) var counter ``` -| Property | Type | Description | -| ---------------- | -------------- | ---------------------------- | -| `wrappedValue` | `Value` | Selected value (read-only) | -| `projectedValue` | `StoreBinding` | Setter access via `$counter` | +| Property | Type | Description | +| ---------------- | ---------------- | ----------------------------------- | +| `wrappedValue` | `Value` | Selected value (read-only) | +| `projectedValue` | `Binding` | Standard SwiftUI binding via `$var` | -### StoreBinding +### Binding Extension -Provides setter methods via projected value (`$counter`): +Brownie adds a `set` method to `Binding` for closure-based updates: -| Method | Description | -| --------- | ---------------------------------------------- | -| `set(_:)` | Set value directly | -| `set(_:)` | Set value with closure receiving current value | +```swift +$counter.set { $0 + 1 } // increment using current value +``` ### Store<State> diff --git a/packages/brownie/ArchitectureOverview.md b/packages/brownie/ArchitectureOverview.md index 13416d04..b763d690 100644 --- a/packages/brownie/ArchitectureOverview.md +++ b/packages/brownie/ArchitectureOverview.md @@ -303,19 +303,18 @@ packages/brownie/ ```swift @UseStore(\BrownfieldStore.counter) var counter // counter -> Double (wrappedValue, read-only) -// $counter.set(5) (projectedValue, direct value) -// $counter.set { $0 + 1 } (projectedValue, closure receives current value) +// $counter -> Binding (projectedValue, standard SwiftUI binding) +// $counter.set { $0 + 1 } (Binding extension for closure updates) ``` - Requires `WritableKeyPath` selector - forces explicit state selection - `Value` must conform to `Equatable` for change detection - Uses `removeDuplicates()` internally - only re-renders when selected value changes - `wrappedValue` - Selected value (read-only) -- `projectedValue` - `StoreBinding` with `set` methods for updates +- `projectedValue` - Standard `Binding` for SwiftUI controls -**StoreBinding** - Setter wrapper returned via `$projectedValue`: +**Binding Extension** - Adds closure-based setter: -- `set(_:)` - Set value directly - `set(_:)` - Set value via closure that receives current value ## JS API diff --git a/packages/brownie/ios/BrownieStore.swift b/packages/brownie/ios/BrownieStore.swift index 29815ef0..23cfce0f 100644 --- a/packages/brownie/ios/BrownieStore.swift +++ b/packages/brownie/ios/BrownieStore.swift @@ -20,26 +20,10 @@ public extension EnvironmentValues { } } -/// Provides setter methods for the selected store value via projectedValue ($counter). -public struct StoreBinding { - private let store: Store - private let keyPath: WritableKeyPath - - init(store: Store, keyPath: WritableKeyPath) { - self.store = store - self.keyPath = keyPath - } - - /// Set value directly - public func set(_ value: Value) { - store.set(keyPath, to: value) - } - +public extension Binding { /// Set value using closure that receives current value - public func set(_ updater: (Value) -> Value) { - let currentValue = store.get(keyPath) - let newValue = updater(currentValue) - store.set(keyPath, to: newValue) + func set(_ updater: (Value) -> Value) { + wrappedValue = updater(wrappedValue) } } @@ -62,8 +46,11 @@ public struct UseStore: DynamicPr observer.value } - public var projectedValue: StoreBinding { - StoreBinding(store: observer.store, keyPath: keyPath) + public var projectedValue: Binding { + Binding( + get: { observer.store.get(keyPath) }, + set: { observer.store.set(keyPath, to: $0) } + ) } } diff --git a/packages/brownie/scripts/generators/swift.ts b/packages/brownie/scripts/generators/swift.ts index 5cff5110..e70b4d82 100644 --- a/packages/brownie/scripts/generators/swift.ts +++ b/packages/brownie/scripts/generators/swift.ts @@ -63,6 +63,8 @@ export async function generateSwift( rendererOptions: { 'mutable-properties': 'true', initializers: 'false', + 'swift-5-support': 'true', + protocol: 'equatable', }, });