Skip to content

Commit c420831

Browse files
authored
Merge pull request #28 from PureSwift/feature/bindable
@bindable for observable models
2 parents 41683d1 + a7d8ade commit c420831

4 files changed

Lines changed: 124 additions & 1 deletion

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
//
2+
// Bindable.swift
3+
// AndroidSwiftUICore
4+
//
5+
// `@Bindable` wraps a reference to an `@Observable` model and projects a
6+
// two-way `Binding` into any of its properties via `$model.property`. Because
7+
// it wraps an existing reference (from `@State`, `@Environment`, or an init
8+
// parameter), it needs no state storage of its own — writes go straight
9+
// through the reference, and the model's observation drives re-evaluation.
10+
//
11+
12+
@propertyWrapper
13+
@dynamicMemberLookup
14+
public struct Bindable<Value: AnyObject> {
15+
16+
public var wrappedValue: Value
17+
18+
public init(wrappedValue: Value) {
19+
self.wrappedValue = wrappedValue
20+
}
21+
22+
public init(_ wrappedValue: Value) {
23+
self.wrappedValue = wrappedValue
24+
}
25+
26+
public var projectedValue: Bindable<Value> { self }
27+
28+
/// Projects a binding to a property of the wrapped reference. A reference
29+
/// keypath means the set writes through the object without reassigning it.
30+
public subscript<Subject>(
31+
dynamicMember keyPath: ReferenceWritableKeyPath<Value, Subject>
32+
) -> Binding<Subject> {
33+
let object = wrappedValue
34+
return Binding<Subject>(
35+
get: { object[keyPath: keyPath] },
36+
set: { object[keyPath: keyPath] = $0 }
37+
)
38+
}
39+
}

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import Testing
99
#if canImport(Observation)
1010
import Observation
1111

12-
@Observable final class ObservableModel { var counter = 0 }
12+
@Observable final class ObservableModel { var counter = 0; var name = "" }
1313
#endif
1414

1515
@Suite("Controls and environment")
@@ -109,5 +109,33 @@ struct ControlTests {
109109
node = host.evaluate()
110110
#expect(node.props["text"] == .string("count 1"))
111111
}
112+
113+
@Test("@Bindable projects a two-way binding into an observable model")
114+
func bindableProjection() {
115+
let model = ObservableModel()
116+
@Bindable var bound = model
117+
let binding = $bound.name
118+
binding.wrappedValue = "hi"
119+
#expect(model.name == "hi")
120+
#expect(binding.wrappedValue == "hi")
121+
}
122+
123+
@Test("A TextField bound via @Bindable writes back to the model")
124+
func bindableTextField() {
125+
struct Screen: View {
126+
@Bindable var model: ObservableModel
127+
var body: some View { TextField("Name", text: $model.name) }
128+
}
129+
let model = ObservableModel()
130+
let host = ViewHost(Screen(model: model))
131+
var node = host.evaluate()
132+
#expect(node.props["text"] == .string(""))
133+
if case .int(let id)? = node.props["onChange"] {
134+
host.callbacks.invokeString(Int64(id), "Coleman")
135+
}
136+
#expect(model.name == "Coleman")
137+
node = host.evaluate()
138+
#expect(node.props["text"] == .string("Coleman"))
139+
}
112140
#endif
113141
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
#if canImport(AndroidSwiftUI)
2+
import AndroidSwiftUI
3+
#else
4+
import SwiftUI
5+
#endif
6+
7+
import Observation
8+
9+
@Observable
10+
final class ProfileModel {
11+
var name = ""
12+
var subscribed = false
13+
var volume = 0.5
14+
}
15+
16+
struct BindablePlayground: View {
17+
@State private var model = ProfileModel()
18+
var body: some View {
19+
ScrollView {
20+
BindableForm(model: model)
21+
}
22+
}
23+
}
24+
25+
/// `@Bindable` takes the model by reference and projects `$model.property`
26+
/// bindings into it; writing a bound control mutates the observable, which
27+
/// re-evaluates the views that read it.
28+
struct BindableForm: View {
29+
@Bindable var model: ProfileModel
30+
var body: some View {
31+
VStack(alignment: .leading, spacing: 0) {
32+
Example("Bound TextField") {
33+
VStack(alignment: .leading, spacing: 8) {
34+
TextField("Name", text: $model.name)
35+
Text(model.name.isEmpty ? "No name yet" : "Hello, \(model.name)")
36+
}
37+
}
38+
Example("Bound Toggle") {
39+
VStack(alignment: .leading, spacing: 8) {
40+
Toggle("Subscribed", isOn: $model.subscribed)
41+
Text(model.subscribed ? "Subscribed" : "Not subscribed")
42+
}
43+
}
44+
Example("Bound Slider") {
45+
VStack(alignment: .leading, spacing: 8) {
46+
Slider(value: $model.volume, in: 0...1)
47+
Text("Volume: \(Int(model.volume * 100))%")
48+
}
49+
}
50+
Example("Shared model") {
51+
Text("All three controls above bind into one @Observable model.")
52+
}
53+
}
54+
}
55+
}

Demo/App.swiftpm/Sources/Catalog.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,6 @@ struct CatalogEntry: Identifiable {
4646
CatalogEntry(id: "state", title: "State", screen: AnyCatalogScreen(StatePlayground())),
4747
CatalogEntry(id: "environment", title: "Environment", screen: AnyCatalogScreen(EnvironmentPlayground())),
4848
CatalogEntry(id: "observable", title: "Observable", screen: AnyCatalogScreen(ObservablePlayground())),
49+
CatalogEntry(id: "bindable", title: "Bindable", screen: AnyCatalogScreen(BindablePlayground())),
4950
]
5051
}

0 commit comments

Comments
 (0)