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:
dskuza
2025-04-15 21:45:39 +00:00
parent d48d3d8448
commit 4110f9f3f6
33 changed files with 3566 additions and 9 deletions

Binary file not shown.

View File

@@ -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;

View 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()
}
}

View File

@@ -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())),