Skip to content

Commit b0d5079

Browse files
authored
feat: ViewModelImageProperty (#40)
* feat: ViewModelImageProperty * force refresh * refactor: use PropertyListenerHelper * fix: addListener fix * fix: ktlint
1 parent b92d071 commit b0d5079

36 files changed

Lines changed: 984 additions & 34 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.margelo.nitro.rive
2+
3+
import androidx.annotation.Keep
4+
import app.rive.runtime.kotlin.core.ViewModelImageProperty
5+
import com.facebook.proguard.annotations.DoNotStrip
6+
import kotlinx.coroutines.flow.map
7+
8+
@Keep
9+
@DoNotStrip
10+
class HybridViewModelImageProperty(private val viewModelImage: ViewModelImageProperty) :
11+
HybridViewModelImagePropertySpec(),
12+
BaseHybridViewModelProperty<Unit> by BaseHybridViewModelPropertyImpl() {
13+
override fun set(image: HybridRiveImageSpec?) {
14+
viewModelImage.set((image as? HybridRiveImage)?.renderImage)
15+
}
16+
17+
override fun addListener(onChanged: () -> Unit) {
18+
listeners.add(onChanged)
19+
ensureValueListenerJob(viewModelImage.valueFlow.map { })
20+
}
21+
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,13 @@ class HybridViewModelInstance(val viewModelInstance: ViewModelInstance) : Hybrid
6464
return null
6565
}
6666
}
67+
68+
override fun imageProperty(path: String): HybridViewModelImagePropertySpec? {
69+
try {
70+
val imageProperty = viewModelInstance.getImageProperty(path)
71+
return HybridViewModelImageProperty(imageProperty)
72+
} catch (e: ViewModelException) {
73+
return null
74+
}
75+
}
6776
}
212 KB
Binary file not shown.

example/src/pages/ManyViewModels.tsx

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
import { StyleSheet, View, Text, TouchableOpacity } from 'react-native';
2-
import { useState, useMemo } from 'react';
1+
import { StyleSheet, View, Text, TouchableOpacity, Button } from 'react-native';
2+
import { useState, useMemo, useRef, useEffect } from 'react';
33
import type { Metadata } from '../helpers/metadata';
44
import {
55
DataBindMode,
66
RiveView,
77
useRiveFile,
88
type ViewModelInstance,
9+
RiveImages,
10+
type RiveViewRef,
911
} from '@rive-app/react-native';
1012

1113
type BindModeOption =
@@ -77,6 +79,10 @@ export default function ManyViewModels() {
7779
require('../../assets/rive/many_viewmodels.riv')
7880
);
7981
const [bindMode, setBindMode] = useState<BindModeOption>('none');
82+
const [isLoadingImage, setIsLoadingImage] = useState(false);
83+
const [imageError, setImageError] = useState<string | null>(null);
84+
const riveViewRef = useRef<RiveViewRef>(undefined);
85+
const isListening = useRef(false);
8086

8187
// Create a ViewModelInstance for "green" to demonstrate instance binding
8288
const greenInstance = useMemo(() => {
@@ -91,13 +97,69 @@ export default function ManyViewModels() {
9197
}
9298
}, [riveFile]);
9399

100+
const handleLoadImage = async () => {
101+
if (!riveViewRef.current) return;
102+
103+
setIsLoadingImage(true);
104+
setImageError(null);
105+
try {
106+
const vmi = riveViewRef.current.getViewModelInstance();
107+
if (!vmi) {
108+
console.log('No ViewModelInstance found on RiveViewRef');
109+
setImageError('No ViewModelInstance found on RiveViewRef');
110+
return null;
111+
}
112+
const imgProp = vmi.imageProperty('imageValue');
113+
if (!imgProp) {
114+
setImageError('Image property "imageValue" not found');
115+
return null;
116+
}
117+
118+
if (!isListening.current) {
119+
imgProp.addListener(() => {
120+
console.log('[IMAGE PROPERTY LISTENER]: Image property changed!');
121+
});
122+
isListening.current = true;
123+
}
124+
125+
const riveImage = await RiveImages.loadFromURLAsync(
126+
'https://picsum.photos/id/372/500/500'
127+
);
128+
imgProp.set(riveImage);
129+
riveViewRef.current.play();
130+
} catch (err) {
131+
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
132+
setImageError(errorMsg);
133+
console.error('Failed to load image:', errorMsg);
134+
} finally {
135+
setIsLoadingImage(false);
136+
}
137+
return true;
138+
};
139+
94140
const dataBindValue = getDataBindValue(bindMode, greenInstance);
141+
useEffect(() => {
142+
isListening.current = false;
143+
}, [dataBindValue]);
95144

96145
return (
97146
<View style={styles.container}>
98147
<BindModeSelector selectedMode={bindMode} onModeChange={setBindMode} />
148+
<View style={styles.imageButtonContainer}>
149+
<Button
150+
title={isLoadingImage ? 'Loading Image...' : 'Load Test Image'}
151+
onPress={handleLoadImage}
152+
disabled={isLoadingImage || !riveFile}
153+
/>
154+
{imageError && <Text style={styles.errorText}>{imageError}</Text>}
155+
</View>
99156
{riveFile && (
100157
<RiveView
158+
hybridRef={{
159+
f: (ref) => {
160+
riveViewRef.current = ref;
161+
},
162+
}}
101163
style={styles.rive}
102164
file={riveFile}
103165
dataBind={dataBindValue}
@@ -119,6 +181,17 @@ const styles = StyleSheet.create({
119181
flex: 1,
120182
backgroundColor: '#fff',
121183
},
184+
imageButtonContainer: {
185+
padding: 16,
186+
backgroundColor: '#fff',
187+
borderBottomWidth: 1,
188+
borderBottomColor: '#e0e0e0',
189+
},
190+
errorText: {
191+
color: 'red',
192+
marginTop: 8,
193+
fontSize: 12,
194+
},
122195
rive: {
123196
flex: 1,
124197
width: '100%',

expo-example/app/_layout.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
} from '@react-navigation/native';
66
import { Stack } from 'expo-router';
77
import { StatusBar } from 'expo-status-bar';
8-
import 'react-native-reanimated';
98

109
import { useColorScheme } from '@/hooks/use-color-scheme';
1110

ios/BaseHybridViewModelProperty.swift

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,15 @@ import RiveRuntime
44
/// Protocol for Rive property types that support listener management
55
protocol RivePropertyWithListeners: AnyObject {
66
associatedtype ListenerValueType
7-
8-
func addListener(_ callback: @escaping (ListenerValueType) -> Void) -> UUID
7+
typealias ListenerType = (ListenerValueType) -> Void
8+
9+
func addListener(_ callback: @escaping ListenerType) -> UUID
10+
func removeListener(_ id: UUID)
11+
}
12+
13+
/// Protocol for Rive property types with void listeners (Trigger, Image)
14+
protocol RivePropertyWithVoidListeners: AnyObject {
15+
func addListener(_ callback: @escaping () -> Void) -> UUID
916
func removeListener(_ id: UUID)
1017
}
1118

@@ -33,32 +40,46 @@ extension EnumPropertyType: RivePropertyWithListeners {
3340
extension ColorPropertyType: RivePropertyWithListeners {
3441
typealias ListenerValueType = UIColor // Native: UIColor → Double (needs conversion)
3542
}
36-
// Note: TriggerProperty doesn't fit the pattern - it has () -> Void listeners, not (Void) -> Void
43+
extension TriggerPropertyType: RivePropertyWithListeners {
44+
func addListener(_ callback: @escaping ListenerType) -> UUID {
45+
addListener { callback(()) }
46+
}
47+
48+
typealias ListenerValueType = Void
49+
}
50+
51+
extension ImagePropertyType: RivePropertyWithListeners {
52+
typealias ListenerValueType = Void
53+
54+
func addListener(_ callback: @escaping ListenerType) -> UUID {
55+
addListener { callback(()) }
56+
}
57+
}
3758

3859
/// Helper class for managing ViewModel property listeners
3960
class PropertyListenerHelper<PropertyType: RivePropertyWithListeners> {
4061
private var listenerIds: [UUID] = []
4162
weak var property: PropertyType?
42-
63+
4364
init(property: PropertyType) {
4465
self.property = property
4566
}
46-
67+
4768
/// Adds a listener to the property and automatically tracks its ID for cleanup
4869
func addListener(_ callback: @escaping (PropertyType.ListenerValueType) -> Void) {
4970
guard let property = property else { return }
5071
let id = property.addListener(callback)
5172
listenerIds.append(id)
5273
}
53-
74+
5475
func removeListeners() throws {
5576
guard let property = property else { return }
5677
for id in listenerIds {
5778
property.removeListener(id)
5879
}
5980
listenerIds.removeAll()
6081
}
61-
82+
6283
func dispose() throws {
6384
try? removeListeners()
6485
}
@@ -69,10 +90,10 @@ class PropertyListenerHelper<PropertyType: RivePropertyWithListeners> {
6990
protocol ValuedPropertyProtocol<ValueType> {
7091
associatedtype PropertyType: RivePropertyWithListeners
7192
associatedtype ValueType
72-
93+
7394
var property: PropertyType! { get }
7495
var helper: PropertyListenerHelper<PropertyType> { get }
75-
96+
7697
func addListener(onChanged: @escaping (ValueType) -> Void) throws
7798
func removeListeners() throws
7899
func dispose() throws
@@ -83,7 +104,7 @@ extension ValuedPropertyProtocol {
83104
func removeListeners() throws {
84105
try helper.removeListeners()
85106
}
86-
107+
87108
func dispose() throws {
88109
try helper.dispose()
89110
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import RiveRuntime
2+
3+
class HybridViewModelImageProperty: HybridViewModelImagePropertySpec, ValuedPropertyProtocol {
4+
func addListener(onChanged: @escaping () -> Void) throws {
5+
try addListener(onChanged: { _ in onChanged() })
6+
}
7+
8+
var property: ImagePropertyType!
9+
lazy var helper = PropertyListenerHelper(property: property!)
10+
11+
init(property: ImagePropertyType) {
12+
self.property = property
13+
super.init()
14+
}
15+
16+
/// ⚠️ DO NOT REMOVE
17+
/// Nitro requires a parameterless initializer for JS bridging.
18+
/// This is invoked automatically during hybrid module construction.
19+
/// Internally we always use `init(property:)`
20+
override init() {
21+
super.init()
22+
}
23+
24+
func set(image: HybridRiveImageSpec?) throws {
25+
if let hybridImage = image as? HybridRiveImage {
26+
property.setValue(hybridImage.renderImage)
27+
} else {
28+
property.setValue(nil)
29+
}
30+
}
31+
}

ios/HybridViewModelInstance.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,9 @@ class HybridViewModelInstance: HybridViewModelInstanceSpec {
4343
guard let property = viewModelInstance?.triggerProperty(fromPath: path) else { return nil }
4444
return HybridViewModelTriggerProperty(property: property)
4545
}
46+
47+
func imageProperty(path: String) throws -> (any HybridViewModelImagePropertySpec)? {
48+
guard let property = viewModelInstance?.imageProperty(fromPath: path) else { return nil }
49+
return HybridViewModelImageProperty(property: property)
50+
}
4651
}
Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import NitroModules
22
import RiveRuntime
33

4-
class HybridViewModelTriggerProperty: HybridViewModelTriggerPropertySpec {
5-
private var property: TriggerPropertyType!
4+
class HybridViewModelTriggerProperty: HybridViewModelTriggerPropertySpec, ValuedPropertyProtocol {
5+
internal var property: TriggerPropertyType!
6+
lazy var helper = PropertyListenerHelper(property: property!)
7+
68
private var listenerIds: [UUID] = []
7-
9+
10+
func addListener(onChanged: @escaping () -> Void) throws {
11+
try addListener(onChanged: { _ in onChanged() })
12+
}
13+
814
init(property: TriggerPropertyType) {
915
self.property = property
1016
super.init()
@@ -21,22 +27,4 @@ class HybridViewModelTriggerProperty: HybridViewModelTriggerPropertySpec {
2127
func trigger() {
2228
property.trigger()
2329
}
24-
25-
func addListener(onChanged: @escaping () -> Void) throws {
26-
let id = property.addListener {
27-
onChanged()
28-
}
29-
listenerIds.append(id)
30-
}
31-
32-
func removeListeners() throws {
33-
for id in listenerIds {
34-
property.removeListener(id)
35-
}
36-
listenerIds.removeAll()
37-
}
38-
39-
func dispose() throws {
40-
try? removeListeners()
41-
}
4230
}

nitro.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@
5353
"swift": "HybridViewModelTriggerProperty",
5454
"kotlin": "HybridViewModelTriggerProperty"
5555
},
56+
"ViewModelImageProperty": {
57+
"swift": "HybridViewModelImageProperty",
58+
"kotlin": "HybridViewModelImageProperty"
59+
},
5660
"RiveView": {
5761
"swift": "HybridRiveView",
5862
"kotlin": "HybridRiveView"

0 commit comments

Comments
 (0)