Skip to content

Commit 3f61c98

Browse files
authored
feat: add viewModel and replaceViewModel for nested ViewModel access (#96)
## Summary Adds two methods to `ViewModelInstance` for accessing and replacing nested ViewModel instances: - `viewModel(path)` - get a nested ViewModelInstance by path - `replaceViewModel(path, instance)` - replace a nested instance with another API naming follows the Web SDK conventions (`@rive-app/canvas`). ## Example ```typescript // Get nested ViewModel instance const nestedVM = instance.viewModel('vm2'); // Replace vm1 with vm2's instance const success = instance.replaceViewModel('vm1', nestedVM); // After replacement, vm1 and vm2 share the same instance // Call playIfNeeded() to refresh the display riveRef.current?.playIfNeeded(); ``` ## Test file Includes `viewmodelproperty.riv` - a custom test file with nested ViewModels (`vm1`, `vm2`) to demonstrate the feature.
1 parent 515070e commit 3f61c98

14 files changed

Lines changed: 375 additions & 0 deletions

android/src/main/java/com/margelo/nitro/rive/HybridViewModelInstance.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,13 @@ class HybridViewModelInstance(val viewModelInstance: ViewModelInstance) : Hybrid
5656
override fun artboardProperty(path: String) = getPropertyOrNull {
5757
HybridViewModelArtboardProperty(viewModelInstance.getArtboardProperty(path))
5858
}
59+
60+
override fun viewModel(path: String) = getPropertyOrNull {
61+
HybridViewModelInstance(viewModelInstance.getInstanceProperty(path))
62+
}
63+
64+
override fun replaceViewModel(path: String, instance: HybridViewModelInstanceSpec) {
65+
val nativeInstance = (instance as HybridViewModelInstance).viewModelInstance
66+
viewModelInstance.setInstanceProperty(path, nativeInstance)
67+
}
5968
}
857 KB
Binary file not shown.
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import {
2+
View,
3+
Text,
4+
StyleSheet,
5+
ActivityIndicator,
6+
Button,
7+
TextInput,
8+
} from 'react-native';
9+
import { useMemo, useRef, useState } from 'react';
10+
import {
11+
Fit,
12+
RiveView,
13+
useRiveFile,
14+
useRiveString,
15+
type ViewModelInstance,
16+
type RiveFile,
17+
type RiveViewRef,
18+
} from '@rive-app/react-native';
19+
import { type Metadata } from '../helpers/metadata';
20+
21+
export default function NestedViewModelExample() {
22+
const { riveFile, isLoading, error } = useRiveFile(
23+
require('../../assets/rive/viewmodelproperty.riv')
24+
);
25+
26+
return (
27+
<View style={styles.container}>
28+
{isLoading ? (
29+
<ActivityIndicator size="large" color="#0000ff" />
30+
) : riveFile ? (
31+
<WithViewModelSetup file={riveFile} />
32+
) : (
33+
<Text style={styles.errorText}>{error || 'Unexpected error'}</Text>
34+
)}
35+
</View>
36+
);
37+
}
38+
39+
function WithViewModelSetup({ file }: { file: RiveFile }) {
40+
const viewModel = useMemo(() => file.defaultArtboardViewModel(), [file]);
41+
const instance = useMemo(
42+
() => viewModel?.createDefaultInstance(),
43+
[viewModel]
44+
);
45+
46+
if (!instance || !viewModel) {
47+
return (
48+
<Text style={styles.errorText}>
49+
{!viewModel
50+
? 'No view model found'
51+
: 'Failed to create view model instance'}
52+
</Text>
53+
);
54+
}
55+
56+
return <ReplaceViewModelTest instance={instance} file={file} />;
57+
}
58+
59+
function ReplaceViewModelTest({
60+
instance,
61+
file,
62+
}: {
63+
instance: ViewModelInstance;
64+
file: RiveFile;
65+
}) {
66+
const riveRef = useRef<RiveViewRef>(null);
67+
const [replaced, setReplaced] = useState(false);
68+
const [log, setLog] = useState<string[]>([]);
69+
70+
const { value: vm1Name, setValue: setVm1Name } = useRiveString(
71+
'vm1/name',
72+
instance
73+
);
74+
const { value: vm2Name, setValue: setVm2Name } = useRiveString(
75+
'vm2/name',
76+
instance
77+
);
78+
79+
const handleSetVm1Name = (newValue: string) => {
80+
setVm1Name(newValue);
81+
riveRef.current?.playIfNeeded();
82+
};
83+
84+
const handleSetVm2Name = (newValue: string) => {
85+
setVm2Name(newValue);
86+
riveRef.current?.playIfNeeded();
87+
};
88+
89+
const addLog = (msg: string) => setLog((prev) => [...prev, msg]);
90+
91+
const handleReplace = () => {
92+
// Get vm2's instance
93+
const vm2Instance = instance.viewModel('vm2');
94+
if (!vm2Instance) {
95+
addLog('❌ viewModel("vm2") returned undefined');
96+
return;
97+
}
98+
addLog(`✅ Got vm2 instance: ${vm2Instance.instanceName}`);
99+
100+
// Replace vm1 with vm2's instance
101+
try {
102+
instance.replaceViewModel('vm1', vm2Instance);
103+
addLog('✅ replaceViewModel("vm1", vm2Instance) succeeded');
104+
// Call playIfNeeded to update the graphics
105+
riveRef.current?.playIfNeeded();
106+
addLog('✅ Called playIfNeeded() to refresh display');
107+
addLog('→ Now vm1 and vm2 point to the same instance');
108+
addLog('→ Changing vm2.name should also change vm1.name');
109+
setReplaced(true);
110+
} catch (error) {
111+
addLog(`❌ replaceViewModel failed: ${error}`);
112+
}
113+
};
114+
115+
return (
116+
<View style={styles.content}>
117+
<RiveView
118+
hybridRef={{ f: (ref: RiveViewRef | null) => (riveRef.current = ref) }}
119+
style={styles.rive}
120+
autoPlay={true}
121+
dataBind={instance}
122+
fit={Fit.Contain}
123+
file={file}
124+
/>
125+
126+
<View style={styles.info}>
127+
<Text style={styles.title}>replaceViewModel() Test</Text>
128+
<Text style={styles.description}>
129+
Replace vm1 with vm2's instance. After replacement, changing vm2.name
130+
should also change vm1.name since they share the same instance.
131+
</Text>
132+
133+
<View style={styles.row}>
134+
<Text style={styles.label}>vm1.name:</Text>
135+
<TextInput
136+
style={styles.input}
137+
value={vm1Name ?? ''}
138+
onChangeText={handleSetVm1Name}
139+
placeholder="vm1 name"
140+
/>
141+
</View>
142+
143+
<View style={styles.row}>
144+
<Text style={styles.label}>vm2.name:</Text>
145+
<TextInput
146+
style={styles.input}
147+
value={vm2Name ?? ''}
148+
onChangeText={handleSetVm2Name}
149+
placeholder="vm2 name"
150+
/>
151+
</View>
152+
153+
{!replaced && (
154+
<Button title="Replace vm1 with vm2" onPress={handleReplace} />
155+
)}
156+
157+
{replaced && (
158+
<Text style={styles.success}>
159+
✅ Replaced! Try changing vm2.name - vm1.name should update too
160+
</Text>
161+
)}
162+
163+
{log.length > 0 && (
164+
<View style={styles.logContainer}>
165+
<Text style={styles.logTitle}>Log:</Text>
166+
{log.map((entry, i) => (
167+
<Text key={i} style={styles.logEntry}>
168+
{entry}
169+
</Text>
170+
))}
171+
</View>
172+
)}
173+
</View>
174+
</View>
175+
);
176+
}
177+
178+
NestedViewModelExample.metadata = {
179+
name: 'Nested ViewModel',
180+
description:
181+
'Tests viewModel() and replaceViewModel() for nested ViewModel instances',
182+
} satisfies Metadata;
183+
184+
const styles = StyleSheet.create({
185+
container: {
186+
flex: 1,
187+
backgroundColor: '#fff',
188+
},
189+
content: {
190+
flex: 1,
191+
},
192+
rive: {
193+
flex: 1,
194+
width: '100%',
195+
},
196+
info: {
197+
padding: 16,
198+
backgroundColor: '#fff',
199+
gap: 12,
200+
},
201+
title: {
202+
fontSize: 18,
203+
fontWeight: 'bold',
204+
},
205+
description: {
206+
fontSize: 14,
207+
color: '#666',
208+
},
209+
row: {
210+
flexDirection: 'row',
211+
alignItems: 'center',
212+
gap: 8,
213+
},
214+
label: {
215+
fontSize: 14,
216+
fontWeight: '600',
217+
width: 80,
218+
},
219+
input: {
220+
flex: 1,
221+
borderWidth: 1,
222+
borderColor: '#ccc',
223+
borderRadius: 8,
224+
padding: 8,
225+
fontSize: 14,
226+
},
227+
success: {
228+
color: 'green',
229+
fontWeight: '600',
230+
},
231+
logContainer: {
232+
marginTop: 8,
233+
padding: 8,
234+
backgroundColor: '#f5f5f5',
235+
borderRadius: 8,
236+
},
237+
logTitle: {
238+
fontWeight: '600',
239+
marginBottom: 4,
240+
},
241+
logEntry: {
242+
fontSize: 12,
243+
fontFamily: 'monospace',
244+
color: '#333',
245+
},
246+
errorText: {
247+
color: 'red',
248+
textAlign: 'center',
249+
padding: 20,
250+
},
251+
});

example/src/pages/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ export { default as ResponsiveLayouts } from './ResponsiveLayouts';
1212
export { default as SharedValueListenerExample } from './SharedValueListenerExample';
1313
export { default as MenuListExample } from './MenuListExample';
1414
export { default as DataBindingArtboardsExample } from './DataBindingArtboardsExample';
15+
export { default as NestedViewModelExample } from './NestedViewModelExample';

ios/HybridViewModelInstance.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,20 @@ class HybridViewModelInstance: HybridViewModelInstanceSpec {
5353
guard let property = viewModelInstance?.artboardProperty(fromPath: path) else { return nil }
5454
return HybridViewModelArtboardProperty(property: property)
5555
}
56+
57+
func viewModel(path: String) throws -> (any HybridViewModelInstanceSpec)? {
58+
guard let instance = viewModelInstance?.viewModelInstanceProperty(fromPath: path) else { return nil }
59+
return HybridViewModelInstance(viewModelInstance: instance)
60+
}
61+
62+
func replaceViewModel(path: String, instance: any HybridViewModelInstanceSpec) throws {
63+
guard let hybridInstance = instance as? HybridViewModelInstance,
64+
let nativeInstance = hybridInstance.viewModelInstance else {
65+
throw RuntimeError.error(withMessage: "Invalid ViewModelInstance provided to replaceViewModel")
66+
}
67+
let success = viewModelInstance?.setViewModelInstanceProperty(fromPath: path, to: nativeInstance) ?? false
68+
if !success {
69+
throw RuntimeError.error(withMessage: "Failed to replace ViewModel at path: \(path)")
70+
}
71+
}
5672
}

nitrogen/generated/android/c++/JHybridViewModelInstanceSpec.cpp

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

nitrogen/generated/android/c++/JHybridViewModelInstanceSpec.hpp

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

nitrogen/generated/android/kotlin/com/margelo/nitro/rive/HybridViewModelInstanceSpec.kt

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

nitrogen/generated/ios/c++/HybridViewModelInstanceSpecSwift.hpp

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

nitrogen/generated/ios/swift/HybridViewModelInstanceSpec.swift

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)