diff --git a/apps/TesterIntegrated/swift/App.swift b/apps/TesterIntegrated/swift/App.swift index 23cb3127..4dad4cf5 100644 --- a/apps/TesterIntegrated/swift/App.swift +++ b/apps/TesterIntegrated/swift/App.swift @@ -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,33 @@ struct MyApp: App { } } + struct CounterView: View { + @UseStore(\BrownfieldStore.counter) var counter + + var body: some View { + VStack { + Text("Count: \(Int(counter))") + Stepper(value: $counter, label: { Text("Increment") }) + + .buttonStyle(.borderedProminent) + .padding(.bottom) + } + } + } + + struct UserView: View { + @UseStore(\BrownfieldStore.user.name) var name + + var body: some View { + TextField("Name", text: $name) + .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 +115,15 @@ 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: $user.name) .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..cfb0e9c3 100644 --- a/apps/TesterIntegrated/swift/Generated/BrownfieldStore.swift +++ b/apps/TesterIntegrated/swift/Generated/BrownfieldStore.swift @@ -5,16 +5,28 @@ 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 -struct BrownfieldStore: Codable { +struct BrownfieldStore: Codable, Equatable { var counter: Double 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 { +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 9e1251be..dc2494cf 100644 --- a/docs/docs/brownie/swift-usage.mdx +++ b/docs/docs/brownie/swift-usage.mdx @@ -44,77 +44,110 @@ 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. +::: + ### Updating State -Use the `set` method with a closure to mutate state: +The projected value (`$`) returns a standard SwiftUI `Binding`: ```swift -// Update single property -store.set { $0.counter += 1 } +// 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) -// Update nested property -store.set { $0.user.name = "John" } +// Set with closure (Brownie extension on Binding) +$counter.set { $0 + 1 } -// Update multiple properties -store.set { - $0.counter = 0 - $0.user.name = "Reset" -} +// Access nested properties via Binding subscript +TextField("Name", text: $user.name) ``` -### 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: +Use the binding directly or select a nested property: ```swift -// Via state property -let counter = store.state.counter -let name = store.state.user.name +// Option 1: Select the nested property directly +struct UserView: View { + @UseStore(\BrownfieldStore.user.name) var name -// Via keypath subscript -let counter = store[\.counter] + var body: some View { + TextField("Name", text: $name) + .textFieldStyle(.roundedBorder) + } +} -// Via get method -let counter = store.get(\.counter) +// Option 2: Select parent and access nested binding +struct UserView: View { + @UseStore(\BrownfieldStore.user) var user + + var body: some View { + TextField("Name", text: $user.name) + .textFieldStyle(.roundedBorder) + } +} ``` ## UIKit @@ -214,6 +247,27 @@ 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` | `Binding` | Standard SwiftUI binding via `$var` | + +### Binding Extension + +Brownie adds a `set` method to `Binding` for closure-based updates: + +```swift +$counter.set { $0 + 1 } // increment using current value +``` + ### Store<State> | Method | Description | @@ -233,13 +287,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..b763d690 100644 --- a/packages/brownie/ArchitectureOverview.md +++ b/packages/brownie/ArchitectureOverview.md @@ -298,10 +298,24 @@ 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 -> 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` - Standard `Binding` for SwiftUI controls + +**Binding Extension** - Adds closure-based setter: + +- `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..23cfce0f 100644 --- a/packages/brownie/ios/BrownieStore.swift +++ b/packages/brownie/ios/BrownieStore.swift @@ -20,20 +20,59 @@ public extension EnvironmentValues { } } +public extension Binding { + /// Set value using closure that receives current value + func set(_ updater: (Value) -> Value) { + wrappedValue = updater(wrappedValue) + } +} + @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: Binding { + Binding( + get: { observer.store.get(keyPath) }, + set: { observer.store.set(keyPath, to: $0) } + ) + } +} + +/// 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 + } } } 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', }, });