Skip to content
Open
41 changes: 41 additions & 0 deletions apps/docs/docs/animations/reanimated3.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,47 @@ export const HelloWorld = () => {

We offer some [Skia specific animation hooks](/docs/animations/hooks), especially for paths.

## Driving multiple props from a single shared value

Every animated prop can be backed by its own shared or derived value. When many props derive from the same source β€” for example several elements driven by one real-time data stream β€” this usually means one `useDerivedValue` per prop, and therefore one Reanimated subscription (mapper) per prop.

`select` lets a single shared value, whose value is an object, drive multiple props by binding each prop to one key of that object. Reanimated then subscribes once, no matter how many props read from it.

```tsx
import { Canvas, Circle, select } from "@shopify/react-native-skia";
import { useSharedValue, useFrameCallback } from "react-native-reanimated";

export const Grouped = () => {
// A single shared value holds every animated field.
const circle = useSharedValue({ cx: 0, cy: 0, r: 10 });
useFrameCallback(({ timeSinceFirstFrame }) => {
"worklet";
const t = timeSinceFirstFrame / 1000;
circle.value = {
cx: 100 + Math.cos(t) * 50,
cy: 100 + Math.sin(t) * 50,
r: 10 + (Math.sin(t * 2) + 1) * 5,
};
}, true);
return (
<Canvas style={{ flex: 1 }}>
<Circle
cx={select(circle, "cx")}
cy={select(circle, "cy")}
r={select(circle, "r")}
color="cyan"
/>
</Canvas>
);
};
```

Here one shared value drives three props with a single subscription, instead of three derived values with three subscriptions.

:::note
Reanimated only animates values assigned directly to a shared value's `.value`, not values nested inside an object. You therefore cannot place `withTiming`/`withSpring` on a key (e.g. `{ cx: withTiming(100) }`); assign plain values to the object (as above), or build the object in a `useDerivedValue` from individually animated shared values.
:::

## Colors

For colors, React Native Skia uses a different storage format from Reanimated.
Expand Down
208 changes: 208 additions & 0 deletions apps/example/src/Examples/Reanimated/SharedValueComparison.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import React, { useState } from "react";
import {
Pressable,
StyleSheet,
Text,
View,
useWindowDimensions,
} from "react-native";
import type { SharedValue } from "react-native-reanimated";
import {
useDerivedValue,
useFrameCallback,
useSharedValue,
} from "react-native-reanimated";
import { Canvas, Circle, Fill, select } from "@shopify/react-native-skia";

import { AnimationDemo } from "./Components";

// Demonstrates driving many animated props from a SINGLE shared value
// instead of one derived value per prop. Both modes render the exact same
// animation; only the wiring differs:
//
// β€’ Grouped β€” one value holds every coordinate; each prop reads its own
// key from it. Reanimated subscribes once (1 mapper).
// β€’ Per-prop β€” every prop gets its own derived value, so Reanimated
// subscribes once per prop (COUNT * 3 mappers).
//
// Grouping wins on:
// - performance fewer UI-thread mappers recomputing each frame
// - maintainability one source of truth per component, no hook-per-prop
// - locality a component's animated state lives in one value
const COUNT = 24;
const BASE_RADIUS = 6;
const CANVAS_HEIGHT = 280;
const COLORS = ["#8556E5", "#3EB489", "#FF7A1A", "#E5563F"];

// Motion of dot `i` at time `t`, shared by both approaches so the animation
// is visually identical β€” only the wiring underneath differs.
const dotX = (t: number, i: number, cx: number, orbit: number) => {
"worklet";
const a = t + (i / COUNT) * Math.PI * 2;
const ring = 0.4 + 0.6 * ((i % 6) / 6);
return cx + Math.cos(a) * orbit * ring;
};
const dotY = (t: number, i: number, cy: number, orbit: number) => {
"worklet";
const a = t + (i / COUNT) * Math.PI * 2;
const ring = 0.4 + 0.6 * ((i % 6) / 6);
return cy + Math.sin(a) * orbit * ring;
};
const dotR = (t: number, i: number) => {
"worklet";
return BASE_RADIUS + (Math.sin(t * 2 + i) + 1) * 3;
};

interface SceneProps {
clock: SharedValue<number>;
cx: number;
cy: number;
orbit: number;
}

// "Separate prop" approach: every prop of every dot gets its own derived
// value, i.e. its own Reanimated mapper. COUNT * 3 mappers total.
const DerivedDot = ({
clock,
i,
cx,
cy,
orbit,
}: SceneProps & { i: number }) => {
const x = useDerivedValue(() => dotX(clock.value, i, cx, orbit));
const y = useDerivedValue(() => dotY(clock.value, i, cy, orbit));
const r = useDerivedValue(() => dotR(clock.value, i));
return <Circle cx={x} cy={y} r={r} color={COLORS[i % COLORS.length]} />;
};

const DerivedScene = (props: SceneProps) => (
<>
{new Array(COUNT).fill(0).map((_, i) => (
<DerivedDot key={i} i={i} {...props} />
))}
</>
);

// "Single value" approach: ONE derived value computes every coordinate into
// an object, and each prop reads its own key from it. 1 mapper total.
const SingleValueScene = ({ clock, cx, cy, orbit }: SceneProps) => {
const data = useDerivedValue(() => {
const t = clock.value;
const obj: Record<string, number> = {};
for (let i = 0; i < COUNT; i++) {
obj[`x${i}`] = dotX(t, i, cx, orbit);
obj[`y${i}`] = dotY(t, i, cy, orbit);
obj[`r${i}`] = dotR(t, i);
}
return obj;
});
return (
<>
{new Array(COUNT).fill(0).map((_, i) => (
<Circle
key={i}
cx={select(data, `x${i}`)}
cy={select(data, `y${i}`)}
r={select(data, `r${i}`)}
color={COLORS[i % COLORS.length]}
/>
))}
</>
);
};

export const SharedValueComparison = () => {
const { width } = useWindowDimensions();
const [single, setSingle] = useState(true);

const clock = useSharedValue(0);
useFrameCallback(({ timeSinceFirstFrame }) => {
"worklet";
clock.value = timeSinceFirstFrame / 1000;
}, true);

const cx = width / 2;
const cy = CANVAS_HEIGHT / 2;
const orbit = Math.min(width, 360) / 2 - 24;
const mapperCount = single ? 1 : COUNT * 3;

return (
<AnimationDemo title={"Grouped vs. per-prop animation values"}>
<Text style={styles.subheading}>
{`Same ${COUNT * 3}-prop animation, wired two ways. "Grouped" drives ` +
`every prop from one shared value; "Per-prop" gives each prop its ` +
`own derived value. Watch the Reanimated mapper count change.`}
</Text>
<View style={styles.row}>
<Pressable
style={[styles.btn, single && styles.btnActive]}
onPress={() => setSingle(true)}
>
<Text style={[styles.btnText, single && styles.btnTextActive]}>
Grouped
</Text>
</Pressable>
<Pressable
style={[styles.btn, !single && styles.btnActive]}
onPress={() => setSingle(false)}
>
<Text style={[styles.btnText, !single && styles.btnTextActive]}>
Per-prop
</Text>
</Pressable>
</View>
<Text style={styles.caption}>
{`Reanimated mappers (animation subscriptions): ${mapperCount}`}
</Text>
<Canvas style={styles.canvas}>
<Fill color="white" />
{single ? (
<SingleValueScene clock={clock} cx={cx} cy={cy} orbit={orbit} />
) : (
<DerivedScene clock={clock} cx={cx} cy={cy} orbit={orbit} />
)}
</Canvas>
</AnimationDemo>
);
};

const styles = StyleSheet.create({
subheading: {
color: "#444",
marginBottom: 12,
lineHeight: 18,
},
row: {
flexDirection: "row",
marginBottom: 8,
},
btn: {
flex: 1,
paddingVertical: 8,
borderRadius: 8,
borderWidth: 1,
borderColor: "#8556E5",
marginHorizontal: 4,
alignItems: "center",
},
btnActive: {
backgroundColor: "#8556E5",
},
btnText: {
color: "#8556E5",
fontWeight: "600",
},
btnTextActive: {
color: "white",
},
caption: {
color: "black",
marginBottom: 8,
fontVariant: ["tabular-nums"],
},
canvas: {
height: CANVAS_HEIGHT,
width: "100%" as const,
backgroundColor: "#FEFEFE" as const,
},
});
2 changes: 2 additions & 0 deletions apps/example/src/Examples/Reanimated/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AnimateTextOnPath } from "./AnimateTextOnPath";
import { AnimationWithTouchHandler } from "./AnimationWithTouchHandler";
import { BokehExample } from "./BokehExample";
import { InterpolationWithEasing } from "./InterpolationWithEasing";
import { SharedValueComparison } from "./SharedValueComparison";
import { SimpleAnimation } from "./SimpleAnimation";
import { SpringBackTouchAnimation } from "./SpringBackTouch";

Expand All @@ -18,6 +19,7 @@ export const ReanimatedExample: React.FC = () => {
<AnimateTextOnPath />
<SpringBackTouchAnimation />
<BokehExample />
<SharedValueComparison />
</ScrollView>
);
};
Expand Down
72 changes: 57 additions & 15 deletions packages/skia/cpp/api/recorder/Convertor.h
Original file line number Diff line number Diff line change
Expand Up @@ -69,34 +69,76 @@ template <typename T> struct unwrap_optional<std::optional<T>> {
template <typename T>
T getPropertyValue(jsi::Runtime &runtime, const jsi::Value &value);

// Base template for convertProperty
template <typename T, typename Target>
void convertPropertyImpl(jsi::Runtime &runtime, const jsi::Object &object,
const std::string &propertyName, Target &target,
void convertPropertyImpl(jsi::Runtime &runtime,
const jsi::Object &object,
const std::string &propertyName,
Target &target,
Variables &variables) {
if (!object.hasProperty(runtime, propertyName.c_str())) {
return;
if (!object.hasProperty(runtime, propertyName.c_str())) return;

auto prop = object.getProperty(runtime, propertyName.c_str());

if (prop.isObject()) {
auto wrapper = prop.asObject(runtime);

if (wrapper.hasProperty(runtime, "__sv") && wrapper.hasProperty(runtime, "__key")) {
auto svVal = wrapper.getProperty(runtime, "__sv");
auto keyVal = wrapper.getProperty(runtime, "__key");

if (isSharedValue(runtime, svVal) && keyVal.isString()) {
auto sv = svVal.asObject(runtime);
std::string key = keyVal.asString(runtime).utf8(runtime);

auto name = sv.getProperty(runtime, "name").asString(runtime).utf8(runtime);

auto conv = [target = &target, key](jsi::Runtime &runtime, const jsi::Object &sharedObj) {
auto valueProp = sharedObj.getProperty(runtime, "value");

if (!valueProp.isObject()) {
return;
}

auto valueObj = valueProp.asObject(runtime);
if (!valueObj.hasProperty(runtime, key.c_str())) {
return;
}

auto finalValue = valueObj.getProperty(runtime, key.c_str());
if (finalValue.isUndefined() || finalValue.isNull() ||
(finalValue.isObject() &&
finalValue.asObject(runtime).isFunction(runtime))) {
return;
}

*target = getPropertyValue<T>(runtime, finalValue);
};

variables[name].push_back(conv);
conv(runtime, sv);
return;
}
}
}

auto property = object.getProperty(runtime, propertyName.c_str());
if (isSharedValue(runtime, prop)) {
auto sharedValue = prop.asObject(runtime);
auto name = sharedValue.getProperty(runtime, "name").asString(runtime).utf8(runtime);

if (isSharedValue(runtime, property)) {
auto sharedValue = property.asObject(runtime);
auto name = sharedValue.getProperty(runtime, "name")
.asString(runtime)
.utf8(runtime);
auto conv = [target = &target](jsi::Runtime &runtime,
const jsi::Object &val) {
auto conv = [target = &target](jsi::Runtime &runtime, const jsi::Object &val) {
auto value = val.getProperty(runtime, "value");
*target = getPropertyValue<T>(runtime, value);
};

variables[name].push_back(conv);
conv(runtime, sharedValue);
} else {
target = getPropertyValue<T>(runtime, property);
return;
}

target = getPropertyValue<T>(runtime, prop);
}


// Main convertProperty template
template <typename T>
void convertProperty(jsi::Runtime &runtime, const jsi::Object &object,
Expand Down
Loading
Loading