From 2d8b9976f2d5a334c3570b475ccef5bef73e7ca3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Mon, 2 Mar 2026 17:20:08 +0100 Subject: [PATCH 1/2] Add pattern component support --- .../animations/routes/properties/svg.ts | 4 + .../animatedProperties/svg/Pattern.tsx | 242 ++++++++++++++++++ .../screens/animatedProperties/svg/index.ts | 2 + .../reanimated/CSS/InterpolatorRegistry.cpp | 12 + .../src/css/svg/init.ts | 2 + .../src/css/svg/native/configs/index.ts | 1 + .../src/css/svg/native/configs/pattern.ts | 24 ++ 7 files changed, 287 insertions(+) create mode 100644 apps/common-app/src/apps/css/examples/animations/screens/animatedProperties/svg/Pattern.tsx create mode 100644 packages/react-native-reanimated/src/css/svg/native/configs/pattern.ts diff --git a/apps/common-app/src/apps/css/examples/animations/routes/properties/svg.ts b/apps/common-app/src/apps/css/examples/animations/routes/properties/svg.ts index a730c6fd1b6c..77c0253ee14b 100644 --- a/apps/common-app/src/apps/css/examples/animations/routes/properties/svg.ts +++ b/apps/common-app/src/apps/css/examples/animations/routes/properties/svg.ts @@ -51,6 +51,10 @@ export const svgPropertiesRoutes = { Component: svgAnimatedProperties.Polyline, name: 'Polyline', }, + Pattern: { + Component: svgAnimatedProperties.Pattern, + name: 'Pattern', + }, LinearGradient: { Component: svgAnimatedProperties.LinearGradient, name: 'LinearGradient', diff --git a/apps/common-app/src/apps/css/examples/animations/screens/animatedProperties/svg/Pattern.tsx b/apps/common-app/src/apps/css/examples/animations/screens/animatedProperties/svg/Pattern.tsx new file mode 100644 index 000000000000..5f209bae0fcb --- /dev/null +++ b/apps/common-app/src/apps/css/examples/animations/screens/animatedProperties/svg/Pattern.tsx @@ -0,0 +1,242 @@ +import type { PropsWithChildren } from 'react'; +import Animated, { type CSSAnimationKeyframes } from 'react-native-reanimated'; +// TODO: Fix me +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore RNSVG doesn't export types for web, see https://github.com/software-mansion/react-native-svg/pull/2801 +import type { PatternProps } from 'react-native-svg'; +import { Defs, Pattern, Rect, Svg } from 'react-native-svg'; + +import { ExamplesScreen } from '@/apps/css/components'; +import { colors } from '@/theme'; + +const TypedDefs = Defs as React.ComponentType; +const AnimatedPattern = Animated.createAnimatedComponent(Pattern); + +type PatternExtraProps = { + patternUnits?: 'userSpaceOnUse' | 'objectBoundingBox'; + tileWidth?: number; + tileHeight?: number; +}; + +// With userSpaceOnUse, numeric values are in viewBox user units, but percentages resolve +// relative to the SVG element's physical layout size in screen pixels. These two coordinate +// spaces only align when the SVG element's physical size matches the viewBox dimensions. +const MIXED_UNITS_NOTE = + "**Note:** With `userSpaceOnUse`, mixing absolute and percentage values is not fully supported — the absolute value is in viewBox user units, but the percentage resolves relative to the SVG element's physical layout size in screen pixels. These coordinate spaces only align when the two happen to match."; + +export default function PatternExample() { + return ( + ; + props?: PatternExtraProps; + }, + PatternProps + > + buildAnimation={({ keyframes }) => ({ + animationDirection: 'alternate', + animationDuration: '2s', + animationIterationCount: 'infinite', + animationName: keyframes, + animationTimingFunction: 'ease-in-out', + })} + renderExample={({ animation, props }) => ( + + + + + + + + + + )} + tabs={[ + { + name: 'Position', + sections: [ + { + examples: [ + { + description: + 'With `userSpaceOnUse`, x is in the viewBox coordinate space. Animates from 5 to 20 viewBox user units.', + keyframes: { from: { x: 5 }, to: { x: 20 } }, + props: { + patternUnits: 'userSpaceOnUse', + tileHeight: 20, + tileWidth: 20, + }, + title: 'Numeric (userSpaceOnUse)', + }, + { + description: + "With `objectBoundingBox`, x is a fraction of the bounding box of the element referencing the pattern. `0.05` = 5% and `0.2` = 20% of the referencing element's width.", + keyframes: { from: { x: 0.05 }, to: { x: 0.2 } }, + props: { + patternUnits: 'objectBoundingBox', + tileHeight: 0.2, + tileWidth: 0.2, + }, + title: 'Numeric (objectBoundingBox)', + }, + { + description: + "Percentage values resolve relative to the SVG element's physical layout width in screen pixels.", + keyframes: { from: { x: '5%' }, to: { x: '20%' } }, + title: 'Percentage', + }, + { + description: MIXED_UNITS_NOTE, + keyframes: { from: { x: 15 }, to: { x: '30%' } }, + title: 'Mixed', + }, + ], + title: 'X Offset', + }, + { + examples: [ + { + description: + 'With `userSpaceOnUse`, y is in the viewBox coordinate space. Animates from 5 to 20 viewBox user units.', + keyframes: { from: { y: 5 }, to: { y: 20 } }, + props: { + patternUnits: 'userSpaceOnUse', + tileHeight: 20, + tileWidth: 20, + }, + title: 'Numeric (userSpaceOnUse)', + }, + { + description: + "With `objectBoundingBox`, y is a fraction of the bounding box of the element referencing the pattern. `0.05` = 5% and `0.2` = 20% of the referencing element's height.", + keyframes: { from: { y: 0.05 }, to: { y: 0.2 } }, + props: { + patternUnits: 'objectBoundingBox', + tileHeight: 0.2, + tileWidth: 0.2, + }, + title: 'Numeric (objectBoundingBox)', + }, + { + description: + "Percentage values resolve relative to the SVG element's physical layout height in screen pixels.", + keyframes: { from: { y: '5%' }, to: { y: '20%' } }, + title: 'Percentage', + }, + { + description: MIXED_UNITS_NOTE, + keyframes: { from: { y: 15 }, to: { y: '30%' } }, + title: 'Mixed', + }, + ], + title: 'Y Offset', + }, + ], + }, + { + name: 'Size', + sections: [ + { + examples: [ + { + description: + 'With `userSpaceOnUse`, tile width is in the viewBox coordinate space. Animates from 15 to 30 viewBox user units.', + keyframes: { from: { width: 15 }, to: { width: 30 } }, + props: { patternUnits: 'userSpaceOnUse' }, + title: 'Numeric (userSpaceOnUse)', + }, + { + description: + "With `objectBoundingBox`, tile width is a fraction of the bounding box of the element referencing the pattern. `0.15` = 15% and `0.3` = 30% of the referencing element's width.", + keyframes: { from: { width: 0.15 }, to: { width: 0.3 } }, + props: { patternUnits: 'objectBoundingBox' }, + title: 'Numeric (objectBoundingBox)', + }, + { + description: + "Percentage values resolve relative to the SVG element's physical layout width in screen pixels.", + keyframes: { + from: { width: '15%' }, + to: { width: '30%' }, + }, + title: 'Percentage', + }, + { + description: MIXED_UNITS_NOTE, + keyframes: { from: { width: 15 }, to: { width: '30%' } }, + title: 'Mixed', + }, + ], + title: 'Tile Width', + }, + { + examples: [ + { + description: + 'With `userSpaceOnUse`, tile height is in the viewBox coordinate space. Animates from 15 to 30 viewBox user units.', + keyframes: { from: { height: 15 }, to: { height: 30 } }, + props: { patternUnits: 'userSpaceOnUse' }, + title: 'Numeric (userSpaceOnUse)', + }, + { + description: + "With `objectBoundingBox`, tile height is a fraction of the bounding box of the element referencing the pattern. `0.15` = 15% and `0.3` = 30% of the referencing element's height.", + keyframes: { from: { height: 0.15 }, to: { height: 0.3 } }, + props: { patternUnits: 'objectBoundingBox' }, + title: 'Numeric (objectBoundingBox)', + }, + { + description: + "Percentage values resolve relative to the SVG element's physical layout height in screen pixels.", + keyframes: { + from: { height: '15%' }, + to: { height: '30%' }, + }, + title: 'Percentage', + }, + { + description: MIXED_UNITS_NOTE, + keyframes: { from: { height: 15 }, to: { height: '30%' } }, + title: 'Mixed', + }, + ], + title: 'Tile Height', + }, + ], + }, + { + name: 'Units', + sections: [ + { + examples: [ + { + description: + 'With `patternUnits: "userSpaceOnUse"`, the pattern tile is defined in absolute user coordinates. Animating to `"objectBoundingBox"` reinterprets those same values as fractions of the bounding box, causing an abrupt jump.', + keyframes: { + from: { + height: 20, + patternUnits: 'userSpaceOnUse', + width: 20, + }, + to: { + height: 20, + patternUnits: 'objectBoundingBox', + width: 20, + }, + }, + title: 'patternUnits interpolation', + }, + ], + title: 'Coordinate systems', + }, + ], + }, + ]} + /> + ); +} diff --git a/apps/common-app/src/apps/css/examples/animations/screens/animatedProperties/svg/index.ts b/apps/common-app/src/apps/css/examples/animations/screens/animatedProperties/svg/index.ts index 142349fbc05a..2644e2ccd2dc 100644 --- a/apps/common-app/src/apps/css/examples/animations/screens/animatedProperties/svg/index.ts +++ b/apps/common-app/src/apps/css/examples/animations/screens/animatedProperties/svg/index.ts @@ -6,6 +6,7 @@ import Image from './Image'; import Line from './Line'; import LinearGradient from './LinearGradient'; import Path from './Path'; +import Pattern from './Pattern'; import Polygon from './Polygon'; import Polyline from './Polyline'; import RadialGradient from './RadialGradient'; @@ -21,6 +22,7 @@ export default { Line, LinearGradient, Path, + Pattern, Polygon, Polyline, RadialGradient, diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/CSS/InterpolatorRegistry.cpp b/packages/react-native-reanimated/Common/cpp/reanimated/CSS/InterpolatorRegistry.cpp index d4523f37d0d2..397bc9958b9c 100644 --- a/packages/react-native-reanimated/Common/cpp/reanimated/CSS/InterpolatorRegistry.cpp +++ b/packages/react-native-reanimated/Common/cpp/reanimated/CSS/InterpolatorRegistry.cpp @@ -352,6 +352,17 @@ const InterpolatorFactoriesRecord SVG_PATH_INTERPOLATORS = mergeInterpolators( {"d", value("")}, }}); +const InterpolatorFactoriesRecord SVG_PATTERN_INTERPOLATORS = mergeInterpolators( + {SVG_COMMON_INTERPOLATORS, + InterpolatorFactoriesRecord{ + {"x", value(0, {RelativeTo::Parent, "width"})}, + {"y", value(0, {RelativeTo::Parent, "height"})}, + {"width", value(0, {RelativeTo::Parent, "width"})}, + {"height", value(0, {RelativeTo::Parent, "height"})}, + {"patternUnits", value(0)}, + {"patternContentUnits", value(0)}, + }}); + const InterpolatorFactoriesRecord SVG_RADIAL_GRADIENT_INTERPOLATORS = mergeInterpolators( {SVG_COMMON_INTERPOLATORS, InterpolatorFactoriesRecord{ @@ -398,6 +409,7 @@ ComponentInterpolatorsMap initializeRegistry() { registry["RNSVGLine"] = SVG_LINE_INTERPOLATORS; registry["RNSVGLinearGradient"] = SVG_LINEAR_GRADIENT_INTERPOLATORS; registry["RNSVGPath"] = SVG_PATH_INTERPOLATORS; + registry["RNSVGPattern"] = SVG_PATTERN_INTERPOLATORS; registry["RNSVGRect"] = SVG_RECT_INTERPOLATORS; registry["RNSVGRadialGradient"] = SVG_RADIAL_GRADIENT_INTERPOLATORS; registry["RNSVGText"] = SVG_TEXT_INTERPOLATORS; diff --git a/packages/react-native-reanimated/src/css/svg/init.ts b/packages/react-native-reanimated/src/css/svg/init.ts index 15b387ec8e0d..6bc28b95c7d2 100644 --- a/packages/react-native-reanimated/src/css/svg/init.ts +++ b/packages/react-native-reanimated/src/css/svg/init.ts @@ -11,6 +11,7 @@ import { SVG_LINE_PROPERTIES_CONFIG, SVG_LINEAR_GRADIENT_PROPERTIES_CONFIG, SVG_PATH_PROPERTIES_CONFIG, + SVG_PATTERN_PROPERTIES_CONFIG, SVG_POLYGON_PROPERTIES_CONFIG, SVG_POLYLINE_PROPERTIES_CONFIG, SVG_RADIAL_GRADIENT_PROPERTIES_CONFIG, @@ -32,6 +33,7 @@ export function initSvgCssSupport() { SVG_RADIAL_GRADIENT_PROPERTIES_CONFIG ); registerComponentPropsBuilder('RNSVGPath', SVG_PATH_PROPERTIES_CONFIG); + registerComponentPropsBuilder('RNSVGPattern', SVG_PATTERN_PROPERTIES_CONFIG); registerComponentPropsBuilder( getCompoundComponentName('RNSVGPath', 'Polygon'), SVG_POLYGON_PROPERTIES_CONFIG diff --git a/packages/react-native-reanimated/src/css/svg/native/configs/index.ts b/packages/react-native-reanimated/src/css/svg/native/configs/index.ts index 6ed0437cee97..93d9c898d355 100644 --- a/packages/react-native-reanimated/src/css/svg/native/configs/index.ts +++ b/packages/react-native-reanimated/src/css/svg/native/configs/index.ts @@ -6,6 +6,7 @@ export * from './image'; export * from './line'; export * from './linearGradient'; export * from './path'; +export * from './pattern'; export * from './polygon'; export * from './polyline'; export * from './radialGradient'; diff --git a/packages/react-native-reanimated/src/css/svg/native/configs/pattern.ts b/packages/react-native-reanimated/src/css/svg/native/configs/pattern.ts new file mode 100644 index 000000000000..e7c1cc9b5809 --- /dev/null +++ b/packages/react-native-reanimated/src/css/svg/native/configs/pattern.ts @@ -0,0 +1,24 @@ +'use strict'; + +import type { PatternProps } from 'react-native-svg'; + +// TODO: Fix me +// @ts-ignore RNSVG doesn't export types for web, see https://github.com/software-mansion/react-native-svg/pull/2801 +import type { SvgStyleBuilderConfig } from './common'; + +// TODO: Fix me +// @ts-ignore RNSVG doesn't export types for web, see https://github.com/software-mansion/react-native-svg/pull/2801 +export const SVG_PATTERN_PROPERTIES_CONFIG: SvgStyleBuilderConfig = + { + x: true, + y: true, + width: true, + height: true, + patternUnits: { + process: (patternUnits) => (patternUnits === 'userSpaceOnUse' ? 1 : 0), + }, + patternContentUnits: { + process: (patternContentUnits) => + patternContentUnits === 'userSpaceOnUse' ? 1 : 0, + }, + }; From 4df74c0bb466c668a28b1017990e14907475dcce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Tue, 10 Mar 2026 17:27:13 +0100 Subject: [PATCH 2/2] Add info about issue on android --- .../screens/animatedProperties/svg/Pattern.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/common-app/src/apps/css/examples/animations/screens/animatedProperties/svg/Pattern.tsx b/apps/common-app/src/apps/css/examples/animations/screens/animatedProperties/svg/Pattern.tsx index 5f209bae0fcb..8f5174a0d118 100644 --- a/apps/common-app/src/apps/css/examples/animations/screens/animatedProperties/svg/Pattern.tsx +++ b/apps/common-app/src/apps/css/examples/animations/screens/animatedProperties/svg/Pattern.tsx @@ -61,6 +61,8 @@ export default function PatternExample() { name: 'Position', sections: [ { + description: + '**x** property does not work on **Android** (not only in animations but also in regular inline styles)', examples: [ { description: @@ -92,13 +94,16 @@ export default function PatternExample() { }, { description: MIXED_UNITS_NOTE, - keyframes: { from: { x: 15 }, to: { x: '30%' } }, + keyframes: { from: { x: 20 }, to: { x: '5%' } }, title: 'Mixed', }, ], + labelTypes: ['iOS'], title: 'X Offset', }, { + description: + '**y** property does not work on **Android** (not only in animations but also in regular inline styles)', examples: [ { description: @@ -130,10 +135,11 @@ export default function PatternExample() { }, { description: MIXED_UNITS_NOTE, - keyframes: { from: { y: 15 }, to: { y: '30%' } }, + keyframes: { from: { y: 20 }, to: { y: '5%' } }, title: 'Mixed', }, ], + labelTypes: ['iOS'], title: 'Y Offset', }, ],