@pixodesk/svg-animator-rn 1.0.21 โ†’ 1.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,415 @@
1
+ # animator-rn
2
+
3
+ [![CI](https://github.com/pixodesk/pixodesk-svg-animator/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/pixodesk/pixodesk-svg-animator/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ React Native component for rendering and controlling Pixodesk SVG animations,
7
+ built on `react-native-svg` and `react-native-reanimated`. Props mirror
8
+ [`@pixodesk/svg-animator-react`](../svg-animator-react/README.md).
9
+
10
+ # ๐Ÿงช **Status โ€” EXPERIMENTAL**
11
+
12
+ - API may change without a major version bump.
13
+ - A few things are unimplemented or unverified โ€” see [Feature support](#feature-support).
14
+
15
+ ## What it does
16
+
17
+ `<PixodeskSvgAnimator doc={โ€ฆ} />` takes an animation document exported from the
18
+ Pixodesk editor โ€” the **same `.json` the web player uses** โ€” and renders it as
19
+ native SVG, driven on the UI thread.
20
+
21
+ - **One prop to render:** put the `.json` next to your component, import it, pass
22
+ it as `doc`.
23
+ - **Playback control:** declarative props (`autoplay`, `play`, `pause`,
24
+ `timeMs`) or an imperative ref (`play()`, `pause()`, `setCurrentTime()`, โ€ฆ).
25
+ - **Sized by its container:** wrap it in a `View` with the dimensions you want.
26
+
27
+ ## Quick start
28
+
29
+ Drop `animation.json` next to your component and render it:
30
+
31
+ ```tsx
32
+ import { View } from 'react-native';
33
+ import { PixodeskSvgAnimator } from '@pixodesk/svg-animator-rn';
34
+ import animation from './animation.json';
35
+
36
+ export function Logo() {
37
+ return (
38
+ <View style={{ width: 200, height: 200 }}>
39
+ <PixodeskSvgAnimator doc={animation} autoplay />
40
+ </View>
41
+ );
42
+ }
43
+ ```
44
+
45
+ That's the whole common case: an animation that plays on mount and loops if the
46
+ document says so.
47
+
48
+ ### Typical cases
49
+
50
+ ```tsx
51
+ // Play once when a screen opens, then hold the last frame
52
+ <PixodeskSvgAnimator doc={doc} autoplay iterations={1} fill="forwards" />
53
+
54
+ // Loop forever regardless of what the document says
55
+ <PixodeskSvgAnimator doc={doc} autoplay iterations="infinite" />
56
+
57
+ // Static โ€” first frame only, no playback
58
+ <PixodeskSvgAnimator doc={doc} />
59
+
60
+ // Toggle from your own state
61
+ <PixodeskSvgAnimator doc={doc} play={isPlaying} />
62
+
63
+ // Tie progress to a gesture / slider (no playback, just a frame)
64
+ <PixodeskSvgAnimator doc={doc} time={scrollProgress} />
65
+
66
+ // Override the document's timing (duration in ms, delay before start)
67
+ <PixodeskSvgAnimator doc={doc} autoplay duration={4000} delay={500} />
68
+
69
+ // Do something when it finishes
70
+ <PixodeskSvgAnimator doc={doc} autoplay onFinish={() => setDone(true)} />
71
+ ```
72
+
73
+ TypeScript: `import animation from './animation.json'` gives you a plain object.
74
+ If your `tsconfig` complains, cast it:
75
+
76
+ ```tsx
77
+ import type { PxAnimatedSvgDocument } from '@pixodesk/svg-animator-core';
78
+ const doc = animation as PxAnimatedSvgDocument;
79
+ ```
80
+
81
+ ## Install
82
+
83
+ ```bash
84
+ npm install @pixodesk/svg-animator-rn
85
+ # peers, if you don't have them already:
86
+ npx expo install react-native-svg react-native-reanimated
87
+ ```
88
+
89
+ Peer dependencies: `react >=18`, `react-native >=0.76`,
90
+ `react-native-svg >=15`, `react-native-reanimated >=3.16`.
91
+
92
+ Reanimated needs its Babel plugin. In `babel.config.js`:
93
+
94
+ ```js
95
+ module.exports = function (api) {
96
+ api.cache(true);
97
+ return {
98
+ presets: ['babel-preset-expo'],
99
+ plugins: ['react-native-reanimated/plugin'], // must be last
100
+ };
101
+ };
102
+ ```
103
+
104
+ > **Monorepo users:** make sure `react-native-svg`, `react-native-reanimated`
105
+ > and `react` resolve to a **single copy**. Two copies produce
106
+ > `View config getter callback for component 'RNSVGLine' must be a function`
107
+ > at runtime. See [Monorepo setup](#monorepo-setup).
108
+
109
+ ## Control modes
110
+
111
+ Four ways to drive playback. Pick one โ€” they are mutually exclusive.
112
+
113
+ ### Autoplay
114
+
115
+ Uses the trigger defined in the animation document (`startOn: 'load'` plays on mount):
116
+
117
+ ```tsx
118
+ <PixodeskSvgAnimator doc={doc} autoplay />
119
+ ```
120
+
121
+ ### Declarative play/pause
122
+
123
+ Control playback with boolean props:
124
+
125
+ ```tsx
126
+ const [play, setPlay] = useState(false);
127
+ const [pause, setPause] = useState(false);
128
+
129
+ <PixodeskSvgAnimator doc={doc} play={play} pause={pause} />
130
+ <Button title={play ? 'Play (on)' : 'Play (off)'} onPress={() => setPlay(p => !p)} />
131
+ <Button title={pause ? 'Pause (on)' : 'Pause (off)'} onPress={() => setPause(p => !p)} />
132
+ ```
133
+
134
+ ### Imperative API
135
+
136
+ Use a ref for full programmatic control:
137
+
138
+ ```tsx
139
+ import { useRef } from 'react';
140
+ import type { RnAnimatorApi } from '@pixodesk/svg-animator-rn';
141
+
142
+ const api = useRef<RnAnimatorApi>(null);
143
+
144
+ <PixodeskSvgAnimator doc={doc} apiRef={api} />
145
+ <Button title="Play" onPress={() => api.current?.play()} />
146
+ <Button title="Pause" onPress={() => api.current?.pause()} />
147
+ <Button title="Cancel" onPress={() => api.current?.cancel()} />
148
+ <Button title="Finish" onPress={() => api.current?.finish()} />
149
+ ```
150
+
151
+ `RnAnimatorApi` methods: `play()`, `pause()`, `cancel()`, `finish()`,
152
+ `isPlaying()`, `setPlaybackRate(rate)`, `getCurrentTime()`, `setCurrentTime(ms)`.
153
+
154
+ ### Controlled time
155
+
156
+ Scrub through the animation or pin a fixed frame:
157
+
158
+ ```tsx
159
+ const [timeMs, setTimeMs] = useState(0);
160
+
161
+ <PixodeskSvgAnimator doc={doc} timeMs={timeMs} />
162
+ <Slider minimumValue={0} maximumValue={2000} value={timeMs} onValueChange={setTimeMs} />
163
+ ```
164
+
165
+ ## Props
166
+
167
+ | Prop | Type | Description |
168
+ |---|---|---|
169
+ | `doc` | `PxAnimatedSvgDocument` | The animation document to render (required) |
170
+ | `autoplay` | `boolean` | Use the trigger from the document |
171
+ | `play` | `boolean` | Start playback, ignoring document triggers |
172
+ | `pause` | `boolean` | Pause current playback |
173
+ | `apiRef` | `RefObject<RnAnimatorApi>` | Ref for imperative control |
174
+ | `time` | `number` | Seek to a fraction (0โ€“1) of the whole timeline (duration ร— iterations) |
175
+ | `timeMs` | `number` | Seek to a time in milliseconds |
176
+ | `duration` | `number` | Duration override (ms) |
177
+ | `delay` | `number` | Delay before start (ms) |
178
+ | `iterations` | `number \| 'infinite'` | Loop count |
179
+ | `fill` | `FillMode` | Fill behaviour |
180
+ | `direction` | `PlaybackDirection` | Playback direction |
181
+ | `resetOnFinish` | `boolean` | Snap back to the start after a natural finish |
182
+ | `outAction` | `OutAction` | What a second tap does with the `click` trigger (default: the document's, else `pause`) |
183
+ | `onPlay` | `() => void` | Called on play/resume |
184
+ | `onPause` | `() => void` | Called on pause |
185
+ | `onFinish` | `() => void` | Called on natural finish |
186
+ | `onCancel` | `() => void` | Called on cancel |
187
+ | `onStop` | `() => void` | Called whenever playback halts (pause / cancel / finish) |
188
+ | `onError` | `(error, componentStack?) => void` | Called when a document cannot be compiled or rendered |
189
+ | `fallback` | `(error) => ReactElement \| null` | Rendered in place of a failed animation (default: nothing) |
190
+
191
+ With none of `autoplay` / `play` / `pause` / `time` / `timeMs` set, the component
192
+ renders the animation statically (initial state, no playback).
193
+
194
+ ### Failure handling
195
+
196
+ The component never throws for a bad document. Compilation and rendering both
197
+ run inside `try`/`catch`, and the rendered tree sits behind an error boundary,
198
+ so a failure is reported through `onError` and shows `fallback` while the
199
+ surrounding screen keeps working.
200
+
201
+ ```tsx
202
+ <PixodeskSvgAnimator
203
+ doc={doc}
204
+ autoplay
205
+ onError={e => console.warn('animation failed:', e.message)}
206
+ fallback={() => <Text>could not play this animation</Text>}
207
+ />
208
+ ```
209
+
210
+ This covers JavaScript errors. A crash inside react-native-svg's **native**
211
+ renderer never reaches JavaScript and cannot be caught โ€” see
212
+ [Known limitations](#known-limitations).
213
+
214
+ ### Differences from the React package
215
+
216
+ | Prop | Why it differs |
217
+ |---|---|
218
+ | `mode` | Not accepted. There is no Web Animations API on RN; playback is always native-driven. |
219
+ | `frameRate` | Not accepted. Reanimated runs at the display refresh rate. The analogous knob is sampling density โ€” see `compileTracks`. |
220
+ | `startOn` | Not accepted as a prop โ€” the document's trigger is honoured via `autoplay` (`load`, `click`, `scrollIntoView`, `programmatic`). `mouseOver` has no touch equivalent. |
221
+ | `className` / `style` | Not accepted. Size the animation with the container `View`. (`node.style` *inside* the document is supported.) |
222
+ | `onRemove` | Not emitted. Use React's own unmount cleanup. |
223
+
224
+ ## How playback works
225
+
226
+ There is **no JavaScript frame loop** โ€” the JS thread is idle while an
227
+ animation runs.
228
+
229
+ 1. **Once per document:** the shared core flattens it
230
+ (`materialiseAllInTree` โ†’ effects, loops, motion-path sampling, animated
231
+ `<use>` inlining), then a track compiler densely samples every animated
232
+ property with `calcAnimationValues` โ€” the same function the web frames engine
233
+ renders with, so values match the web player exactly.
234
+ 2. **Per frame:** one reanimated progress value, driven by
235
+ `withTiming`/`withRepeat` on the UI thread, and a tiny worklet per animated
236
+ element that indexes its precompiled track.
237
+
238
+ This is why sampling appears so often below: where `react-native-svg` cannot
239
+ express something directly (motion along a path, text on a path), the core
240
+ converts it into plain values ahead of time instead of fighting the platform.
241
+
242
+ ## Feature support
243
+
244
+ Every row below was verified by running the document through the real
245
+ pipeline (`materialiseAllInTree` โ†’ track compilation) and checking that the
246
+ element maps to a `react-native-svg` component and that its animated
247
+ properties actually change over time.
248
+
249
+ ### Elements
250
+
251
+ | Element | Renders | Notes |
252
+ |---|---|---|
253
+ | `svg`, `g`, `defs` | โœ… | |
254
+ | `rect`, `circle`, `ellipse`, `line`, `path`, `polygon`, `polyline` | โœ… | |
255
+ | `text`, `tspan` | โœ… | content via the `text` attribute |
256
+ | `textPath` | โœ… | see *Text along a path* below |
257
+ | `image` | โœ… | `href` accepts `data:` URIs; remote URLs are blocked by the sanitiser |
258
+ | `use`, `symbol` | โœ… | animated targets are **inlined into real clones** before render โ€” `<use>` does not propagate animation natively in RN |
259
+ | `linearGradient`, `radialGradient`, `stop` | โœ… | |
260
+ | `pattern`, `marker` | โœ… | static geometry verified; complex cases unverified on device |
261
+ | `mask`, `clipPath` | โœ… | |
262
+ | `filter` + all 22 `fe*` primitives | โœ… | `feGaussianBlur`, `feDropShadow`, `feColorMatrix`, `feMerge`, `feComponentTransfer` + `feFunc*`, โ€ฆ Requires the New Architecture; **visual parity with the web is unverified on device** |
263
+ | `foreignObject` | โŒ | blocked by the shared sanitiser (embeds arbitrary host content) |
264
+ | `script` | โŒ | blocked by the shared sanitiser |
265
+
266
+ ### Animatable attributes
267
+
268
+ | Attribute | Animates | Notes |
269
+ |---|---|---|
270
+ | `opacity`, `fill-opacity`, `stroke-opacity` | โœ… | |
271
+ | `fill`, `stroke`, `stop-color` | โœ… | interpolated as RGBA |
272
+ | `stroke-width`, `stroke-dasharray`, `stroke-dashoffset` | โœ… | dash arrays are converted to the numeric form RN expects |
273
+ | `x`, `y`, `width`, `height`, `cx`, `cy`, `r`, `rx`, `ry` | โœ… | |
274
+ | `d` (**path morphing**) | โœ… | keyframes must share command structure |
275
+ | `transform` (unified parts record) | โœ… | `translate`, `rotate`, `skew`, `scale`, `origin` |
276
+ | `translate` / `rotate` / `scale` (legacy per-key form) | โœ… | |
277
+ | `offset` and `stop-color` on gradient stops | โœ… | |
278
+ | filter primitive attrs (e.g. `stdDeviation`) | โœ… | compiles correctly; on-device rendering unverified |
279
+ | `font-size` | โœ… | |
280
+ | Any other numeric SVG attribute | โœ… | interpolated numerically and written straight through |
281
+
282
+ ### Effects (`node.effects`)
283
+
284
+ All effects are materialised by the shared core before rendering, so the RN
285
+ player sees plain nodes. **All are supported:**
286
+
287
+ | Effect | Status | Notes |
288
+ |---|---|---|
289
+ | `transformation` | โœ… | all parts animatable, including `skew` |
290
+ | `repeater` | โœ… | copies materialised as real elements; per-copy params animatable |
291
+ | `maskedBy` | โœ… | including an animated mask source |
292
+ | `clipPath` | โœ… | including animated clip geometry |
293
+ | `trimPath` | โœ… | incl. `offset` and `trimAllAsOne` |
294
+ | `clone` + `retime` | โœ… | each clone keeps its own time shift. `retime.timeCrop` is not implemented (core-wide) |
295
+ | `fillGradient` / `strokeGradient` | โœ… | animated stops **and animated geometry** (`animate.gradientX1`/`Cx`/`R`, โ€ฆ); `gradientTransform` is static (core-wide) |
296
+ | `textPath` | โœ… | incl. animated `startOffset` |
297
+ | `text.useGlyphs` | โœ… | text becomes `<path>` outlines from `definitions.glyphs` โ€” no font needed |
298
+ | `isCombinedShape` | โœ… | |
299
+
300
+ ### Motion, timing and references
301
+
302
+ | Feature | Status | Notes |
303
+ |---|---|---|
304
+ | **Motion along a path** + `autoOrient` | โœ… | **sampled** by the core into plain transform keyframes โ€” `react-native-svg` has no native path motion |
305
+ | **Text along a path** | โœ… two ways | native `textPath` (incl. animated `startOffset`), or **per-letter motion paths** for smooth results โ€” the example app uses the latter, since animating native `startOffset` is janky in `react-native-svg` |
306
+ | Per-property `loop` (incl. `alternate` pingpong) | โœ… | expanded before playback |
307
+ | Easing (cubic-bezier and named refs) | โœ… | baked into the sampled tracks |
308
+ | `definitions.animations` / `easings` / `styles` / `glyphs` | โœ… | named refs resolved; `style` presets applied as props |
309
+ | `node.style` (inline or named) | โœ… | resolved to props โ€” RN has no CSS, so explicit attributes win |
310
+
311
+ ### Playback and triggers
312
+
313
+ | Feature | Status | Notes |
314
+ |---|---|---|
315
+ | `duration`, `delay`, `iterations` (incl. `'infinite'`) | โœ… | |
316
+ | `direction` โ€” all four values | โœ… | |
317
+ | `fill` โ€” `forwards` / `backwards` / `both` / `none` | โœ… | |
318
+ | `resetOnFinish` | โœ… | |
319
+ | `play` / `pause` / `cancel` / `finish` | โœ… | |
320
+ | `setCurrentTime` โ€” seek, including **while playing** | โœ… | playback continues from the new position |
321
+ | `setPlaybackRate` โ€” faster, slower and **reverse** (negative) | โœ… | composes with `direction` |
322
+ | Trigger `load` / `programmatic` | โœ… | |
323
+ | Trigger `click` | โœ… | wrapped in a `Pressable`; a second tap applies `outAction` |
324
+ | Trigger `scrollIntoView` | โœ… | visibility sampled by measuring against the window (RN has no `IntersectionObserver`); honours `scrollIntoViewThreshold` and `outAction` |
325
+ | Trigger `mouseOver` | โŒ | no touch equivalent โ€” use `click`, or drive `play` yourself |
326
+ | `frameRate` | n/a | reanimated runs at the display refresh rate; use `compileTracks({sampleRate})` to trade memory for temporal precision |
327
+ | `mode` (`webapi` / `frames`) | n/a | there is no Web Animations API on RN โ€” playback is always native-driven |
328
+
329
+ ### Known limitations
330
+
331
+ - **On-device verification is incomplete.** The pipeline, prop mapping and
332
+ driving logic are covered by unit tests and were exercised end-to-end
333
+ through `react-native-web`; the native reanimated โ†” `react-native-svg` prop
334
+ bridge (notably filters and `strokeDasharray`) still needs checking on real
335
+ iOS/Android.
336
+ - **`retime.timeCrop`** and **animated `gradientTransform`** are unimplemented
337
+ in the core, so they are unavailable here too.
338
+ - **`mouseOver`** has no touch analogue and will not be implemented.
339
+ - **Text on a closed path is worked around, not fixed.** react-native-svg's
340
+ native text-on-path layout crashes the app (an `NSRangeException` on iOS) when
341
+ a `<textPath>` has a non-zero `startOffset` on a *closed* path: it bounds
342
+ glyph placement by `startOffset โ€ฆ startOffset + pathLength` instead of
343
+ `0 โ€ฆ pathLength`, so glyphs past the end of the path reach a lookup that
344
+ returns `NSNotFound`. On native the player gives such a `<textPath>` its own
345
+ open copy of the path (`openClosedTextPathTargets`), which restores the
346
+ correct bounds. Text that would have wrapped around past the end of the loop
347
+ is clipped instead. Web is unaffected and left untouched.
348
+
349
+ ## Monorepo setup
350
+
351
+ pnpm and yarn workspaces can install **two physical copies** of a native package
352
+ when peer versions differ even slightly. If the copy this player imports is not
353
+ the copy the app registered natively, you get:
354
+
355
+ ```
356
+ Invariant Violation: View config getter callback for component `RNSVGLine`
357
+ must be a function (received `undefined`)
358
+ ```
359
+
360
+ Two things prevent it:
361
+
362
+ 1. Keep `@types/react`, `react` and `react-native` versions aligned across every
363
+ workspace package.
364
+ 2. Force single instances in `metro.config.js`:
365
+
366
+ ```js
367
+ const SINGLETONS = ['react', 'react-dom', 'react-native', 'react-native-svg',
368
+ 'react-native-reanimated', 'react-native-worklets'];
369
+
370
+ const base = config.resolver.resolveRequest;
371
+ config.resolver.resolveRequest = (context, moduleName, platform) => {
372
+ if (SINGLETONS.some(n => moduleName === n || moduleName.startsWith(n + '/'))) {
373
+ return context.resolveRequest(
374
+ { ...context, originModulePath: path.join(projectRoot, 'index.js') },
375
+ moduleName, platform);
376
+ }
377
+ return (base ?? context.resolveRequest)(context, moduleName, platform);
378
+ };
379
+ ```
380
+
381
+ A complete config is in
382
+ [`examples/react-native-preview-player/metro.config.js`](../../examples/react-native-preview-player/metro.config.js).
383
+
384
+ ## Advanced exports
385
+
386
+ For custom rendering or diagnostics:
387
+
388
+ - `renderRnNode(node, opts)` โ€” render a `PxNode` tree to `react-native-svg`
389
+ elements, with a `decorate` hook for wrapping animated elements.
390
+ - `compileTracks(doc, { sampleRate, maxSamples, native })` โ€” build the sampled
391
+ tracks yourself; `sampleRate` trades memory for temporal precision
392
+ (default 60/s). `native` selects the value form: the default is the SVG/DOM
393
+ one, `true` gives what the native views want (a `transform` becomes a
394
+ 6-number matrix).
395
+ - `sampleProps(tracks, tMs, stepMs, sampleCount, native)` โ€” the worklet-safe
396
+ lookup. `native` renames `transform` to the native views' `matrix`; pass it
397
+ only for values going through reanimated's animated-props path on a device.
398
+ - `openClosedTextPathTargets(doc, warnings?)` โ€” the closed-path `<textPath>`
399
+ workaround described under [Known limitations](#known-limitations).
400
+ - `PxRnErrorBoundary` โ€” the boundary the component wraps itself in.
401
+ - `RN_SVG_COMPONENTS`, `toRnPropName` โ€” the tag and attribute maps.
402
+
403
+ ## Example app
404
+
405
+ A full preview player with six animations and transport controls:
406
+
407
+ ```bash
408
+ pnpm example:rn # or: cd examples/react-native-preview-player && npx expo start
409
+ ```
410
+
411
+ See [`examples/react-native-preview-player`](../../examples/react-native-preview-player).
412
+
413
+ ## License
414
+
415
+ [MIT](../../LICENSE) ยฉ [Pixodesk](https://pixodesk.com)