diff --git a/apps/docs/docs/animations/reanimated3.md b/apps/docs/docs/animations/reanimated3.md
index d26f142bfb..aa966b9d9d 100644
--- a/apps/docs/docs/animations/reanimated3.md
+++ b/apps/docs/docs/animations/reanimated3.md
@@ -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 (
+
+ );
+};
+```
+
+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.
diff --git a/apps/example/src/Examples/Reanimated/SharedValueComparison.tsx b/apps/example/src/Examples/Reanimated/SharedValueComparison.tsx
new file mode 100644
index 0000000000..483035beee
--- /dev/null
+++ b/apps/example/src/Examples/Reanimated/SharedValueComparison.tsx
@@ -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;
+ 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 ;
+};
+
+const DerivedScene = (props: SceneProps) => (
+ <>
+ {new Array(COUNT).fill(0).map((_, i) => (
+
+ ))}
+ >
+);
+
+// "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 = {};
+ 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) => (
+
+ ))}
+ >
+ );
+};
+
+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 (
+
+
+ {`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.`}
+
+
+ setSingle(true)}
+ >
+
+ Grouped
+
+
+ setSingle(false)}
+ >
+
+ Per-prop
+
+
+
+
+ {`Reanimated mappers (animation subscriptions): ${mapperCount}`}
+
+
+
+ );
+};
+
+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,
+ },
+});
diff --git a/apps/example/src/Examples/Reanimated/index.tsx b/apps/example/src/Examples/Reanimated/index.tsx
index ca9786bd07..a75be1e16d 100644
--- a/apps/example/src/Examples/Reanimated/index.tsx
+++ b/apps/example/src/Examples/Reanimated/index.tsx
@@ -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";
@@ -18,6 +19,7 @@ export const ReanimatedExample: React.FC = () => {
+
);
};
diff --git a/packages/skia/cpp/api/recorder/Convertor.h b/packages/skia/cpp/api/recorder/Convertor.h
index 0e58c16e4e..70f070325b 100644
--- a/packages/skia/cpp/api/recorder/Convertor.h
+++ b/packages/skia/cpp/api/recorder/Convertor.h
@@ -69,34 +69,76 @@ template struct unwrap_optional> {
template
T getPropertyValue(jsi::Runtime &runtime, const jsi::Value &value);
-// Base template for convertProperty
template
-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(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(runtime, value);
};
+
variables[name].push_back(conv);
conv(runtime, sharedValue);
- } else {
- target = getPropertyValue(runtime, property);
+ return;
}
+
+ target = getPropertyValue(runtime, prop);
}
+
// Main convertProperty template
template
void convertProperty(jsi::Runtime &runtime, const jsi::Object &object,
diff --git a/packages/skia/src/renderer/processors/Animations/Animations.ts b/packages/skia/src/renderer/processors/Animations/Animations.ts
index 1fd34c79f1..229b155a40 100644
--- a/packages/skia/src/renderer/processors/Animations/Animations.ts
+++ b/packages/skia/src/renderer/processors/Animations/Animations.ts
@@ -1,4 +1,37 @@
-export type AnimatedProp = T | { value: T };
+import type { SharedValue } from "react-native-reanimated";
+
+/**
+ * Binds a prop to a single key of a "grouped" shared value: one shared value
+ * whose `.value` is an object can drive many props (one key each) instead of
+ * requiring a separate shared value per prop.
+ *
+ * Created via {@link select}. `T` is the type of the selected value; `__type`
+ * is a phantom marker carrying that type and is never present at runtime.
+ */
+export type SharedValueSelector = {
+ __sv: { value: Record };
+ __key: string;
+ readonly __type?: T;
+};
+
+export type AnimatedProp = T | { value: T } | SharedValueSelector;
+
+/**
+ * Selects a single key of a shared value so it can drive an animated prop.
+ * This lets one shared value (whose value is an object) drive multiple props
+ * — one per key — instead of using a separate shared value for each.
+ *
+ * @example
+ * const data = useSharedValue({ x: 0, y: 0, r: 10 });
+ *
+ */
+export const select = (
+ value: SharedValue,
+ key: K
+): SharedValueSelector => ({
+ __sv: value as unknown as { value: Record },
+ __key: key,
+});
export type AnimatedProps = {
[K in keyof T]: K extends "children"
diff --git a/packages/skia/src/sksg/Recorder/Core.ts b/packages/skia/src/sksg/Recorder/Core.ts
index da0106467a..ae7fc00130 100644
--- a/packages/skia/src/sksg/Recorder/Core.ts
+++ b/packages/skia/src/sksg/Recorder/Core.ts
@@ -23,6 +23,7 @@ import type {
DrawingNodeProps,
SkottieProps,
} from "../../dom/types";
+import { isSharedValueSelector } from "../utils";
export enum CommandType {
// Context
@@ -79,7 +80,13 @@ export const materializeCommand = (command: any) => {
const newProps = { ...command.props };
if (command.animatedProps) {
for (const key in command.animatedProps) {
- newProps[key] = command.animatedProps[key].value;
+ const entry = command.animatedProps[key];
+ if (isSharedValueSelector(entry)) {
+ const group = entry.__sv.value as Record;
+ newProps[key] = group[entry.__key];
+ } else {
+ newProps[key] = entry.value;
+ }
}
}
return { ...command, props: newProps };
diff --git a/packages/skia/src/sksg/Recorder/ReanimatedRecorder.ts b/packages/skia/src/sksg/Recorder/ReanimatedRecorder.ts
index c18d5e272a..2421981a28 100644
--- a/packages/skia/src/sksg/Recorder/ReanimatedRecorder.ts
+++ b/packages/skia/src/sksg/Recorder/ReanimatedRecorder.ts
@@ -31,7 +31,7 @@ import type {
DrawingNodeProps,
} from "../../dom/types";
import type { AnimatedProps } from "../../renderer";
-import { isSharedValue } from "../utils";
+import { isSharedValue, isSharedValueSelector } from "../utils";
/**
* Currently the recorder only work if the GPU resources (e.g Images) are owned by the main thread.
@@ -55,6 +55,15 @@ export class ReanimatedRecorder implements BaseRecorder {
return;
}
Object.values(props).forEach((value) => {
+ if (isSharedValueSelector(value) && !this.values.has(value.__sv)) {
+ const sv = value.__sv;
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+ sv.name = `variable${this.values.size}`;
+ this.values.add(sv as SharedValue);
+ return;
+ }
+
if (isSharedValue(value) && !this.values.has(value)) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
diff --git a/packages/skia/src/sksg/Recorder/Recorder.ts b/packages/skia/src/sksg/Recorder/Recorder.ts
index 1a0b548791..9509b43524 100644
--- a/packages/skia/src/sksg/Recorder/Recorder.ts
+++ b/packages/skia/src/sksg/Recorder/Recorder.ts
@@ -30,7 +30,7 @@ import type {
DrawingNodeProps,
} from "../../dom/types";
import type { AnimatedProps } from "../../renderer";
-import { isSharedValue } from "../utils";
+import { isSharedValue, isSharedValueSelector } from "../utils";
import { isColorFilter, isImageFilter, isPathEffect, isShader } from "../Node";
import type { SkPaint, BaseRecorder } from "../../skia/types";
@@ -64,7 +64,7 @@ export class Recorder implements BaseRecorder {
}
private processProps(props: Record) {
- const animatedProps: Record> = {};
+ const animatedProps: Record = {};
let hasAnimatedProps = false;
for (const key in props) {
@@ -73,6 +73,10 @@ export class Recorder implements BaseRecorder {
this.animationValues.add(prop);
animatedProps[key] = prop;
hasAnimatedProps = true;
+ } else if (isSharedValueSelector(prop)) {
+ this.animationValues.add(prop.__sv);
+ animatedProps[key] = prop;
+ hasAnimatedProps = true;
}
}
@@ -208,7 +212,7 @@ export class Recorder implements BaseRecorder {
boxProps: AnimatedProps,
shadows: {
props: BoxShadowProps;
- animatedProps?: Record>;
+ animatedProps?: Record;
}[]
) {
shadows.forEach((shadow) => {
diff --git a/packages/skia/src/sksg/__tests__/SharedValueSelector.spec.ts b/packages/skia/src/sksg/__tests__/SharedValueSelector.spec.ts
new file mode 100644
index 0000000000..fe26a10a51
--- /dev/null
+++ b/packages/skia/src/sksg/__tests__/SharedValueSelector.spec.ts
@@ -0,0 +1,96 @@
+import type { SharedValue } from "react-native-reanimated";
+
+import { select } from "../../renderer/processors/Animations/Animations";
+import { isSharedValueSelector } from "../utils";
+import { materializeCommand, CommandType } from "../Recorder/Core";
+import { Recorder } from "../Recorder/Recorder";
+
+// Minimal stand-in for a Reanimated shared value: the guard only checks the
+// `_isReanimatedSharedValue` flag, so we don't need the Reanimated runtime.
+const makeSharedValue = (value: T) =>
+ ({ _isReanimatedSharedValue: true, value }) as unknown as SharedValue;
+
+describe("isSharedValueSelector", () => {
+ it("returns true for a selector", () => {
+ const sv = makeSharedValue({ x: 1 });
+ expect(isSharedValueSelector({ __sv: sv, __key: "x" })).toBe(true);
+ });
+
+ it("returns false for a plain shared value", () => {
+ expect(isSharedValueSelector(makeSharedValue(1))).toBe(false);
+ });
+
+ it("returns false for invalid shapes", () => {
+ expect(isSharedValueSelector(null)).toBe(false);
+ expect(isSharedValueSelector(42)).toBe(false);
+ expect(isSharedValueSelector({})).toBe(false);
+ // __sv missing
+ expect(isSharedValueSelector({ __key: "x" })).toBe(false);
+ // __sv is not a shared value
+ expect(isSharedValueSelector({ __sv: { value: {} }, __key: "x" })).toBe(
+ false
+ );
+ // __key missing
+ expect(isSharedValueSelector({ __sv: makeSharedValue({}) })).toBe(false);
+ // __key is not a string
+ expect(isSharedValueSelector({ __sv: makeSharedValue({}), __key: 3 })).toBe(
+ false
+ );
+ });
+});
+
+describe("select", () => {
+ it("creates a selector the guard recognizes", () => {
+ const sv = makeSharedValue({ x: 10, y: 20 });
+ const selector = select(sv, "x");
+ expect(selector).toEqual({ __sv: sv, __key: "x" });
+ expect(isSharedValueSelector(selector)).toBe(true);
+ });
+});
+
+describe("materializeCommand", () => {
+ it("resolves a plain shared value via .value", () => {
+ const command = {
+ type: CommandType.DrawCircle,
+ props: { r: 0 },
+ animatedProps: { r: makeSharedValue(10) },
+ };
+ expect(materializeCommand(command).props.r).toBe(10);
+ });
+
+ it("resolves a selector via __sv.value[__key]", () => {
+ const sv = makeSharedValue({ x: 5, y: 8 });
+ const command = {
+ type: CommandType.DrawCircle,
+ props: { cx: 0, cy: 0 },
+ animatedProps: { cx: select(sv, "x"), cy: select(sv, "y") },
+ };
+ const { props } = materializeCommand(command);
+ expect(props.cx).toBe(5);
+ expect(props.cy).toBe(8);
+ });
+});
+
+describe("Recorder selector collection (web path)", () => {
+ it("registers one shared value for many selectors of the same value", () => {
+ const sv = makeSharedValue({ x: 1, y: 2 });
+ const recorder = new Recorder();
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const props: any = { cx: select(sv, "x"), cy: select(sv, "y"), r: 3 };
+ recorder.drawCircle(props);
+ const { animationValues } = recorder.getRecording();
+ expect(animationValues.size).toBe(1);
+ expect([...animationValues][0]).toBe(sv);
+ });
+
+ it("registers a plain shared value and a selector's underlying value", () => {
+ const grouped = makeSharedValue({ x: 1 });
+ const plain = makeSharedValue(5);
+ const recorder = new Recorder();
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const props: any = { cx: select(grouped, "x"), r: plain };
+ recorder.drawCircle(props);
+ const { animationValues } = recorder.getRecording();
+ expect(animationValues.size).toBe(2);
+ });
+});
diff --git a/packages/skia/src/sksg/utils.ts b/packages/skia/src/sksg/utils.ts
index 66945d036d..0b6c08493a 100644
--- a/packages/skia/src/sksg/utils.ts
+++ b/packages/skia/src/sksg/utils.ts
@@ -10,6 +10,17 @@ export const isSharedValue = (
return (value as Record)?._isReanimatedSharedValue === true;
};
+export const isSharedValueSelector = (
+ value: unknown
+): value is { __sv: SharedValue; __key: string } => {
+ "worklet";
+ if (!value || typeof value !== "object") {
+ return false;
+ }
+ const obj = value as Record;
+ return isSharedValue(obj.__sv) && typeof obj.__key === "string";
+};
+
export const materialize = (props: T) => {
"worklet";
const result: T = Object.assign({}, props);
@@ -17,6 +28,9 @@ export const materialize = (props: T) => {
const value = result[key];
if (isSharedValue(value)) {
result[key] = value.value as never;
+ } else if (isSharedValueSelector(value)) {
+ const group = value.__sv.value as Record;
+ result[key] = group[value.__key] as never;
}
});
return result;