Skip to content

Commit 90b6473

Browse files
committed
feat(ios): add RiveImages.loadFromURLAsync for dynamic image loading
- Add RiveImage nitro spec and factory for loading images from URLs - Implement iOS native support using RiveRenderImage - Support passing RiveImage directly to referencedAssets - Add Suspense example demonstrating async image loading
1 parent fde1c81 commit 90b6473

47 files changed

Lines changed: 1744 additions & 19 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import * as React from 'react';
2+
import {
3+
ActivityIndicator,
4+
ScrollView,
5+
StyleSheet,
6+
Text,
7+
View,
8+
} from 'react-native';
9+
import {
10+
Fit,
11+
useRiveFile,
12+
RiveView,
13+
RiveImages,
14+
type RiveImage,
15+
} from '@rive-app/react-native';
16+
import { Picker } from '@react-native-picker/picker';
17+
import { type Metadata } from '../helpers/metadata';
18+
19+
const delay = 1000;
20+
21+
const ImageURL1 = `https://picsum.photos/id/372/500/500` as const;
22+
const ImageURL2 = `https://picsum.photos/id/373/500/500` as const;
23+
const ImageURLSlow =
24+
`https://app.requestly.io/delay/${delay}/https://picsum.photos/id/374/500/500` as const;
25+
26+
type ImageURLS = typeof ImageURL1 | typeof ImageURL2 | typeof ImageURLSlow;
27+
28+
const imagePromises = new Map<string, Promise<RiveImage>>();
29+
30+
function getImagePromise(url: string): Promise<RiveImage> {
31+
if (!imagePromises.has(url)) {
32+
imagePromises.set(url, RiveImages.loadFromURLAsync(url));
33+
}
34+
return imagePromises.get(url)!;
35+
}
36+
37+
function RiveContent({ imageUrl }: { imageUrl: ImageURLS }) {
38+
const riveImage = React.use(getImagePromise(imageUrl));
39+
40+
const { riveFile, isLoading, error } = useRiveFile(
41+
require('../../assets/rive/out_of_band.riv'),
42+
{
43+
referencedAssets: {
44+
'Inter-594377': {
45+
source: require('../../assets/fonts/Inter-594377.ttf'),
46+
},
47+
'referenced-image-2929282': riveImage,
48+
'referenced_audio-2929340': {
49+
source: require('../../assets/audio/referenced_audio-2929340.wav'),
50+
},
51+
},
52+
}
53+
);
54+
55+
if (isLoading) {
56+
return <ActivityIndicator />;
57+
} else if (error != null) {
58+
return (
59+
<View style={styles.safeAreaViewContainer}>
60+
<Text>Error loading Rive file: {error}</Text>
61+
</View>
62+
);
63+
}
64+
65+
return (
66+
<RiveView
67+
file={riveFile}
68+
fit={Fit.Contain}
69+
style={styles.rive}
70+
stateMachineName="State Machine 1"
71+
artboardName="Artboard"
72+
/>
73+
);
74+
}
75+
76+
export default function OutOfBandAssetsWithSuspenseExample() {
77+
const [uri, setUri] = React.useState<ImageURLS>(ImageURL1);
78+
79+
return (
80+
<View style={styles.safeAreaViewContainer}>
81+
<React.Suspense
82+
fallback={
83+
<View style={styles.loadingContainer}>
84+
<ActivityIndicator size="large" />
85+
<Text style={styles.loadingText}>Loading image...</Text>
86+
</View>
87+
}
88+
>
89+
<RiveContent imageUrl={uri} />
90+
</React.Suspense>
91+
92+
<ScrollView contentContainerStyle={styles.container}>
93+
<View style={styles.pickersWrapper}>
94+
<Text style={styles.description}>
95+
This example uses React Suspense and RiveImages.loadFromURLAsync to
96+
load images on demand.
97+
</Text>
98+
<View style={styles.pickerWrapper}>
99+
<Picker
100+
selectedValue={uri}
101+
onValueChange={(value) => setUri(value)}
102+
mode={'dropdown'}
103+
style={styles.picker}
104+
>
105+
{[ImageURL1, ImageURL2, ImageURLSlow].map((key) => (
106+
<Picker.Item key={key} value={key} label={key} />
107+
))}
108+
</Picker>
109+
</View>
110+
</View>
111+
</ScrollView>
112+
</View>
113+
);
114+
}
115+
116+
const styles = StyleSheet.create({
117+
safeAreaViewContainer: {
118+
flex: 1,
119+
},
120+
container: {
121+
flexGrow: 1,
122+
alignItems: 'center',
123+
justifyContent: 'flex-start',
124+
},
125+
rive: {
126+
width: '100%',
127+
height: 400,
128+
},
129+
picker: {
130+
flex: 1,
131+
width: '100%',
132+
},
133+
pickerWrapper: {
134+
borderWidth: 1,
135+
borderColor: 'black',
136+
borderRadius: 5,
137+
alignItems: 'center',
138+
margin: 16,
139+
},
140+
pickersWrapper: {
141+
flex: 1,
142+
padding: 16,
143+
alignSelf: 'stretch',
144+
},
145+
loadingContainer: {
146+
width: '100%',
147+
height: 400,
148+
justifyContent: 'center',
149+
alignItems: 'center',
150+
},
151+
loadingText: {
152+
marginTop: 16,
153+
fontSize: 16,
154+
color: '#666',
155+
},
156+
description: {
157+
fontSize: 14,
158+
color: '#666',
159+
marginBottom: 8,
160+
paddingHorizontal: 16,
161+
},
162+
});
163+
164+
OutOfBandAssetsWithSuspenseExample.metadata = {
165+
name: 'Out-of-Band Assets (Suspense)',
166+
description:
167+
'Shows how to load images using RiveImages.loadFromURLAsync with React Suspense',
168+
} satisfies Metadata;

example/src/pages/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@ export { default as EventsExample } from './RiveEventsExample';
55
export { default as StateMachineInputsExample } from './RiveStateMachineInputsExample';
66
export { default as TextRunExample } from './RiveTextRunExample';
77
export { default as OutOfBandAssets } from './OutOfBandAssets';
8+
export { default as OutOfBandAssetsWithSuspense } from './OutOfBandAssetsWithSuspense';
89
export { default as ManyViewModels } from './ManyViewModels';
910
export { default as ResponsiveLayouts } from './ResponsiveLayouts';

ios/HybridRiveImage.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import NitroModules
2+
import RiveRuntime
3+
4+
class HybridRiveImage: HybridRiveImageSpec {
5+
let renderImage: RiveRenderImage
6+
private let dataSize: Int
7+
8+
init(renderImage: RiveRenderImage, dataSize: Int) {
9+
self.renderImage = renderImage
10+
self.dataSize = dataSize
11+
super.init()
12+
}
13+
14+
override init() {
15+
fatalError("HybridRiveImage requires a RiveRenderImage. Use init(renderImage:dataSize:) instead.")
16+
}
17+
18+
var byteSize: Double {
19+
Double(dataSize)
20+
}
21+
}

ios/HybridRiveImageFactory.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import NitroModules
2+
import RiveRuntime
3+
4+
final class HybridRiveImageFactory: HybridRiveImageFactorySpec {
5+
func loadFromURLAsync(url: String) throws -> Promise<(any HybridRiveImageSpec)> {
6+
return Promise.async {
7+
guard let requestUrl = URL(string: url) else {
8+
throw RuntimeError.error(withMessage: "Invalid URL: \(url)")
9+
}
10+
11+
let (data, response) = try await URLSession.shared.data(from: requestUrl)
12+
13+
if let httpResponse = response as? HTTPURLResponse,
14+
!(200...299).contains(httpResponse.statusCode)
15+
{
16+
throw RuntimeError.error(
17+
withMessage: "Failed to load image from URL: \(url) (status: \(httpResponse.statusCode))")
18+
}
19+
20+
guard let renderImage = RiveRenderImage(data: data) else {
21+
throw RuntimeError.error(withMessage: "Failed to decode image from URL: \(url)")
22+
}
23+
24+
return HybridRiveImage(renderImage: renderImage, dataSize: data.count)
25+
}
26+
}
27+
}

ios/ReferencedAssetLoader.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,10 +198,29 @@ final class ReferencedAssetLoader {
198198
}, onError: completion)
199199
}
200200

201+
private func handlePreloadedImage(
202+
_ image: any HybridRiveImageSpec, asset: RiveFileAsset, completion: @escaping () -> Void
203+
) {
204+
guard let imageAsset = asset as? RiveImageAsset,
205+
let hybridImage = image as? HybridRiveImage
206+
else {
207+
completion()
208+
return
209+
}
210+
211+
imageAsset.renderImage(hybridImage.renderImage)
212+
completion()
213+
}
214+
201215
private func loadAssetInternal(
202216
source: ResolvedReferencedAsset, asset: RiveFileAsset, factory: RiveFactory,
203217
completion: @escaping () -> Void
204218
) {
219+
if let preloadedImage = source.image {
220+
handlePreloadedImage(preloadedImage, asset: asset, completion: completion)
221+
return
222+
}
223+
205224
let sourceAssetId = source.sourceAssetId
206225
let sourceUrl = source.sourceUrl
207226
let sourceAsset = source.sourceAsset

nitro.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@
5656
"RiveView": {
5757
"swift": "HybridRiveView",
5858
"kotlin": "HybridRiveView"
59+
},
60+
"RiveImage": {
61+
"swift": "HybridRiveImage",
62+
"kotlin": "HybridRiveImage"
63+
},
64+
"RiveImageFactory": {
65+
"swift": "HybridRiveImageFactory",
66+
"kotlin": "HybridRiveImageFactory"
5967
}
6068
},
6169
"ignorePaths": ["node_modules"]

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

Lines changed: 4 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++/JHybridRiveFileSpec.cpp

Lines changed: 4 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++/JHybridRiveImageFactorySpec.cpp

Lines changed: 69 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)