feat(apple): add support for data binding list properties (#9936) d2997eeef4

Co-authored-by: David Skuza <david@rive.app>
This commit is contained in:
dskuza
2025-07-08 20:13:34 +00:00
parent fb0403f981
commit aadb3d3d52
10 changed files with 336 additions and 21 deletions

View File

@@ -1 +1 @@
7379bdd49fc2b6c2a209b447b1d7d74e730f41df
d2997eeef48f74a50e6eda2d5691e295ad328f51

View File

@@ -18,6 +18,7 @@ NS_ASSUME_NONNULL_BEGIN
@class RiveDataBindingViewModelInstanceEnumProperty;
@class RiveDataBindingViewModelInstanceTriggerProperty;
@class RiveDataBindingViewModelInstanceImageProperty;
@class RiveDataBindingViewModelInstanceListProperty;
@class RiveDataBindingViewModelInstancePropertyData;
/// An object that represents an instance of a view model, used to update
@@ -87,7 +88,7 @@ NS_SWIFT_NAME(RiveDataBindingViewModel.Instance)
/// will be cached, and calling this function again with the same path is
/// guaranteed to return the same object.
///
/// - Parameter path: The path to the number property.
/// - Parameter path: The path to the boolean property.
///
/// - Returns: The property if it exists at the supplied path, otherwise nil.
- (nullable RiveDataBindingViewModelInstanceBooleanProperty*)
@@ -100,7 +101,7 @@ NS_SWIFT_NAME(RiveDataBindingViewModel.Instance)
/// will be cached, and calling this function again with the same path is
/// guaranteed to return the same object.
///
/// - Parameter path: The path to the number property.
/// - Parameter path: The path to the color property.
///
/// - Returns: The property if it exists at the supplied path, otherwise nil.
- (nullable RiveDataBindingViewModelInstanceColorProperty*)
@@ -113,7 +114,7 @@ NS_SWIFT_NAME(RiveDataBindingViewModel.Instance)
/// will be cached, and calling this function again with the same path is
/// guaranteed to return the same object.
///
/// - Parameter path: The path to the number property.
/// - Parameter path: The path to the enum property.
///
/// - Returns: The property if it exists at the supplied path, otherwise nil.
- (nullable RiveDataBindingViewModelInstanceEnumProperty*)enumPropertyFromPath:
@@ -126,7 +127,7 @@ NS_SWIFT_NAME(RiveDataBindingViewModel.Instance)
/// will be cached, and calling this function again with the same path is
/// guaranteed to return the same object.
///
/// - Parameter path: The path to the number property.
/// - Parameter path: The path to the view model instance property.
///
/// - Returns: The property if it exists at the supplied path, otherwise nil.
- (nullable RiveDataBindingViewModelInstance*)viewModelInstancePropertyFromPath:
@@ -154,7 +155,7 @@ NS_SWIFT_NAME(RiveDataBindingViewModel.Instance)
/// will be cached, and calling this function again with the same path is
/// guaranteed to return the same object.
///
/// - Parameter path: The path to the number property.
/// - Parameter path: The path to the trigger property.
///
/// - Returns: The property if it exists at the supplied path, otherwise nil.
- (nullable RiveDataBindingViewModelInstanceTriggerProperty*)
@@ -167,12 +168,25 @@ NS_SWIFT_NAME(RiveDataBindingViewModel.Instance)
/// will be cached, and calling this function again with the same path is
/// guaranteed to return the same object.
///
/// - Parameter path: The path to the number property.
/// - Parameter path: The path to the image property.
///
/// - Returns: The property if it exists at the supplied path, otherwise nil.
- (nullable RiveDataBindingViewModelInstanceImageProperty*)
imagePropertyFromPath:(NSString*)path;
/// Gets a list property in the view model instance.
///
/// - Note: Unlike a `RiveViewModel.Instance`, a strong reference to this type
/// does not have to be made. If the property exists, the underlying property
/// will be cached, and calling this function again with the same path is
/// guaranteed to return the same object.
///
/// - Parameter path: The path to the list property.
///
/// - Returns: The property if it exists at the supplied path, otherwise nil.
- (nullable RiveDataBindingViewModelInstanceListProperty*)listPropertyFromPath:
(NSString*)path;
/// Calls all registered property listeners for the properties of the view model
/// instance.
- (void)updateListeners;

View File

@@ -347,6 +347,41 @@
return imageProperty;
}
- (RiveDataBindingViewModelInstanceListProperty*)listPropertyFromPath:
(NSString*)path
{
RiveDataBindingViewModelInstanceListProperty* cached;
if ((cached = [self
cachedPropertyFromPath:path
asClass:
[RiveDataBindingViewModelInstanceListProperty
class]]))
{
return cached;
}
auto list = _instance->propertyList(std::string([path UTF8String]));
if (list == nullptr)
{
[RiveLogger logWithViewModelInstance:self
listPropertyAtPath:path
found:NO];
return nil;
}
[RiveLogger logWithViewModelInstance:self
listPropertyAtPath:path
found:YES];
RiveDataBindingViewModelInstanceListProperty* listProperty =
[[RiveDataBindingViewModelInstanceListProperty alloc]
initWithList:list];
listProperty.valueDelegate = self;
[self cacheProperty:listProperty withPath:path];
return listProperty;
}
- (void)updateListeners
{
[_properties enumerateKeysAndObjectsUsingBlock:^(

View File

@@ -274,7 +274,7 @@ NS_SWIFT_NAME(RiveDataBindingViewModelInstance.ImageProperty)
- (instancetype)init NS_UNAVAILABLE;
- (void)setValue:(nonnull RiveRenderImage*)image;
- (void)setValue:(nullable RiveRenderImage*)image;
/// Adds a block as a listener, called when the property is triggered.
///
@@ -291,4 +291,78 @@ NS_SWIFT_NAME(RiveDataBindingViewModelInstance.ImageProperty)
@end
#pragma mark - List
typedef void (^RiveDataBindingViewModelInstanceListPropertyListener)(void)
NS_SWIFT_NAME(RiveDataBindingViewModelInstanceListProperty.Listener);
/// An object that represents a trigger property of a view model instance.
NS_SWIFT_NAME(RiveDataBindingViewModelInstance.ListProperty)
@interface RiveDataBindingViewModelInstanceListProperty
: RiveDataBindingViewModelInstanceProperty
/// The number of instances in the list.
@property(nonatomic, readonly) NSUInteger count;
- (instancetype)init NS_UNAVAILABLE;
/// Gets an instance at the specified index.
///
/// - Parameter index: The index of the instance to get.
///
/// - Returns: The instance at the specified index, or nil if the index is out
/// of range.
- (nullable RiveDataBindingViewModelInstance*)instanceAtIndex:(int)index
NS_SWIFT_NAME(instance(at:));
/// Adds an instance to the list.
///
/// - Parameter instance: The instance to add.
- (void)addInstance:(RiveDataBindingViewModelInstance*)instance
NS_SWIFT_NAME(append(_:));
/// Inserts an instance to the list at a given index.
///
/// - Parameter instance: The instance to add to the list.
/// - Parameter index: The index in the list at which to insert an instance.
/// This value must not be greater than the count of elements in the array.
///
/// - Returns: `true` if the instance has been added, otherwise `false`.
- (BOOL)insertInstance:(RiveDataBindingViewModelInstance*)instance
atIndex:(int)index NS_SWIFT_NAME(insert(_:at:));
/// Removes an instance from the list.
///
/// - Parameter instance: The instance to remove.
- (void)removeInstance:(RiveDataBindingViewModelInstance*)instance
NS_SWIFT_NAME(remove(_:));
/// Removes an instance at the specified index.
///
/// - Parameter index: The index of the instance to remove.
- (void)removeInstanceAtIndex:(int)index NS_SWIFT_NAME(remove(at:));
/// Swaps two instances in the list.
///
/// - Parameters:
/// - firstIndex: The index of the first instance.
/// - secondIndex: The index of the second instance.
- (void)swapInstanceAtIndex:(uint32_t)firstIndex
withInstanceAtIndex:(uint32_t)secondIndex NS_SWIFT_NAME(swap(at:with:));
/// Adds a block as a listener, called when there is a change to the list.
///
/// - Note: The property can be triggered either explicitly by the developer,
/// or as a result of a change in a state machine.
///
/// - Parameter listener: The block that will be called when there is a change
/// to the list.
///
/// - Returns: A UUID for the listener, used in conjunction with
/// `removeListener`.
- (NSUUID*)addListener:
(RiveDataBindingViewModelInstanceListPropertyListener)listener;
@end
NS_ASSUME_NONNULL_END

View File

@@ -555,6 +555,12 @@
- (void)setValue:(RiveRenderImage*)renderImage
{
if (renderImage == nil)
{
[RiveLogger logPropertyUpdated:self value:@"nil"];
_image->value(nullptr);
}
[RiveLogger logPropertyUpdated:self value:@"new image"];
_image->value([renderImage instance].get());
}
@@ -575,3 +581,114 @@
}
@end
#pragma mark - List
@implementation RiveDataBindingViewModelInstanceListProperty
{
rive::ViewModelInstanceListRuntime* _list;
NSMutableDictionary<NSValue*, RiveDataBindingViewModelInstance*>*
_instances;
}
- (instancetype)initWithList:(rive::ViewModelInstanceListRuntime*)list
{
if (self = [super initWithValue:list])
{
_list = list;
_instances = [NSMutableDictionary dictionary];
}
return self;
}
- (nullable RiveDataBindingViewModelInstance*)instanceAtIndex:(int)index
{
auto instance = _list->instanceAt(index);
if (instance == nullptr)
{
return nil;
}
NSValue* key = [NSValue valueWithPointer:instance];
RiveDataBindingViewModelInstance* cachedInstance = _instances[key];
if (cachedInstance != nil)
{
return cachedInstance;
}
RiveDataBindingViewModelInstance* newInstance =
[[RiveDataBindingViewModelInstance alloc] initWithInstance:instance];
_instances[key] = newInstance;
return newInstance;
}
- (void)addInstance:(RiveDataBindingViewModelInstance*)instance
{
auto i = [instance instance];
_list->addInstance(i);
NSValue* key = [NSValue valueWithPointer:i];
_instances[key] = instance;
}
- (BOOL)insertInstance:(RiveDataBindingViewModelInstance*)instance
atIndex:(int)index
{
auto i = [instance instance];
BOOL success = _list->addInstanceAt(i, index);
if (success)
{
NSValue* key = [NSValue valueWithPointer:i];
_instances[key] = instance;
}
return success;
}
- (void)removeInstance:(RiveDataBindingViewModelInstance*)instance
{
auto i = [instance instance];
_list->removeInstance(i);
NSValue* key = [NSValue valueWithPointer:i];
[_instances removeObjectForKey:key];
}
- (void)removeInstanceAtIndex:(int)index
{
auto i = _list->instanceAt(index);
if (i != nullptr)
{
NSValue* key = [NSValue valueWithPointer:i];
[_instances removeObjectForKey:key];
}
_list->removeInstanceAt(index);
}
- (void)swapInstanceAtIndex:(uint32_t)firstIndex
withInstanceAtIndex:(uint32_t)secondIndex
{
_list->swap(firstIndex, secondIndex);
}
- (NSUInteger)count
{
return _list->size();
}
- (NSUUID*)addListener:
(RiveDataBindingViewModelInstanceListPropertyListener)listener
{
return [super addListener:listener];
}
- (void)handleListeners
{
for (RiveDataBindingViewModelInstanceListPropertyListener listener in self
.listeners.allValues)
{
listener();
}
}
@end

View File

@@ -28,6 +28,7 @@ enum RiveLoggerDataBindingEvent {
case viewModelProperty(String, Bool)
case triggerProperty(String, Bool)
case imageProperty(String, Bool)
case listProperty(String, Bool)
}
enum Property: DataBindingEvent {
case propertyUpdated(String, String)
@@ -112,6 +113,11 @@ extension RiveLogger {
let message = message(instance: instance, for: "image", path: path, found: found)
dataBinding.debug("\(message)")
}
case .listProperty(let path, let found):
_log(event: event, level: .debug) {
let message = message(instance: instance, for: "list", path: path, found: found)
dataBinding.debug("\(message)")
}
}
}
@@ -184,6 +190,10 @@ extension RiveLogger {
Self.log(viewModelInstance: instance, event: .imageProperty(path, found))
}
@objc static func log(viewModelInstance instance: RiveDataBindingViewModel.Instance, listPropertyAtPath path: String, found: Bool) {
Self.log(viewModelInstance: instance, event: .listProperty(path, found))
}
// MARK: - Properties
@objc(logPropertyUpdated:value:) static func log(propertyUpdated property: RiveDataBindingViewModel.Instance.Property, value: String) {

View File

@@ -236,4 +236,8 @@ NS_ASSUME_NONNULL_BEGIN
- (instancetype)initWithImage:(rive::ViewModelInstanceAssetImageRuntime*)image;
@end
@interface RiveDataBindingViewModelInstanceListProperty ()
- (instancetype)initWithList:(rive::ViewModelInstanceListRuntime*)list;
@end
NS_ASSUME_NONNULL_END

Binary file not shown.

View File

@@ -83,51 +83,57 @@ class DataBindingTests: XCTestCase {
func test_viewModel_propertyCount_returnsCount() {
let viewModel = file.viewModelNamed("Test")!
XCTAssertEqual(viewModel.propertyCount, 10)
XCTAssertEqual(viewModel.propertyCount, 12)
}
func test_viewModel_properties_returnsAllProperties() {
let viewModel = file.viewModelNamed("Test")!
XCTAssertEqual(viewModel.properties.count, 10)
var data = viewModel.properties[0]
XCTAssertEqual(data.type, .list)
XCTAssertEqual(data.name, "List")
data = viewModel.properties[1]
XCTAssertEqual(data.type, .assetImage)
XCTAssertEqual(data.name, "Image")
data = viewModel.properties[2]
XCTAssertEqual(data.type, .viewModel)
XCTAssertEqual(data.name, "SecondNested")
data = viewModel.properties[1]
data = viewModel.properties[3]
XCTAssertEqual(data.type, .trigger)
XCTAssertEqual(data.name, "Trigger Blue")
data = viewModel.properties[2]
data = viewModel.properties[4]
XCTAssertEqual(data.type, .trigger)
XCTAssertEqual(data.name, "Trigger Green")
data = viewModel.properties[3]
data = viewModel.properties[5]
XCTAssertEqual(data.type, .trigger)
XCTAssertEqual(data.name, "Trigger Red")
data = viewModel.properties[4]
data = viewModel.properties[6]
XCTAssertEqual(data.type, .viewModel)
XCTAssertEqual(data.name, "Nested")
data = viewModel.properties[5]
data = viewModel.properties[7]
XCTAssertEqual(data.type, .enum)
XCTAssertEqual(data.name, "Enum")
data = viewModel.properties[6]
data = viewModel.properties[8]
XCTAssertEqual(data.type, .color)
XCTAssertEqual(data.name, "Color")
data = viewModel.properties[7]
data = viewModel.properties[9]
XCTAssertEqual(data.type, .boolean)
XCTAssertEqual(data.name, "Boolean")
data = viewModel.properties[8]
data = viewModel.properties[10]
XCTAssertEqual(data.type, .number)
XCTAssertEqual(data.name, "Number")
data = viewModel.properties[9]
data = viewModel.properties[11]
XCTAssertEqual(data.type, .string)
XCTAssertEqual(data.name, "String")
}
@@ -462,6 +468,61 @@ class DataBindingTests: XCTestCase {
let renderImage = RiveRenderImage(data: data)!
property.setValue(renderImage)
XCTAssertTrue(property.hasChanged)
property.clearChanges()
property.setValue(nil)
XCTAssertTrue(property.hasChanged)
}
// MARK: Lists
func test_viewModelInstance_listProperty_canAddAndRemoveAndSwapIdenticalItems() throws {
let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
let list = instance.listProperty(fromPath: "List")!
XCTAssertEqual(list.count, 0)
let nestedInstance = file.viewModelNamed("Nested")!.createInstance()!
list.append(nestedInstance)
XCTAssertEqual(list.count, 1)
XCTAssertIdentical(list.instance(at: 0), nestedInstance)
list.remove(nestedInstance)
XCTAssertEqual(list.count, 0)
XCTAssertNil(list.instance(at: 0))
let instance1 = file.viewModelNamed("Nested")!.createInstance()!
let instance2 = file.viewModelNamed("Nested")!.createInstance()!
list.append(instance1)
list.append(instance2)
XCTAssertEqual(list.count, 2)
XCTAssertIdentical(list.instance(at: 0), instance1)
XCTAssertIdentical(list.instance(at: 1), instance2)
list.remove(at: 0)
XCTAssertEqual(list.count, 1)
XCTAssertIdentical(list.instance(at: 0), instance2)
let instance3 = file.viewModelNamed("Nested")!.createInstance()!
list.append(instance3)
list.swap(at: 0, with: 1)
XCTAssertIdentical(list.instance(at: 0), instance3)
XCTAssertIdentical(list.instance(at: 1), instance2)
for i in (0..<list.count).reversed() {
list.remove(at: Int32(i))
}
let instance4 = file.viewModelNamed("Nested")!.createInstance()!
let instance5 = file.viewModelNamed("Nested")!.createInstance()!
let instance6 = file.viewModelNamed("Nested")!.createInstance()!
XCTAssertFalse(list.insert(instance4, at: 100))
XCTAssertTrue(list.insert(instance4, at: 0))
XCTAssertTrue(list.insert(instance5, at: 1))
// One final check to check false if index > list.count (currently 2)
XCTAssertFalse(list.insert(instance6, at: 3))
XCTAssertIdentical(list.instance(at: 0)!, instance4)
XCTAssertEqual(list.count, 2)
}
// MARK: Binding