Skip to content

Commit db8f1b9

Browse files
Support animation and debounce with native bindings (#1007)
* Support animation with native bindings * Fix issue with sending native bindings to incorrect LiveView * Fix test suite * Fix redirect disconnection * Support debouncing native bindings with `binding-attribute:debounce` * Support global and scoped persistence modes * Attempt to fix assertMatch --------- Co-authored-by: May Matyi <[email protected]>
1 parent 577f956 commit db8f1b9

File tree

7 files changed

+176
-80
lines changed

7 files changed

+176
-80
lines changed

Sources/LiveViewNative/Coordinators/LiveSessionCoordinator.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public class LiveSessionCoordinator<R: RootRegistry>: ObservableObject {
4141
public private(set) var url: URL
4242

4343
@Published var navigationPath = [LiveNavigationEntry<R>]()
44-
private(set) var rootCoordinator: LiveViewCoordinator<R>!
44+
var rootCoordinator: LiveViewCoordinator<R>!
4545

4646
internal let config: LiveSessionConfiguration
4747

@@ -321,6 +321,7 @@ public class LiveSessionCoordinator<R: RootRegistry>: ObservableObject {
321321
case .replaceTop:
322322
let coordinator = (navigationPath.last?.coordinator ?? rootCoordinator)!
323323
coordinator.url = redirect.to
324+
coordinator.disconnect()
324325
let entry = LiveNavigationEntry(url: redirect.to, coordinator: coordinator)
325326
switch redirect.kind {
326327
case .push:

Sources/LiveViewNative/Coordinators/LiveViewCoordinator.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ public class LiveViewCoordinator<R: RootRegistry>: ObservableObject {
253253
connectParams["_csrf_token"] = session.phxCSRFToken
254254
connectParams["_platform"] = "swiftui"
255255
connectParams["_platform_meta"] = try getPlatformMetadata()
256+
connectParams["_global_native_bindings"] = Dictionary(uniqueKeysWithValues: R.globalBindings.map({ ($0, R.bindingValue(forKey: $0, in: nil)) }))
256257

257258
let params: Payload = [
258259
"session": session.phxSession,

Sources/LiveViewNative/NavStackEntryView.swift

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,14 @@ import LiveViewNativeCore
1111
struct NavStackEntryView<R: RootRegistry>: View {
1212
private let entry: LiveNavigationEntry<R>
1313
@ObservedObject private var coordinator: LiveViewCoordinator<R>
14-
@StateObject private var liveViewModel = LiveViewModel()
14+
@StateObject private var liveViewModel = LiveViewModel(bindingValue: R.bindingValue, setBindingValue: R.setBindingValue, globalBindings: { R.globalBindings }, registerGlobalBinding: R.registerGlobalBinding)
1515

1616
init(_ entry: LiveNavigationEntry<R>) {
1717
self.entry = entry
1818
self.coordinator = entry.coordinator
1919
}
2020

2121
var body: some View {
22-
let _ = Self._printChanges()
2322
elementTree
2423
.environmentObject(liveViewModel)
2524
.onReceive(coordinator.$document) { newDocument in
@@ -29,11 +28,15 @@ struct NavStackEntryView<R: RootRegistry>: View {
2928
}
3029
}
3130
.onReceive(coordinator.receiveEvent("_native_bindings"), perform: liveViewModel.updateBindings)
31+
.onReceive(coordinator.receiveEvent("_native_bindings_init"), perform: liveViewModel.initBindings(payload:))
3232
.onReceive(
3333
liveViewModel.bindingUpdatedByClient
34-
.collect(.byTime(RunLoop.main, RunLoop.main.minimumTolerance))
34+
.collect(.byTime(RunLoop.current, RunLoop.current.minimumTolerance))
3535
) { updates in
3636
Task {
37+
// In some cases, the bindings will update as the page is navigating.
38+
// Don't send the bindings to the wrong live view in this case.
39+
guard entry.url == coordinator.url else { return }
3740
try? await coordinator.pushEvent(type: "_native_bindings", event: "_native_bindings", value: Dictionary(updates, uniquingKeysWith: { cur, new in new }))
3841
}
3942
}

Sources/LiveViewNative/Property Wrappers/LiveBinding.swift

Lines changed: 56 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -149,40 +149,17 @@ public struct LiveBinding<Value: Codable> {
149149
/// Setting this property will send an event to the server to update its value as well.
150150
public var wrappedValue: Value {
151151
get {
152-
let valueError: Error?
153-
if let bindingName {
154-
do {
155-
return try data.getValue(from: liveViewModel, bindingName: bindingName)
156-
} catch {
157-
valueError = error
158-
}
159-
} else {
160-
valueError = nil
161-
}
162-
if case .local(let value) = data.mode {
163-
return value
152+
if let boundValue = data.value {
153+
return boundValue
164154
} else if let initialLocalValue {
165155
return initialLocalValue
166156
} else {
167-
if let valueError {
168-
fatalError(valueError.localizedDescription)
169-
} else {
170-
fatalError("@LiveBinding must have binding name or default value")
171-
}
157+
fatalError("@LiveBinding must have binding name or default value")
172158
}
173159
}
174160
nonmutating set {
175-
// update the local value
176-
data.mode = .local(newValue)
177-
data.objectWillChange.send()
178-
// if we are bound, update the view model, which will send an update to the backend
179-
if let bindingName {
180-
let encoder = FragmentEncoder()
181-
// todo: if encoding fails, what should happen?
182-
try! newValue.encode(to: encoder)
183-
data.skipNextUpdate = true
184-
liveViewModel.setBinding(bindingName, to: encoder.unwrap() as Any)
185-
}
161+
data.value = newValue
162+
data.localValueChanged.send() // make sure to send a signal when the client changes the value.
186163
}
187164
}
188165

@@ -200,7 +177,20 @@ public struct LiveBinding<Value: Codable> {
200177

201178
extension LiveBinding: DynamicProperty {
202179
public func update() {
203-
// don't need to do anything, just need to conform to DynamicProperty to make sure our @StateObject gets installed
180+
switch bindingNameSource {
181+
case let .attribute(attribute):
182+
data.bind(
183+
to: liveViewModel,
184+
bindingName: bindingName,
185+
debounce: (try? element.attributeValue(Double.self, for: .init(namespace: attribute.name, name: "debounce"))) ?? 0
186+
)
187+
default:
188+
data.bind(
189+
to: liveViewModel,
190+
bindingName: bindingName,
191+
debounce: 0
192+
)
193+
}
204194
}
205195
}
206196

@@ -214,52 +204,46 @@ extension LiveBinding {
214204

215205
extension LiveBinding {
216206
class Data: ObservableObject {
217-
var mode: Mode = .uninitialized
218-
var skipNextUpdate = false
219-
private var serverCancellable: AnyCancellable?
220-
private var clientCancellable: AnyCancellable?
207+
/// The current value of the binding.
208+
var value: Value?
221209

222-
func getValue(from liveViewModel: LiveViewModel, bindingName: String) throws -> Value {
223-
switch mode {
224-
case .uninitialized:
225-
serverCancellable = liveViewModel.bindingUpdatedByServer
226-
.filter { $0.0 == bindingName }
227-
.sink { [unowned self] _ in
228-
self.mode = .needsUpdateFromViewModel
229-
self.objectWillChange.send()
230-
}
231-
clientCancellable = liveViewModel.bindingUpdatedByClient
232-
.filter { $0.0 == bindingName }
233-
.sink { [unowned self] _ in
234-
if self.skipNextUpdate {
235-
self.skipNextUpdate = false
236-
} else {
237-
self.mode = .needsUpdateFromViewModel
238-
self.objectWillChange.send()
239-
}
240-
}
241-
return try getValueFromViewModel(liveViewModel, bindingName: bindingName)
242-
case .needsUpdateFromViewModel:
243-
return try getValueFromViewModel(liveViewModel, bindingName: bindingName)
244-
case .local(let value):
245-
return value
246-
}
247-
}
210+
/// A publisher used to track when the value is changed by the client.
211+
let localValueChanged: ObjectWillChangePublisher = .init()
248212

249-
private func getValueFromViewModel(_ liveViewModel: LiveViewModel, bindingName: String) throws -> Value {
250-
guard let defaultPayload = liveViewModel.bindingValues[bindingName] else {
251-
throw LiveBindingError.missingDefaultPayload(bindingName)
252-
}
253-
// todo: if decoding fails, what should happen?
254-
let value = try Value(from: FragmentDecoder(data: defaultPayload))
255-
mode = .local(value)
256-
return value
257-
}
213+
var valueCancellable: AnyCancellable?
214+
var cancellable: AnyCancellable?
258215

259-
enum Mode {
260-
case uninitialized
261-
case needsUpdateFromViewModel
262-
case local(Value)
216+
func bind(to model: LiveViewModel, bindingName: String?, debounce: Double) {
217+
if let bindingName {
218+
if valueCancellable == nil && cancellable == nil,
219+
let defaultValue = model.bindingValues[bindingName]
220+
{
221+
let decoder = FragmentDecoder(data: defaultValue)
222+
value = try! Value(from: decoder)
223+
}
224+
// Watch for local changes to the value.
225+
valueCancellable = self.localValueChanged
226+
.debounce(for: .init(debounce), scheduler: RunLoop.current)
227+
.sink { [weak self, weak model] _ in
228+
guard let value = self?.value else { return }
229+
let encoder = FragmentEncoder()
230+
try! value.encode(to: encoder)
231+
guard let encodedValue = encoder.unwrap()
232+
else { return }
233+
model?.setBinding(bindingName, to: encodedValue)
234+
}
235+
// Watch for changes from the server.
236+
cancellable = model.bindingUpdatedByServer
237+
.filter({ $0.0 == bindingName })
238+
.sink(receiveValue: { [weak self] newValue in
239+
let decoder = FragmentDecoder(data: newValue.1)
240+
self?.value = try! Value(from: decoder)
241+
self?.objectWillChange.send()
242+
})
243+
} else {
244+
valueCancellable = nil
245+
cancellable = nil
246+
}
263247
}
264248
}
265249
}

Sources/LiveViewNative/Registries/CustomRegistry.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,11 @@ public protocol CustomRegistry<Root> {
135135
/// - Parameter error: The error of the view is reporting.
136136
@ViewBuilder
137137
static func errorView(for error: Error) -> ErrorView
138+
139+
static func setBindingValue(_ value: Any, forKey key: String, in scope: String?) throws
140+
static func bindingValue(forKey key: String, in scope: String?) -> Any?
141+
static var globalBindings: Set<String> { get }
142+
static func registerGlobalBinding(_ key: String)
138143
}
139144

140145
extension CustomRegistry where LoadingView == Never {
@@ -151,6 +156,21 @@ extension CustomRegistry where ErrorView == Never {
151156
}
152157
}
153158

159+
extension CustomRegistry {
160+
public static func setBindingValue(_ value: Any, forKey key: String, in scope: String?) throws {
161+
UserDefaults(suiteName: scope)?.setValue(value, forKey: key)
162+
}
163+
public static func bindingValue(forKey key: String, in scope: String?) -> Any? {
164+
UserDefaults(suiteName: scope)?.value(forKey: key)
165+
}
166+
public static var globalBindings: Set<String> {
167+
Set(UserDefaults.standard.stringArray(forKey: "_lvn_global_bindings") ?? [])
168+
}
169+
public static func registerGlobalBinding(_ key: String) {
170+
UserDefaults.standard.setValue(Array<String>(globalBindings.union([key])), forKey: "_lvn_global_bindings")
171+
}
172+
}
173+
154174
/// The empty registry is the default ``CustomRegistry`` implementation that does not provide any views or modifiers.
155175
public struct EmptyRegistry {
156176
}

Sources/LiveViewNative/ViewModel.swift

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import Foundation
99
import Combine
10+
import SwiftUI
1011
import LiveViewNativeCore
1112

1213
/// The working-copy data model for a ``LiveView``.
@@ -16,10 +17,40 @@ public class LiveViewModel: ObservableObject {
1617
private var forms = [String: FormModel]()
1718
var cachedNavigationTitle: NavigationTitleModifier?
1819

20+
private(set) var bindings = [String: NativeBinding]()
21+
private(set) var bindingScope: String?
22+
1923
private(set) var bindingValues = [String: Any]()
2024
let bindingUpdatedByServer = PassthroughSubject<(String, Any), Never>()
2125
let bindingUpdatedByClient = PassthroughSubject<(String, Any), Never>()
2226

27+
let bindingValue: (String, String?) -> Any?
28+
let setBindingValue: (Any, String, String?) throws -> ()
29+
let globalBindings: () -> Set<String>
30+
let registerGlobalBinding: (String) -> ()
31+
32+
init(
33+
bindingValue: @escaping (String, String?) -> Any?,
34+
setBindingValue: @escaping (Any, String, String?) throws -> (),
35+
globalBindings: @escaping () -> Set<String>,
36+
registerGlobalBinding: @escaping (String) -> ()
37+
) {
38+
self.bindingValue = bindingValue
39+
self.setBindingValue = setBindingValue
40+
self.globalBindings = globalBindings
41+
self.registerGlobalBinding = registerGlobalBinding
42+
}
43+
44+
struct NativeBinding {
45+
let persist: PersistenceMode
46+
47+
enum PersistenceMode: String {
48+
case scoped
49+
case global
50+
case none
51+
}
52+
}
53+
2354
/// Get or create a ``FormModel`` for the given `<live-form>`.
2455
///
2556
/// - Important: The element parameter must be the form element. To get the form model for an element within a form, use the ``LiveContext`` or the `\.formModel` environment value.
@@ -51,15 +82,69 @@ public class LiveViewModel: ObservableObject {
5182
}
5283

5384
func updateBindings(payload: Payload) {
54-
for (key, value) in payload {
55-
bindingValues[key] = value
56-
bindingUpdatedByServer.send((key, value))
85+
if let data = payload["data"] as? [String:Any] {
86+
let animation = (payload["animation"] as? [String:Any])
87+
.flatMap({ try? JSONSerialization.data(withJSONObject: $0) })
88+
.flatMap({ try? makeJSONDecoder().decode(Animation.self, from: $0) })
89+
withAnimation(animation) {
90+
for (key, value) in data {
91+
bindingValues[key] = value
92+
bindingUpdatedByServer.send((key, value))
93+
storeBinding(key, value: value)
94+
}
95+
}
5796
}
5897
}
5998

6099
func setBinding(_ name: String, to encodedValue: Any) {
61100
bindingValues[name] = encodedValue
62101
bindingUpdatedByClient.send((name, encodedValue))
102+
storeBinding(name, value: encodedValue)
103+
}
104+
105+
func storeBinding(_ key: String, value: Any) {
106+
if let options = bindings[key] {
107+
switch options.persist {
108+
case .scoped:
109+
try? setBindingValue(value, key, self.bindingScope)
110+
case .global:
111+
try? setBindingValue(value, key, nil)
112+
case .none:
113+
break
114+
}
115+
}
116+
}
117+
118+
func initBindings(payload: Payload) {
119+
self.bindingScope = payload["scope"] as? String
120+
for (key, options) in (payload["bindings"] as? [String:[String:Any]] ?? [:]) {
121+
let binding = NativeBinding(
122+
persist: (options["persist"] as? String).flatMap(NativeBinding.PersistenceMode.init(rawValue:)) ?? .none
123+
)
124+
self.bindings[key] = binding
125+
126+
let defaultValue = options["default"]
127+
128+
switch binding.persist {
129+
case .scoped:
130+
if let value = bindingValue(key, self.bindingScope) {
131+
self.bindingValues[key] = value
132+
setBinding(key, to: value)
133+
} else {
134+
self.bindingValues[key] = defaultValue
135+
}
136+
case .global:
137+
registerGlobalBinding(key)
138+
if let value = bindingValue(key, nil) {
139+
self.bindingValues[key] = value
140+
setBinding(key, to: value)
141+
} else {
142+
self.bindingValues[key] = defaultValue
143+
}
144+
case .none:
145+
self.bindingValues[key] = defaultValue
146+
}
147+
}
63148
}
64149
}
65150

Tests/RenderingTests/assertMatch.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,9 @@ extension XCTestCase {
100100
document[document.root()].children(),
101101
coordinator: session.rootCoordinator,
102102
url: session.url
103-
).environment(\.coordinatorEnvironment, CoordinatorEnvironment(session.rootCoordinator, document: document))
103+
)
104+
.environment(\.coordinatorEnvironment, CoordinatorEnvironment(session.rootCoordinator, document: document))
105+
.environmentObject(LiveViewModel(bindingValue: EmptyRegistry.bindingValue, setBindingValue: EmptyRegistry.setBindingValue, globalBindings: { EmptyRegistry.globalBindings }, registerGlobalBinding: EmptyRegistry.registerGlobalBinding))
104106

105107
let modifyViewForRender: (any View) -> any View = {
106108
if useDrawingGroup {

0 commit comments

Comments
 (0)