From 89a6429f88890dff87b085e107e31ee4f01850ca Mon Sep 17 00:00:00 2001 From: Grassper Date: Sat, 20 Jun 2026 18:37:35 +0530 Subject: [PATCH 1/6] =?UTF-8?q?feat(=F0=9F=90=8E):=20support=20driving=20a?= =?UTF-8?q?nimated=20props=20from=20keys=20of=20a=20single=20shared=20valu?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously each animated prop required its own shared value. This adds a wrapper ({ __sv, __key }) so a single shared value whose `.value` is an object can drive multiple props, each reading one key. The Reanimated recorder registers the underlying shared value once (deduped on __sv), and the native converter resolves `sharedValue.value[__key]` per prop on the UI thread. --- packages/skia/cpp/api/recorder/Convertor.h | 72 +++++++++++++++---- .../processors/Animations/Animations.ts | 7 +- .../src/sksg/Recorder/ReanimatedRecorder.ts | 25 ++++--- packages/skia/src/sksg/utils.ts | 7 ++ 4 files changed, 87 insertions(+), 24 deletions(-) diff --git a/packages/skia/cpp/api/recorder/Convertor.h b/packages/skia/cpp/api/recorder/Convertor.h index efd4c61f2f..b8e5342201 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..32aebffede 100644 --- a/packages/skia/src/renderer/processors/Animations/Animations.ts +++ b/packages/skia/src/renderer/processors/Animations/Animations.ts @@ -1,4 +1,9 @@ -export type AnimatedProp = T | { value: T }; +export type WrappedSharedValue = { + __sv: { value: T }; + __key: string; +}; + +export type AnimatedProp = T | { value: T } | WrappedSharedValue; export type AnimatedProps = { [K in keyof T]: K extends "children" diff --git a/packages/skia/src/sksg/Recorder/ReanimatedRecorder.ts b/packages/skia/src/sksg/Recorder/ReanimatedRecorder.ts index c18d5e272a..900ebc59a0 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, isWrappedSharedValue } 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 (isWrappedSharedValue(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 @@ -104,7 +113,7 @@ export class ReanimatedRecorder implements BaseRecorder { pushPathEffect( pathEffectType: NodeType, - props: AnimatedProps + props: AnimatedProps, ): void { this.processAnimationValues(props); this.recorder.pushPathEffect(pathEffectType, props); @@ -112,7 +121,7 @@ export class ReanimatedRecorder implements BaseRecorder { pushImageFilter( imageFilterType: NodeType, - props: AnimatedProps + props: AnimatedProps, ): void { this.processAnimationValues(props); this.recorder.pushImageFilter(imageFilterType, props); @@ -120,7 +129,7 @@ export class ReanimatedRecorder implements BaseRecorder { pushColorFilter( colorFilterType: NodeType, - props: AnimatedProps + props: AnimatedProps, ): void { this.processAnimationValues(props); this.recorder.pushColorFilter(colorFilterType, props); @@ -129,7 +138,7 @@ export class ReanimatedRecorder implements BaseRecorder { pushShader( shaderType: NodeType, props: AnimatedProps, - children: number + children: number, ): void { this.processAnimationValues(props); this.recorder.pushShader(shaderType, props, children); @@ -177,12 +186,12 @@ export class ReanimatedRecorder implements BaseRecorder { boxProps: AnimatedProps, shadows: { props: BoxShadowProps; - }[] + }[], ): void { this.processAnimationValues(boxProps); shadows.forEach((shadow) => { this.processAnimationValues( - shadow.props as AnimatedProps + shadow.props as AnimatedProps, ); }); this.recorder.drawBox( @@ -190,7 +199,7 @@ export class ReanimatedRecorder implements BaseRecorder { // TODO: Fix this type BaseRecorder.drawBox() // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error - shadows.map((s) => s.props) + shadows.map((s) => s.props), ); } diff --git a/packages/skia/src/sksg/utils.ts b/packages/skia/src/sksg/utils.ts index 66945d036d..d7527eb2ed 100644 --- a/packages/skia/src/sksg/utils.ts +++ b/packages/skia/src/sksg/utils.ts @@ -10,6 +10,13 @@ export const isSharedValue = ( return (value as Record)?._isReanimatedSharedValue === true; }; +export const isWrappedSharedValue = (value: unknown): value is { __sv: SharedValue; __key: string } => { + "worklet"; + if (!value || typeof value !== "object") return false; + const obj = value as any; + return isSharedValue(obj.__sv) && typeof obj.__key === "string"; +}; + export const materialize = (props: T) => { "worklet"; const result: T = Object.assign({}, props); From 948f752a58de9aa401991b33a60fb83b9594d476 Mon Sep 17 00:00:00 2001 From: Grassper Date: Sat, 20 Jun 2026 20:01:46 +0530 Subject: [PATCH 2/6] =?UTF-8?q?feat(=F0=9F=90=8E):=20add=20SharedValueComp?= =?UTF-8?q?arison=20example=20for=20single=20shared=20value?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Reanimated/SharedValueComparison.tsx | 217 ++++++++++++++++++ .../example/src/Examples/Reanimated/index.tsx | 2 + 2 files changed, 219 insertions(+) create mode 100644 apps/example/src/Examples/Reanimated/SharedValueComparison.tsx diff --git a/apps/example/src/Examples/Reanimated/SharedValueComparison.tsx b/apps/example/src/Examples/Reanimated/SharedValueComparison.tsx new file mode 100644 index 0000000000..7debe9090c --- /dev/null +++ b/apps/example/src/Examples/Reanimated/SharedValueComparison.tsx @@ -0,0 +1,217 @@ +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 } 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"]; + +// Wraps a single (shared OR derived) value so one of its object keys can +// drive a prop. This lets many props share ONE value instead of needing a +// separate derived value each. The cast hides the internal { __sv, __key } +// representation from the call site. +const pick = , K extends keyof T>( + sv: SharedValue, + key: K +) => ({ __sv: sv, __key: key }) as unknown as T[K]; + +// 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}`} + + + + {single ? ( + + ) : ( + + )} + + + ); +}; + +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 = () => { + ); }; From e57441d326c57c5391f890fd69db11d1208ef054 Mon Sep 17 00:00:00 2001 From: Grassper Date: Sat, 20 Jun 2026 20:33:16 +0530 Subject: [PATCH 3/6] =?UTF-8?q?refactor(=F0=9F=90=8E):=20rename=20to=20sel?= =?UTF-8?q?ect/SharedValueSelector=20and=20export=20public=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Reanimated/SharedValueComparison.tsx | 17 +++------- .../processors/Animations/Animations.ts | 34 +++++++++++++++++-- .../src/sksg/Recorder/ReanimatedRecorder.ts | 18 +++++----- packages/skia/src/sksg/utils.ts | 10 ++++-- 4 files changed, 51 insertions(+), 28 deletions(-) diff --git a/apps/example/src/Examples/Reanimated/SharedValueComparison.tsx b/apps/example/src/Examples/Reanimated/SharedValueComparison.tsx index 7debe9090c..483035beee 100644 --- a/apps/example/src/Examples/Reanimated/SharedValueComparison.tsx +++ b/apps/example/src/Examples/Reanimated/SharedValueComparison.tsx @@ -12,7 +12,7 @@ import { useFrameCallback, useSharedValue, } from "react-native-reanimated"; -import { Canvas, Circle, Fill } from "@shopify/react-native-skia"; +import { Canvas, Circle, Fill, select } from "@shopify/react-native-skia"; import { AnimationDemo } from "./Components"; @@ -34,15 +34,6 @@ const BASE_RADIUS = 6; const CANVAS_HEIGHT = 280; const COLORS = ["#8556E5", "#3EB489", "#FF7A1A", "#E5563F"]; -// Wraps a single (shared OR derived) value so one of its object keys can -// drive a prop. This lets many props share ONE value instead of needing a -// separate derived value each. The cast hides the internal { __sv, __key } -// representation from the call site. -const pick = , K extends keyof T>( - sv: SharedValue, - key: K -) => ({ __sv: sv, __key: key }) as unknown as T[K]; - // 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) => { @@ -110,9 +101,9 @@ const SingleValueScene = ({ clock, cx, cy, orbit }: SceneProps) => { {new Array(COUNT).fill(0).map((_, i) => ( ))} diff --git a/packages/skia/src/renderer/processors/Animations/Animations.ts b/packages/skia/src/renderer/processors/Animations/Animations.ts index 32aebffede..229b155a40 100644 --- a/packages/skia/src/renderer/processors/Animations/Animations.ts +++ b/packages/skia/src/renderer/processors/Animations/Animations.ts @@ -1,9 +1,37 @@ -export type WrappedSharedValue = { - __sv: { 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 } | WrappedSharedValue; +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/ReanimatedRecorder.ts b/packages/skia/src/sksg/Recorder/ReanimatedRecorder.ts index 900ebc59a0..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, isWrappedSharedValue } 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,7 +55,7 @@ export class ReanimatedRecorder implements BaseRecorder { return; } Object.values(props).forEach((value) => { - if (isWrappedSharedValue(value) && !this.values.has(value.__sv)) { + if (isSharedValueSelector(value) && !this.values.has(value.__sv)) { const sv = value.__sv; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error @@ -113,7 +113,7 @@ export class ReanimatedRecorder implements BaseRecorder { pushPathEffect( pathEffectType: NodeType, - props: AnimatedProps, + props: AnimatedProps ): void { this.processAnimationValues(props); this.recorder.pushPathEffect(pathEffectType, props); @@ -121,7 +121,7 @@ export class ReanimatedRecorder implements BaseRecorder { pushImageFilter( imageFilterType: NodeType, - props: AnimatedProps, + props: AnimatedProps ): void { this.processAnimationValues(props); this.recorder.pushImageFilter(imageFilterType, props); @@ -129,7 +129,7 @@ export class ReanimatedRecorder implements BaseRecorder { pushColorFilter( colorFilterType: NodeType, - props: AnimatedProps, + props: AnimatedProps ): void { this.processAnimationValues(props); this.recorder.pushColorFilter(colorFilterType, props); @@ -138,7 +138,7 @@ export class ReanimatedRecorder implements BaseRecorder { pushShader( shaderType: NodeType, props: AnimatedProps, - children: number, + children: number ): void { this.processAnimationValues(props); this.recorder.pushShader(shaderType, props, children); @@ -186,12 +186,12 @@ export class ReanimatedRecorder implements BaseRecorder { boxProps: AnimatedProps, shadows: { props: BoxShadowProps; - }[], + }[] ): void { this.processAnimationValues(boxProps); shadows.forEach((shadow) => { this.processAnimationValues( - shadow.props as AnimatedProps, + shadow.props as AnimatedProps ); }); this.recorder.drawBox( @@ -199,7 +199,7 @@ export class ReanimatedRecorder implements BaseRecorder { // TODO: Fix this type BaseRecorder.drawBox() // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error - shadows.map((s) => s.props), + shadows.map((s) => s.props) ); } diff --git a/packages/skia/src/sksg/utils.ts b/packages/skia/src/sksg/utils.ts index d7527eb2ed..18442f3203 100644 --- a/packages/skia/src/sksg/utils.ts +++ b/packages/skia/src/sksg/utils.ts @@ -10,10 +10,14 @@ export const isSharedValue = ( return (value as Record)?._isReanimatedSharedValue === true; }; -export const isWrappedSharedValue = (value: unknown): value is { __sv: SharedValue; __key: string } => { +export const isSharedValueSelector = ( + value: unknown +): value is { __sv: SharedValue; __key: string } => { "worklet"; - if (!value || typeof value !== "object") return false; - const obj = value as any; + if (!value || typeof value !== "object") { + return false; + } + const obj = value as Record; return isSharedValue(obj.__sv) && typeof obj.__key === "string"; }; From 48b7ce4bce71094d3cd2cb88cb793d1bbe136473 Mon Sep 17 00:00:00 2001 From: Grassper Date: Sat, 20 Jun 2026 21:46:32 +0530 Subject: [PATCH 4/6] =?UTF-8?q?feat(=F0=9F=8C=8D):=20support=20grouped=20s?= =?UTF-8?q?hared=20values=20(select)=20on=20RN=20Web?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/skia/src/sksg/Recorder/Core.ts | 9 ++++++++- packages/skia/src/sksg/Recorder/Recorder.ts | 10 +++++++--- packages/skia/src/sksg/utils.ts | 3 +++ 3 files changed, 18 insertions(+), 4 deletions(-) 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/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/utils.ts b/packages/skia/src/sksg/utils.ts index 18442f3203..0b6c08493a 100644 --- a/packages/skia/src/sksg/utils.ts +++ b/packages/skia/src/sksg/utils.ts @@ -28,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; From 7c1f588414fcf4f54124da23a4cd0683fcf5c2f7 Mon Sep 17 00:00:00 2001 From: Grassper Date: Sun, 21 Jun 2026 20:14:49 +0530 Subject: [PATCH 5/6] =?UTF-8?q?docs(=F0=9F=93=9D):=20document=20select()?= =?UTF-8?q?=20for=20grouped=20shared=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/docs/docs/animations/reanimated3.md | 41 ++++++++++++++++++++++++ 1 file changed, 41 insertions(+) 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. From ff22e6ccb918e4e85fcf12915c37df18824d3b65 Mon Sep 17 00:00:00 2001 From: Grassper Date: Sun, 21 Jun 2026 20:25:55 +0530 Subject: [PATCH 6/6] =?UTF-8?q?chore(=F0=9F=A7=AA):=20add=20unit=20tests?= =?UTF-8?q?=20for=20select=20/=20SharedValueSelector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/SharedValueSelector.spec.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 packages/skia/src/sksg/__tests__/SharedValueSelector.spec.ts 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); + }); +});