mirror of
https://github.com/rive-app/rive-ios.git
synced 2026-01-18 17:11:28 +01:00
feat(ios): add support for data binding
Adds support for data binding to the iOS runtime.
## Changes
### RiveFile
Updated to match c++ runtime support for getting view models by index, name, and default for an artboard.
### RiveArtboard
Updated to match c++ runtime support for binding a view model instance.
### RiveStateMachine
Updated to match c++ runtime support for binding a view model instance. Additionally, this holds a strong reference to the view model instance that is currently bound (which aids in knowing which state machine should have its property listeners called after advance).
### RiveModel
Adds support for enabling / disabling autoBind functionality. This has to be done _after_ initialization, since Swift default arguments don't bridge to ObjC, and I didn't want to add n number of new initializers. When enabled, a callback will be called with the bound instance.
## New Types
Currently, type names are similar to those found within the c++ runtime. They're kind of ugly when it comes to ObjC, but the Swift names are cleaned up (e.g `RiveViewModelRuntime.Instance` instead of `RiveViewModelInstanceRuntime`).
### RiveDataBindingViewModel
The bridging type between the c++ runtime equivalent (`rive::ViewModelRuntime`) and ObjC.
### RiveDataBindingViewModelInstance
Swift: `RiveDataBindingViewModel.Instance`
The bridging type between the c++ runtime equivalent (`rive::ViewModelInstanceRuntime`) and ObjC.
### RiveDataBindingViewModelInstanceProperty
Swift: `RiveDataBindingViewModel.Instance.Property`
The superclass bridging type between the c++ runtime equivalent (`rive::ViewModelInstanceValueRuntime`) and ObjC.
### Subclasses
- Strings: `RiveDataBindingViewModelInstanceStringProperty`
- Swift: `RiveDataBindingViewModel.Instance.StringProperty`
- Numbers: `RiveDataBindingViewModelInstanceNumberProperty`
- Swift: `RiveDataBindingViewModel.Instance.NumberProperty`
- Boolean: `RiveDataBindingViewModelInstanceBooleanProperty`
- Swift: `RiveDataBindingViewModel.Instance.BooleanProperty`
- Color: `RiveDataBindingViewModelInstanceColorProperty`
- Swift: `RiveDataBindingViewModel.Instance.ColorProperty`
- Enum: `RiveDataBindingViewModelInstanceEnumProperty`
- Swift: `RiveDataBindingViewModel.Instance.EnumProperty`
- Trigger: `RiveDataBindingViewModelInstanceTriggerProperty`
- Swift: `RiveDataBindingViewModel.Instance.TriggerProperty`
### Observability
KVO has (temporarily) been disabled, "forcing" observability to be done through the explicit `addListener` and `removeListener` functions of each property type. `removeListener` exists on the superclass, however the matching `addListener` functions exist on each property type, primarily due to the fact that there is no "pretty" way of handing these functions as generics (where only the value type of the property differs for each callback) other than utilizing `id`. Listeners exist within the context of a property; however, an instance will use its (cached) properties to request that it calls its listeners.
## Details
### Caching properties
When a property getter is called on a view model instance, a cache is first checked for the property, otherwise a new one is returned and cached. This helps with ensuring we are using the same pointer under-the-hood. This isn't strictly necessary (per testing) but does allow for some niceties, such as not having to explicitly maintain a strong reference to a property if you want to just observe: `instance.triggerProperty(from: "...").addListener { ... }`.
Properties are cached for the first component when parsing the path for the property. In the instance that a path with > 1 component is provided to a property (e.g `instance.triggerProperty(from: 'nested/trigger")`, then the appropriate nested view models are created, and the property is associated with the correct view model (e.g above, the view model `nested will be cached, and the trigger property will be cached within that view model).
### Caching nested view models
Similar to caching properties, when a (nested) view model getter is called on a view model instance, a cache is first checked for the view model, otherwise a new one is returned and cached. This helps ensure that when (re)binding an instance to a (new) state machine or artboard, that all properties within that view model still have its listeners attached, regardless of how nested a path goes. This will likely help with implementing replacing instance functionality in v2.
## Testing
Unit tests have been added for data binding, attempting to capture high-level expectations rather than totally verifying the c++ runtime expectations. This includes things like: all getters returning object-or-nil, listeners being called with the correct values, autoBinding, property and view model caching, etc. The `.riv` file for unit tests is the same one that is used within the Example app.
## Example
A new `.riv` file has been added that shows basic usage of each property type (including observability). The same `.riv` file is used in the unit tests.
Diffs=
b2f1db73d7 feat(ios): add support for data binding (#9227)
Co-authored-by: David Skuza <david@rive.app>
This commit is contained in:
BIN
Example-iOS/Assets/data_binding_test.riv
Normal file
BIN
Example-iOS/Assets/data_binding_test.riv
Normal file
Binary file not shown.
@@ -325,6 +325,13 @@
|
||||
F22138832CCBEDD500A25BA7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F22138822CCBEDD500A25BA7 /* Assets.xcassets */; };
|
||||
F22138862CCBEDD500A25BA7 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F22138852CCBEDD500A25BA7 /* Preview Assets.xcassets */; };
|
||||
F221388B2CCBEDE100A25BA7 /* marty.riv in Resources */ = {isa = PBXBuildFile; fileRef = 83C89ACE2988709400044C17 /* marty.riv */; };
|
||||
F22CF1AA2D36BFFF00D35779 /* data_binding_test.riv in Resources */ = {isa = PBXBuildFile; fileRef = F22CF1A92D36BFFF00D35779 /* data_binding_test.riv */; };
|
||||
F22CF1AB2D380E1200D35779 /* data_binding_test.riv in Resources */ = {isa = PBXBuildFile; fileRef = F22CF1A92D36BFFF00D35779 /* data_binding_test.riv */; };
|
||||
F22CF1AC2D380E1300D35779 /* data_binding_test.riv in Resources */ = {isa = PBXBuildFile; fileRef = F22CF1A92D36BFFF00D35779 /* data_binding_test.riv */; };
|
||||
F22CF1AD2D380E1400D35779 /* data_binding_test.riv in Resources */ = {isa = PBXBuildFile; fileRef = F22CF1A92D36BFFF00D35779 /* data_binding_test.riv */; };
|
||||
F22CF1AE2D380E1500D35779 /* data_binding_test.riv in Resources */ = {isa = PBXBuildFile; fileRef = F22CF1A92D36BFFF00D35779 /* data_binding_test.riv */; };
|
||||
F22CF1AF2D380E1600D35779 /* data_binding_test.riv in Resources */ = {isa = PBXBuildFile; fileRef = F22CF1A92D36BFFF00D35779 /* data_binding_test.riv */; };
|
||||
F259E5832D35B7FA00B78FEF /* DataBindingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F259E5822D35B7FA00B78FEF /* DataBindingView.swift */; };
|
||||
F26E20AC2CF0E21000130111 /* streaming.riv in Resources */ = {isa = PBXBuildFile; fileRef = F26E20A92CF0E21000130111 /* streaming.riv */; };
|
||||
F2AB95D12CF0EB800055376E /* streaming.riv in Resources */ = {isa = PBXBuildFile; fileRef = F26E20A92CF0E21000130111 /* streaming.riv */; };
|
||||
F2AC7F3C2CCBEEDC00E3ED79 /* RiveRuntime.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 04A8F6BD26452E10002C909A /* RiveRuntime.framework */; };
|
||||
@@ -525,6 +532,8 @@
|
||||
F22138822CCBEDD500A25BA7 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
F22138852CCBEDD500A25BA7 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
||||
F22138872CCBEDD500A25BA7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
F22CF1A92D36BFFF00D35779 /* data_binding_test.riv */ = {isa = PBXFileReference; lastKnownFileType = file; path = data_binding_test.riv; sourceTree = "<group>"; };
|
||||
F259E5822D35B7FA00B78FEF /* DataBindingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataBindingView.swift; sourceTree = "<group>"; };
|
||||
F26E20A92CF0E21000130111 /* streaming.riv */ = {isa = PBXFileReference; lastKnownFileType = file; path = streaming.riv; sourceTree = "<group>"; };
|
||||
F2B7F2F72C5AC09200F47FBC /* RealityKitContent */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = RealityKitContent; sourceTree = "<group>"; };
|
||||
F2C623352C874E3A0006E0CA /* fallback_fonts.riv */ = {isa = PBXFileReference; lastKnownFileType = file; path = fallback_fonts.riv; sourceTree = "<group>"; };
|
||||
@@ -667,6 +676,7 @@
|
||||
C9696B0E24FC6FD10041502A /* Assets */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F22CF1A92D36BFFF00D35779 /* data_binding_test.riv */,
|
||||
F26E20A92CF0E21000130111 /* streaming.riv */,
|
||||
2E974F942CB3509200642588 /* layout_test.riv */,
|
||||
F2C623352C874E3A0006E0CA /* fallback_fonts.riv */,
|
||||
@@ -752,6 +762,7 @@
|
||||
0490914B2BC8326E00F2C12B /* SwiftAudioAssets.swift */,
|
||||
049091622BC948AA00F2C12B /* SwiftOutOfBandAudioAssets.swift */,
|
||||
F2C623382C874E690006E0CA /* SwiftFallbackFonts.swift */,
|
||||
F259E5822D35B7FA00B78FEF /* DataBindingView.swift */,
|
||||
);
|
||||
path = SwiftUI;
|
||||
sourceTree = "<group>";
|
||||
@@ -1065,6 +1076,7 @@
|
||||
040553902B7A274B008F076A /* hero_editor.riv in Resources */,
|
||||
040553912B7A274B008F076A /* watch_v1.riv in Resources */,
|
||||
040553922B7A274B008F076A /* leg_day_events_example.riv in Resources */,
|
||||
F22CF1AC2D380E1300D35779 /* data_binding_test.riv in Resources */,
|
||||
040553932B7A274B008F076A /* play_button_event_example.riv in Resources */,
|
||||
040553942B7A274B008F076A /* switch_event_example.riv in Resources */,
|
||||
040553952B7A274B008F076A /* magic_8-ball_v2.riv in Resources */,
|
||||
@@ -1143,6 +1155,7 @@
|
||||
040554032B7A2858008F076A /* off_road_car_blog.riv in Resources */,
|
||||
040554042B7A2858008F076A /* Main.storyboard in Resources */,
|
||||
040554052B7A2858008F076A /* skills.riv in Resources */,
|
||||
F22CF1AB2D380E1200D35779 /* data_binding_test.riv in Resources */,
|
||||
040554062B7A2858008F076A /* artboard_animations.riv in Resources */,
|
||||
040554072B7A2858008F076A /* trailblaze.riv in Resources */,
|
||||
049091582BC832B000F2C12B /* ping_pong_audio_demo.riv in Resources */,
|
||||
@@ -1198,6 +1211,7 @@
|
||||
04E51C492A151C230075E473 /* hero_editor.riv in Resources */,
|
||||
04E51C4A2A151C230075E473 /* watch_v1.riv in Resources */,
|
||||
04E51C4B2A151C230075E473 /* leg_day_events_example.riv in Resources */,
|
||||
F22CF1AD2D380E1400D35779 /* data_binding_test.riv in Resources */,
|
||||
04E51C4C2A151C230075E473 /* play_button_event_example.riv in Resources */,
|
||||
04E51C4D2A151C230075E473 /* switch_event_example.riv in Resources */,
|
||||
04E51C4E2A151C230075E473 /* magic_8-ball_v2.riv in Resources */,
|
||||
@@ -1303,6 +1317,7 @@
|
||||
046AFA712673AF04004ED497 /* blendmodes.riv in Resources */,
|
||||
042C88EC2644447500E7DBB2 /* vader.riv in Resources */,
|
||||
E5964AB12A9CDB2100140479 /* rating_animation.riv in Resources */,
|
||||
F22CF1AA2D36BFFF00D35779 /* data_binding_test.riv in Resources */,
|
||||
0453FCB42B012D17001185C8 /* picture-47982.jpeg in Resources */,
|
||||
83C89AD1298870A700044C17 /* paper.riv in Resources */,
|
||||
C9C73E9E24FC471E00EF9516 /* Assets.xcassets in Resources */,
|
||||
@@ -1327,6 +1342,7 @@
|
||||
F221388B2CCBEDE100A25BA7 /* marty.riv in Resources */,
|
||||
F22138862CCBEDD500A25BA7 /* Preview Assets.xcassets in Resources */,
|
||||
F26E20AC2CF0E21000130111 /* streaming.riv in Resources */,
|
||||
F22CF1AE2D380E1500D35779 /* data_binding_test.riv in Resources */,
|
||||
F22138832CCBEDD500A25BA7 /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -1338,6 +1354,7 @@
|
||||
F2C852F22CD19F6A00F0D81F /* marty.riv in Resources */,
|
||||
F2C852DB2CD1772400F0D81F /* Preview Assets.xcassets in Resources */,
|
||||
F2AB95D12CF0EB800055376E /* streaming.riv in Resources */,
|
||||
F22CF1AF2D380E1600D35779 /* data_binding_test.riv in Resources */,
|
||||
F2C852D82CD1772400F0D81F /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -1417,6 +1434,7 @@
|
||||
C324DB5D280728690060589F /* RiveButton.swift in Sources */,
|
||||
C9C73E9824FC471E00EF9516 /* AppDelegate.swift in Sources */,
|
||||
0490914D2BC8326E00F2C12B /* SwiftAudioAssets.swift in Sources */,
|
||||
F259E5832D35B7FA00B78FEF /* DataBindingView.swift in Sources */,
|
||||
C3E2B58C2833ECFE00A8651B /* SwiftCannonGame.swift in Sources */,
|
||||
C9CB2F13264C92D200E7FF0D /* SwiftWidgets.swift in Sources */,
|
||||
042C888E2644230700E7DBB2 /* utility.swift in Sources */,
|
||||
@@ -1756,7 +1774,7 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"Source/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
DEVELOPMENT_TEAM = NJ3JMFUNS9;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = Source/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
@@ -1780,7 +1798,7 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"Source/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
DEVELOPMENT_TEAM = NJ3JMFUNS9;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = Source/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
|
||||
179
Example-iOS/Source/Examples/SwiftUI/DataBindingView.swift
Normal file
179
Example-iOS/Source/Examples/SwiftUI/DataBindingView.swift
Normal file
@@ -0,0 +1,179 @@
|
||||
//
|
||||
// DataBindingView.swift
|
||||
// Example (iOS)
|
||||
//
|
||||
// Created by David Skuza on 1/13/25.
|
||||
// Copyright © 2025 Rive. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import RiveRuntime
|
||||
|
||||
private class DataBindingViewModel: RiveViewModel {
|
||||
// A strong reference to an instance must be made in order
|
||||
// to properly update an instance's properties. This instance
|
||||
// must be the same that was bound to an artboard and/or state machine.
|
||||
private(set) var dataBindingInstance: RiveDataBindingViewModel.Instance?
|
||||
|
||||
// Properties get cached as they are created, so returning a property
|
||||
// by path from the same instance will return the same object.
|
||||
// This way, no strong reference has to be kept. This is the same
|
||||
// for all property types.
|
||||
var stringProperty: RiveDataBindingViewModel.Instance.StringProperty? {
|
||||
return dataBindingInstance?.stringProperty(fromPath: "String")
|
||||
}
|
||||
|
||||
var numberProperty: RiveDataBindingViewModel.Instance.NumberProperty? {
|
||||
return dataBindingInstance?.numberProperty(fromPath: "Number")
|
||||
}
|
||||
|
||||
var booleanProperty: RiveDataBindingViewModel.Instance.BooleanProperty? {
|
||||
return dataBindingInstance?.booleanProperty(fromPath: "Boolean")
|
||||
}
|
||||
|
||||
var colorProperty: RiveDataBindingViewModel.Instance.ColorProperty? {
|
||||
return dataBindingInstance?.colorProperty(fromPath: "Color")
|
||||
}
|
||||
|
||||
var enumProperty: RiveDataBindingViewModel.Instance.EnumProperty? {
|
||||
return dataBindingInstance?.enumProperty(fromPath: "Enum")
|
||||
}
|
||||
|
||||
var viewModelProperty: RiveDataBindingViewModel.Instance? {
|
||||
return dataBindingInstance?.viewModelInstanceProperty(fromPath: "Nested")
|
||||
}
|
||||
|
||||
init(fileName: String) {
|
||||
super.init(fileName: fileName)
|
||||
|
||||
riveModel?.enableAutoBind { [weak self] instance in
|
||||
guard let self else { return }
|
||||
// Capture the new instance so any new properties
|
||||
// can be created from the new instance.
|
||||
dataBindingInstance = instance
|
||||
|
||||
stringProperty?.addListener { [weak self] value in
|
||||
guard let self, let stringProperty else { return }
|
||||
print("Updated value: \(stringProperty.value)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func triggerProperty(name: String) -> RiveDataBindingViewModel.Instance.TriggerProperty? {
|
||||
return dataBindingInstance?.triggerProperty(fromPath: name)
|
||||
}
|
||||
}
|
||||
|
||||
struct DataBindingView: DismissableView {
|
||||
var dismiss: () -> Void = {}
|
||||
|
||||
@StateObject private var riveViewModel = DataBindingViewModel(fileName: "data_binding_test")
|
||||
@State var isDismissing = false
|
||||
|
||||
var body: some View {
|
||||
riveViewModel
|
||||
.view()
|
||||
.onAppear {
|
||||
// Make sure an instance is bound. If so, start looping every 500ms
|
||||
// and randomize the values of all instance properties.
|
||||
guard let instance = riveViewModel.dataBindingInstance else { return }
|
||||
loop(instance)
|
||||
}.onDisappear {
|
||||
isDismissing = true
|
||||
}
|
||||
}
|
||||
|
||||
private func loop(_ instance: RiveDataBindingViewModel.Instance) {
|
||||
updateString()
|
||||
updateNumber()
|
||||
updateBoolean()
|
||||
updateColor()
|
||||
updateEnum()
|
||||
updateNestedViewModel()
|
||||
updateTrigger()
|
||||
|
||||
// Manually advance the Rive view since it is not playing.
|
||||
// When a Rive view is playing, this is handled for you.
|
||||
riveViewModel.riveView?.advance(delta: 0)
|
||||
|
||||
if !isDismissing {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) {
|
||||
loop(instance)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateString() {
|
||||
guard let property = riveViewModel.stringProperty else { return }
|
||||
let team = [
|
||||
"Adam",
|
||||
"David",
|
||||
"Erik",
|
||||
"Gordon",
|
||||
"Tod"
|
||||
]
|
||||
let string = team.randomElement()!
|
||||
property.value = string
|
||||
}
|
||||
|
||||
private func updateNumber() {
|
||||
guard let property = riveViewModel.numberProperty else { return }
|
||||
let number = Int.random(in: 1...10)
|
||||
property.value = Float(number)
|
||||
}
|
||||
|
||||
private func updateBoolean() {
|
||||
guard let property = riveViewModel.booleanProperty else { return }
|
||||
let value = property.value
|
||||
property.value = !value
|
||||
}
|
||||
|
||||
private func updateColor() {
|
||||
guard let property = riveViewModel.colorProperty else { return }
|
||||
let colors: [UIColor] = [
|
||||
.black,
|
||||
.blue,
|
||||
.brown,
|
||||
.cyan,
|
||||
.gray,
|
||||
.green,
|
||||
.orange,
|
||||
.red,
|
||||
.yellow
|
||||
]
|
||||
property.value = colors.randomElement()!
|
||||
}
|
||||
|
||||
private func updateEnum() {
|
||||
guard let property = riveViewModel.enumProperty else { return }
|
||||
let random = property.values.randomElement()!
|
||||
property.value = random
|
||||
}
|
||||
|
||||
private func updateNestedViewModel() {
|
||||
guard let nested = riveViewModel.viewModelProperty,
|
||||
let property = nested.stringProperty(fromPath: "String")
|
||||
else { return }
|
||||
let team = [
|
||||
"Adam",
|
||||
"David",
|
||||
"Erik",
|
||||
"Gordon",
|
||||
"Tod"
|
||||
]
|
||||
let string = team.randomElement()!
|
||||
property.value = string
|
||||
}
|
||||
|
||||
private func updateTrigger() {
|
||||
let triggers = [
|
||||
"Trigger Red",
|
||||
"Trigger Green",
|
||||
"Trigger Blue"
|
||||
]
|
||||
let trigger = triggers.randomElement()!
|
||||
guard let property = riveViewModel.triggerProperty(name: trigger) else { return }
|
||||
property.trigger()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ class ExamplesMasterTableViewController: UITableViewController {
|
||||
// MARK: SwiftUI View Examples
|
||||
/// Made from custom `Views`
|
||||
private lazy var swiftViews: [(String, AnyView)] = [
|
||||
("Data Binding", typeErased(dismissableView: DataBindingView())),
|
||||
("Touch Events!", typeErased(dismissableView: SwiftTouchEvents())),
|
||||
("Widget Collection", typeErased(dismissableView: SwiftWidgets())),
|
||||
("Animation Player", typeErased(dismissableView: SwiftSimpleAnimation())),
|
||||
|
||||
Reference in New Issue
Block a user