diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Bindable.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Bindable.swift new file mode 100644 index 0000000..6f1da07 --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Bindable.swift @@ -0,0 +1,39 @@ +// +// Bindable.swift +// AndroidSwiftUICore +// +// `@Bindable` wraps a reference to an `@Observable` model and projects a +// two-way `Binding` into any of its properties via `$model.property`. Because +// it wraps an existing reference (from `@State`, `@Environment`, or an init +// parameter), it needs no state storage of its own — writes go straight +// through the reference, and the model's observation drives re-evaluation. +// + +@propertyWrapper +@dynamicMemberLookup +public struct Bindable { + + public var wrappedValue: Value + + public init(wrappedValue: Value) { + self.wrappedValue = wrappedValue + } + + public init(_ wrappedValue: Value) { + self.wrappedValue = wrappedValue + } + + public var projectedValue: Bindable { self } + + /// Projects a binding to a property of the wrapped reference. A reference + /// keypath means the set writes through the object without reassigning it. + public subscript( + dynamicMember keyPath: ReferenceWritableKeyPath + ) -> Binding { + let object = wrappedValue + return Binding( + get: { object[keyPath: keyPath] }, + set: { object[keyPath: keyPath] = $0 } + ) + } +} diff --git a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift index ebd2730..67a0798 100644 --- a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift +++ b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift @@ -9,7 +9,7 @@ import Testing #if canImport(Observation) import Observation -@Observable final class ObservableModel { var counter = 0 } +@Observable final class ObservableModel { var counter = 0; var name = "" } #endif @Suite("Controls and environment") @@ -109,5 +109,33 @@ struct ControlTests { node = host.evaluate() #expect(node.props["text"] == .string("count 1")) } + + @Test("@Bindable projects a two-way binding into an observable model") + func bindableProjection() { + let model = ObservableModel() + @Bindable var bound = model + let binding = $bound.name + binding.wrappedValue = "hi" + #expect(model.name == "hi") + #expect(binding.wrappedValue == "hi") + } + + @Test("A TextField bound via @Bindable writes back to the model") + func bindableTextField() { + struct Screen: View { + @Bindable var model: ObservableModel + var body: some View { TextField("Name", text: $model.name) } + } + let model = ObservableModel() + let host = ViewHost(Screen(model: model)) + var node = host.evaluate() + #expect(node.props["text"] == .string("")) + if case .int(let id)? = node.props["onChange"] { + host.callbacks.invokeString(Int64(id), "Coleman") + } + #expect(model.name == "Coleman") + node = host.evaluate() + #expect(node.props["text"] == .string("Coleman")) + } #endif } diff --git a/Demo/App.swiftpm/Sources/BindablePlaygrounds.swift b/Demo/App.swiftpm/Sources/BindablePlaygrounds.swift new file mode 100644 index 0000000..2470495 --- /dev/null +++ b/Demo/App.swiftpm/Sources/BindablePlaygrounds.swift @@ -0,0 +1,55 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +import Observation + +@Observable +final class ProfileModel { + var name = "" + var subscribed = false + var volume = 0.5 +} + +struct BindablePlayground: View { + @State private var model = ProfileModel() + var body: some View { + ScrollView { + BindableForm(model: model) + } + } +} + +/// `@Bindable` takes the model by reference and projects `$model.property` +/// bindings into it; writing a bound control mutates the observable, which +/// re-evaluates the views that read it. +struct BindableForm: View { + @Bindable var model: ProfileModel + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Example("Bound TextField") { + VStack(alignment: .leading, spacing: 8) { + TextField("Name", text: $model.name) + Text(model.name.isEmpty ? "No name yet" : "Hello, \(model.name)") + } + } + Example("Bound Toggle") { + VStack(alignment: .leading, spacing: 8) { + Toggle("Subscribed", isOn: $model.subscribed) + Text(model.subscribed ? "Subscribed" : "Not subscribed") + } + } + Example("Bound Slider") { + VStack(alignment: .leading, spacing: 8) { + Slider(value: $model.volume, in: 0...1) + Text("Volume: \(Int(model.volume * 100))%") + } + } + Example("Shared model") { + Text("All three controls above bind into one @Observable model.") + } + } + } +} diff --git a/Demo/App.swiftpm/Sources/Catalog.swift b/Demo/App.swiftpm/Sources/Catalog.swift index 3131291..e368f8f 100644 --- a/Demo/App.swiftpm/Sources/Catalog.swift +++ b/Demo/App.swiftpm/Sources/Catalog.swift @@ -46,5 +46,6 @@ struct CatalogEntry: Identifiable { CatalogEntry(id: "state", title: "State", screen: AnyCatalogScreen(StatePlayground())), CatalogEntry(id: "environment", title: "Environment", screen: AnyCatalogScreen(EnvironmentPlayground())), CatalogEntry(id: "observable", title: "Observable", screen: AnyCatalogScreen(ObservablePlayground())), + CatalogEntry(id: "bindable", title: "Bindable", screen: AnyCatalogScreen(BindablePlayground())), ] }