Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions AndroidSwiftUICore/Sources/AndroidSwiftUICore/Bindable.swift
Original file line number Diff line number Diff line change
@@ -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<Value: AnyObject> {

public var wrappedValue: Value

public init(wrappedValue: Value) {
self.wrappedValue = wrappedValue
}

public init(_ wrappedValue: Value) {
self.wrappedValue = wrappedValue
}

public var projectedValue: Bindable<Value> { 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<Subject>(
dynamicMember keyPath: ReferenceWritableKeyPath<Value, Subject>
) -> Binding<Subject> {
let object = wrappedValue
return Binding<Subject>(
get: { object[keyPath: keyPath] },
set: { object[keyPath: keyPath] = $0 }
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
}
55 changes: 55 additions & 0 deletions Demo/App.swiftpm/Sources/BindablePlaygrounds.swift
Original file line number Diff line number Diff line change
@@ -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.")
}
}
}
}
1 change: 1 addition & 0 deletions Demo/App.swiftpm/Sources/Catalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())),
]
}
Loading