diff --git a/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap index f49e1240c8..957f5f71f4 100644 --- a/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/ListSection.test.tsx.snap @@ -292,6 +292,102 @@ exports[`renders list section with custom title style 1`] = ` "lineHeight": 20, }, }, + "motion": { + "duration": { + "extraLong1": 700, + "extraLong2": 800, + "extraLong3": 900, + "extraLong4": 1000, + "long1": 450, + "long2": 500, + "long3": 550, + "long4": 600, + "medium1": 250, + "medium2": 300, + "medium3": 350, + "medium4": 400, + "short1": 50, + "short2": 100, + "short3": 150, + "short4": 200, + }, + "easing": { + "emphasized": [ + 0.2, + 0, + 0, + 1, + ], + "emphasizedAccelerate": [ + 0.3, + 0, + 0.8, + 0.15, + ], + "emphasizedDecelerate": [ + 0.05, + 0.7, + 0.1, + 1, + ], + "linear": [ + 0, + 0, + 1, + 1, + ], + "standard": [ + 0.2, + 0, + 0, + 1, + ], + "standardAccelerate": [ + 0.3, + 0, + 1, + 1, + ], + "standardDecelerate": [ + 0, + 0, + 0, + 1, + ], + }, + "spring": { + "default": { + "effects": { + "damping": 1, + "stiffness": 1600, + }, + "spatial": { + "damping": 0.8, + "stiffness": 380, + }, + }, + "fast": { + "effects": { + "damping": 1, + "stiffness": 3800, + }, + "spatial": { + "damping": 0.6, + "stiffness": 800, + }, + }, + "slow": { + "effects": { + "damping": 1, + "stiffness": 800, + }, + "spatial": { + "damping": 0.8, + "stiffness": 200, + }, + }, + }, + }, "shapes": { "corner": { "extraExtraLarge": 48, @@ -950,6 +1046,102 @@ exports[`renders list section with subheader 1`] = ` "lineHeight": 20, }, }, + "motion": { + "duration": { + "extraLong1": 700, + "extraLong2": 800, + "extraLong3": 900, + "extraLong4": 1000, + "long1": 450, + "long2": 500, + "long3": 550, + "long4": 600, + "medium1": 250, + "medium2": 300, + "medium3": 350, + "medium4": 400, + "short1": 50, + "short2": 100, + "short3": 150, + "short4": 200, + }, + "easing": { + "emphasized": [ + 0.2, + 0, + 0, + 1, + ], + "emphasizedAccelerate": [ + 0.3, + 0, + 0.8, + 0.15, + ], + "emphasizedDecelerate": [ + 0.05, + 0.7, + 0.1, + 1, + ], + "linear": [ + 0, + 0, + 1, + 1, + ], + "standard": [ + 0.2, + 0, + 0, + 1, + ], + "standardAccelerate": [ + 0.3, + 0, + 1, + 1, + ], + "standardDecelerate": [ + 0, + 0, + 0, + 1, + ], + }, + "spring": { + "default": { + "effects": { + "damping": 1, + "stiffness": 1600, + }, + "spatial": { + "damping": 0.8, + "stiffness": 380, + }, + }, + "fast": { + "effects": { + "damping": 1, + "stiffness": 3800, + }, + "spatial": { + "damping": 0.6, + "stiffness": 800, + }, + }, + "slow": { + "effects": { + "damping": 1, + "stiffness": 800, + }, + "spatial": { + "damping": 0.8, + "stiffness": 200, + }, + }, + }, + }, "shapes": { "corner": { "extraExtraLarge": 48, @@ -1606,6 +1798,102 @@ exports[`renders list section without subheader 1`] = ` "lineHeight": 20, }, }, + "motion": { + "duration": { + "extraLong1": 700, + "extraLong2": 800, + "extraLong3": 900, + "extraLong4": 1000, + "long1": 450, + "long2": 500, + "long3": 550, + "long4": 600, + "medium1": 250, + "medium2": 300, + "medium3": 350, + "medium4": 400, + "short1": 50, + "short2": 100, + "short3": 150, + "short4": 200, + }, + "easing": { + "emphasized": [ + 0.2, + 0, + 0, + 1, + ], + "emphasizedAccelerate": [ + 0.3, + 0, + 0.8, + 0.15, + ], + "emphasizedDecelerate": [ + 0.05, + 0.7, + 0.1, + 1, + ], + "linear": [ + 0, + 0, + 1, + 1, + ], + "standard": [ + 0.2, + 0, + 0, + 1, + ], + "standardAccelerate": [ + 0.3, + 0, + 1, + 1, + ], + "standardDecelerate": [ + 0, + 0, + 0, + 1, + ], + }, + "spring": { + "default": { + "effects": { + "damping": 1, + "stiffness": 1600, + }, + "spatial": { + "damping": 0.8, + "stiffness": 380, + }, + }, + "fast": { + "effects": { + "damping": 1, + "stiffness": 3800, + }, + "spatial": { + "damping": 0.6, + "stiffness": 800, + }, + }, + "slow": { + "effects": { + "damping": 1, + "stiffness": 800, + }, + "spatial": { + "damping": 0.8, + "stiffness": 200, + }, + }, + }, + }, "shapes": { "corner": { "extraExtraLarge": 48, diff --git a/src/core/__tests__/PaperProvider.test.tsx b/src/core/__tests__/PaperProvider.test.tsx index 1d04ec00ff..7b9b554ff7 100644 --- a/src/core/__tests__/PaperProvider.test.tsx +++ b/src/core/__tests__/PaperProvider.test.tsx @@ -122,20 +122,6 @@ describe('PaperProvider', () => { ); }); - it('handles overriding animation with the custom one', () => { - const { getByTestId } = render( - createProvider({ - ...LightTheme, - animation: { defaultAnimationDuration: 250 }, - }) - ); - - expect(getByTestId('provider-child-view').props.theme).toStrictEqual({ - ...LightTheme, - animation: { scale: 1, defaultAnimationDuration: 250 }, - }); - }); - it('should set AccessibilityInfo listeners, if there is no theme', async () => { mockAppearance(); mockAccessibilityInfo(); diff --git a/src/theme/schemes/base.ts b/src/theme/schemes/base.ts index 44d3a017e9..ba8ebdd15d 100644 --- a/src/theme/schemes/base.ts +++ b/src/theme/schemes/base.ts @@ -1,3 +1,4 @@ +import { expressiveMotion } from '../tokens/sys/motion'; import { defaultShapes } from '../tokens/sys/shape'; import { defaultFonts } from '../tokens/sys/typography'; import type { Theme } from '../types'; @@ -10,4 +11,5 @@ export const themeDefaults: ThemeDefaults = { }, fonts: defaultFonts, shapes: defaultShapes, + motion: expressiveMotion, }; diff --git a/src/theme/tokens/sys/motion.ts b/src/theme/tokens/sys/motion.ts new file mode 100644 index 0000000000..a7c96454f2 --- /dev/null +++ b/src/theme/tokens/sys/motion.ts @@ -0,0 +1,99 @@ +import type { + MotionConfig, + MotionDuration, + MotionEasing, + SpringConfig, +} from '../../types'; + +// Spring, easing curves and duration constants per the M3 spec: +// https://m3.material.io/styles/motion/easing-and-duration/tokens-specs + +const expressiveSpring = { + spring: { + fast: { + spatial: { stiffness: 800, damping: 0.6 }, + effects: { stiffness: 3800, damping: 1 }, + }, + default: { + spatial: { stiffness: 380, damping: 0.8 }, + effects: { stiffness: 1600, damping: 1 }, + }, + slow: { + spatial: { stiffness: 200, damping: 0.8 }, + effects: { stiffness: 800, damping: 1 }, + }, + }, +}; + +const standardSpring = { + spring: { + fast: { + spatial: { stiffness: 1400, damping: 0.9 }, + effects: { stiffness: 3800, damping: 1 }, + }, + default: { + spatial: { stiffness: 700, damping: 0.9 }, + effects: { stiffness: 1600, damping: 1 }, + }, + slow: { + spatial: { stiffness: 300, damping: 0.9 }, + effects: { stiffness: 800, damping: 1 }, + }, + }, +}; + +export const motionEasing: MotionEasing = { + emphasized: [0.2, 0, 0, 1], + emphasizedAccelerate: [0.3, 0, 0.8, 0.15], + emphasizedDecelerate: [0.05, 0.7, 0.1, 1], + standard: [0.2, 0, 0, 1], + standardAccelerate: [0.3, 0, 1, 1], + standardDecelerate: [0, 0, 0, 1], + linear: [0, 0, 1, 1], +}; + +export const motionDuration: MotionDuration = { + short1: 50, + short2: 100, + short3: 150, + short4: 200, + medium1: 250, + medium2: 300, + medium3: 350, + medium4: 400, + long1: 450, + long2: 500, + long3: 550, + long4: 600, + extraLong1: 700, + extraLong2: 800, + extraLong3: 900, + extraLong4: 1000, +}; + +export const expressiveMotion: MotionConfig = { + ...expressiveSpring, + easing: motionEasing, + duration: motionDuration, +}; + +export const standardMotion: MotionConfig = { + ...standardSpring, + easing: motionEasing, + duration: motionDuration, +}; + +/** + * Converts a `SpringConfig` (spec damping ratio 0–1) to the raw damping + * coefficient expected by `Animated.spring` and Reanimated's `withSpring`. + * + * @example + * Animated.spring(value, { + * toValue: 0.85, + * ...toRawSpring(theme.motion.spring.fast.spatial), + * useNativeDriver: true, + * }); + */ +export function toRawSpring({ stiffness, damping }: SpringConfig) { + return { stiffness, damping: damping * 2 * Math.sqrt(stiffness) }; +} diff --git a/src/theme/types/index.ts b/src/theme/types/index.ts index e62ef4ad45..cc1ffe21ef 100644 --- a/src/theme/types/index.ts +++ b/src/theme/types/index.ts @@ -1,5 +1,6 @@ export * from './color'; export * from './elevation'; +export * from './motion'; export * from './navigation'; export * from './shape'; export * from './theme'; diff --git a/src/theme/types/motion.ts b/src/theme/types/motion.ts new file mode 100644 index 0000000000..a0e037f524 --- /dev/null +++ b/src/theme/types/motion.ts @@ -0,0 +1,47 @@ +export type SpringConfig = { + stiffness: number; + damping: number; // damping ratio 0–1; matches md.sys.motion.spring.*.*.damping +}; + +export type MotionSpring = { + fast: { spatial: SpringConfig; effects: SpringConfig }; + default: { spatial: SpringConfig; effects: SpringConfig }; + slow: { spatial: SpringConfig; effects: SpringConfig }; +}; + +export type EasingConfig = readonly [number, number, number, number]; + +export type MotionEasing = { + emphasized: EasingConfig; + emphasizedAccelerate: EasingConfig; + emphasizedDecelerate: EasingConfig; + standard: EasingConfig; + standardAccelerate: EasingConfig; + standardDecelerate: EasingConfig; + linear: EasingConfig; +}; + +export type MotionDuration = { + short1: number; + short2: number; + short3: number; + short4: number; + medium1: number; + medium2: number; + medium3: number; + medium4: number; + long1: number; + long2: number; + long3: number; + long4: number; + extraLong1: number; + extraLong2: number; + extraLong3: number; + extraLong4: number; +}; + +export type MotionConfig = { + spring: MotionSpring; + easing: MotionEasing; + duration: MotionDuration; +}; diff --git a/src/theme/types/theme.ts b/src/theme/types/theme.ts index dac2946cc9..a067fdec12 100644 --- a/src/theme/types/theme.ts +++ b/src/theme/types/theme.ts @@ -1,6 +1,7 @@ import type { $DeepPartial } from '@callstack/react-theme-provider'; import type { ThemeColors } from './color'; +import type { MotionConfig } from './motion'; import type { ThemeShapes } from './shape'; import type { Typescale } from './typography'; @@ -11,7 +12,6 @@ export type ThemeBase = { mode?: Mode; animation: { scale: number; - defaultAnimationDuration?: number; }; }; @@ -19,6 +19,7 @@ export type Theme = ThemeBase & { colors: ThemeColors; fonts: Typescale; shapes: ThemeShapes; + motion: MotionConfig; }; export type InternalTheme = Theme;