feat(ios): add data binding replace view model instance support

## Changes
- Adds support for replacing view model instance properties with other view model instances

## Fixes
- Fixes an issue where nested view model instances and properties were getting incorrectly created and cached

Diffs=
909ae012c1 feat(ios): add data binding replace view model instance support (#9500)

Co-authored-by: David Skuza <david@rive.app>
This commit is contained in:
dskuza
2025-04-22 20:45:12 +00:00
parent eb612b77a0
commit 5dd670b893
5 changed files with 132 additions and 58 deletions

View File

@@ -1 +1 @@
598ad6c62e7b56024c97dad252774e5b935353c1
909ae012c1e5c721d473e997fa61976a23acb768

View File

@@ -131,6 +131,21 @@ NS_SWIFT_NAME(RiveDataBindingViewModel.Instance)
- (nullable RiveDataBindingViewModelInstance*)viewModelInstancePropertyFromPath:
(NSString*)path;
/// Replaces a view model property of the view model instance with another
/// instance.
///
/// - Parameters:
/// - path: The path to the view model property to replace.
/// - instance: The instance to replace the view model property at `path`
/// with.
///
/// - Returns: `true` if the view model instance was replaced, otherwise
/// `false`.
- (BOOL)setViewModelInstancePropertyFromPath:(NSString*)path
toInstance:(RiveDataBindingViewModelInstance*)
instance
NS_SWIFT_NAME(setViewModelInstanceProperty(fromPath:to:));
/// Returns a trigger property in the view model instance.
///
/// - Note: Unlike a `RiveViewModel.Instance`, a strong reference to this type

View File

@@ -264,7 +264,18 @@
- (RiveDataBindingViewModelInstance*)viewModelInstancePropertyFromPath:
(NSString*)path
{
return [self childForPath:path];
RiveDataBindingViewModelInstance* parent = [self parentForPath:path];
return
[parent viewModelInstanceWithName:[[path pathComponents] lastObject]];
}
- (BOOL)setViewModelInstancePropertyFromPath:(NSString*)path
toInstance:(RiveDataBindingViewModelInstance*)
instance
{
RiveDataBindingViewModelInstance* parent = [self parentForPath:path];
return [parent setViewModelInstance:instance
forName:[path lastPathComponent]];
}
- (RiveDataBindingViewModelInstanceTriggerProperty*)triggerPropertyFromPath:
@@ -341,29 +352,14 @@
- (void)cacheProperty:(RiveDataBindingViewModelInstanceProperty*)value
withPath:(NSString*)path
{
NSArray<NSString*>* components = [path pathComponents];
if (components.count == 1)
{
_properties[path] = value;
}
else
{
RiveDataBindingViewModelInstance* child =
[self childForPath:components[0]];
if (child)
{
NSArray* subcomponents = [components
subarrayWithRange:NSMakeRange(1, components.count - 1)];
NSString* subpath = [subcomponents componentsJoinedByString:@"/"];
[child cacheProperty:value withPath:subpath];
}
}
RiveDataBindingViewModelInstance* parent = [self parentForPath:path];
[parent setProperty:value forName:[path lastPathComponent]];
}
- (nullable id)cachedPropertyFromPath:(NSString*)path asClass:(Class)aClass
{
RiveDataBindingViewModelInstanceProperty* property =
[_properties objectForKey:path];
RiveDataBindingViewModelInstance* parent = [self parentForPath:path];
id property = [parent cachedPropertyWithName:[path lastPathComponent]];
if (property != nil && [property isKindOfClass:aClass])
{
return property;
@@ -384,47 +380,68 @@
#pragma mark - Paths
- (nullable RiveDataBindingViewModelInstance*)childForPath:(NSString*)path
- (nullable RiveDataBindingViewModelInstance*)parentForPath:(NSString*)path
{
NSArray* components = [path pathComponents];
// If we have no components, we have no child to add
if (components.count == 0)
NSArray* pathComponents = [path pathComponents];
if (pathComponents.count == 0)
{
return nil;
}
// E.g from "current/path", this is "current".
// If a child exists with that name, return it.
NSString* currentPath = components[0];
// Use map over set
RiveDataBindingViewModelInstance* existing = nil;
if ((existing = [_children objectForKey:currentPath]))
if (pathComponents.count == 1)
{
return self;
}
NSArray* subpathComponents = [pathComponents
subarrayWithRange:NSMakeRange(1, pathComponents.count - 1)];
RiveDataBindingViewModelInstance* instance =
[self viewModelInstanceWithName:[pathComponents firstObject]];
return [instance
parentForPath:[subpathComponents componentsJoinedByString:@"/"]];
}
- (nullable RiveDataBindingViewModelInstance*)viewModelInstanceWithName:
(NSString*)name
{
RiveDataBindingViewModelInstance* existing;
if ((existing = _children[name]))
{
return existing;
}
// Otherwise, for the current path, build a tree recursively, starting with
// the current position.
auto instance =
_instance->propertyViewModel(std::string([currentPath UTF8String]));
if (instance == nullptr)
auto i = _instance->propertyViewModel(std::string([name UTF8String]));
if (i == nullptr)
{
return nil;
}
RiveDataBindingViewModelInstance* instance =
[[RiveDataBindingViewModelInstance alloc] initWithInstance:i];
_children[name] = instance;
return instance;
}
RiveDataBindingViewModelInstance* child =
[[RiveDataBindingViewModelInstance alloc] initWithInstance:instance];
_children[currentPath] = child;
if (components.count == 1)
- (BOOL)setViewModelInstance:(RiveDataBindingViewModelInstance*)instance
forName:(NSString*)name
{
BOOL replaced = _instance->replaceViewModelByName(
std::string([name UTF8String]), instance.instance);
if (replaced)
{
return child;
}
else
{
NSArray* subpath =
[components subarrayWithRange:NSMakeRange(1, components.count - 1)];
return [self childForPath:[subpath componentsJoinedByString:@"/"]];
_children[name] = instance;
}
return replaced;
}
- (id)cachedPropertyWithName:(NSString*)name
{
return [_properties objectForKey:name];
}
- (void)setProperty:(RiveDataBindingViewModelInstanceProperty*)property
forName:(NSString*)name
{
_properties[name] = property;
}
@end

Binary file not shown.

View File

@@ -380,6 +380,58 @@ class DataBindingTests: XCTestCase {
XCTAssertEqual(nestedProperty!.value, newValue)
}
func test_viewModelInstance_nested_areIdentical() {
let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
let fromPath = instance.viewModelInstanceProperty(fromPath: "Nested/DeeperNested")!
let nested = instance.viewModelInstanceProperty(fromPath: "Nested")!.viewModelInstanceProperty(fromPath: "DeeperNested")!
XCTAssertIdentical(fromPath, nested)
}
func test_viewModelInstance_replace_withCorrectInstance_setsNewInstance() throws {
let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
XCTAssertEqual(instance.stringProperty(fromPath: "Nested/String")!.value, "Nested")
// Test one-level deep + caching
let replacement = file.viewModelNamed("Nested")!.createInstance()!
replacement.stringProperty(fromPath: "String")!.value = "Hello, Rive"
var replaced = instance.setViewModelInstanceProperty(fromPath: "Nested", to: replacement)
XCTAssertTrue(replaced)
XCTAssertEqual(instance.stringProperty(fromPath: "Nested/String")!.value, "Hello, Rive")
XCTAssertIdentical(
instance.viewModelInstanceProperty(fromPath: "Nested"),
replacement
)
// Test two-level deep traversal + caching
let nestedReplacement = file.viewModelNamed("Default")!.createDefaultInstance()!
replaced = instance.setViewModelInstanceProperty(fromPath: "Nested/DeeperNested", to: nestedReplacement)
XCTAssertTrue(replaced)
XCTAssertIdentical(
instance.viewModelInstanceProperty(fromPath: "Nested/DeeperNested")!,
nestedReplacement
)
XCTAssertIdentical(
instance.viewModelInstanceProperty(fromPath: "Nested/DeeperNested")!,
instance.viewModelInstanceProperty(fromPath: "Nested")!.viewModelInstanceProperty(fromPath: "DeeperNested")
)
XCTAssertEqual(
instance.stringProperty(fromPath: "Nested/DeeperNested/String")!.value,
instance.viewModelInstanceProperty(fromPath: "Nested")!
.viewModelInstanceProperty(fromPath: "DeeperNested")!
.stringProperty(fromPath: "String")!.value
)
}
func test_viewModelInstance_replace_withIncorrectInstance_returnsFalse() throws {
let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
let replacement = file.viewModelNamed("Default")!.createInstance()!
let replaced = instance.setViewModelInstanceProperty(fromPath: "Nested", to: replacement)
XCTAssertFalse(replaced)
}
// MARK: Trigger
func test_viewModelInstance_triggerProperty_returnsPropertyOrNil() {
@@ -957,16 +1009,6 @@ class DataBindingTests: XCTestCase {
wait(for: [expectation], timeout: 1)
}
func test_nestedViewModel_property_createsAndCachesChildren() throws {
let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
let nested: RiveDataBindingViewModel.Instance? = instance.viewModelInstanceProperty(fromPath: "Nested")!
let nestedCopy = instance.viewModelInstanceProperty(fromPath: "Nested")!
XCTAssertTrue(nested === nestedCopy)
let fullPath = instance.viewModelInstanceProperty(fromPath: "Nested/Nested")
XCTAssertTrue(nestedCopy === fullPath)
}
func test_instance_property_whenBoundToTwoStateMachines_ifNoAdditionalChanges_callsListenerOnce() throws {
let instance = file.viewModelNamed("Test")!.createDefaultInstance()!