From bda86866b888d49fed86f93857a00d5a76b6aae0 Mon Sep 17 00:00:00 2001 From: harang Date: Mon, 20 Jul 2026 09:44:07 +0900 Subject: [PATCH] feat: let motion follow rendered text lines React Native titles need motion units that match the lines the platform lays out. The overlay renderer keeps one source paragraph as truth and reuses measured line masks. Frame transforms stop scale and pulse from clipping against the renderer's own mask. lineReveal keeps only its relative Y movement inside the line mask. Type and declaration gates keep private renderer state outside the package surface. Constraint: Preserve controls, progress, accessibility, reduced motion, and fallback behavior Constraint: Preserve the pnpm workspace and custom source condition Rejected: Reconstruct lines from tokens | React Native layout stays the source of truth Rejected: Add scale padding or bleed props | transforms cannot reserve layout reliably Confidence: high Scope-risk: broad Directive: Keep line motion geometry-first and retain one progress owner Tested: Node 24 format, lint, typecheck, 14 suites / 190 tests, and package build Tested: Declaration audit and independent code-reviewer APPROVE / architect CLEAR Not-tested: Post-change iOS and Android pixel comparison for transform-origin rounding --- README.ko.md | 51 +- README.md | 50 +- example/package.json | 1 + example/src/App.tsx | 448 +++++++- packages/text-motion/README.ko.md | 65 +- packages/text-motion/README.md | 65 +- packages/text-motion/package.json | 2 +- .../scripts/check-declarations.mjs | 82 ++ .../src/__tests__/capability-types.test.ts | 99 ++ .../text-motion/src/__tests__/effects.test.ts | 30 +- .../text-motion/src/__tests__/index.test.tsx | 28 +- .../src/__tests__/lineReveal.test.ts | 70 ++ .../src/__tests__/nativeText.test.tsx | 41 +- .../src/__tests__/nativeTextProgress.test.ts | 89 -- .../src/__tests__/overlayText.test.tsx | 971 ++++++++++++++++++ .../src/__tests__/overlayTextLayout.test.ts | 148 +++ .../src/__tests__/rendererMotion.test.ts | 401 ++++++++ packages/text-motion/src/effects/fade.ts | 12 +- packages/text-motion/src/effects/index.ts | 1 + .../text-motion/src/effects/lineReveal.ts | 28 + packages/text-motion/src/effects/pulse.ts | 10 +- packages/text-motion/src/effects/rise.ts | 10 +- packages/text-motion/src/effects/scale.ts | 12 +- packages/text-motion/src/effects/shake.ts | 10 +- packages/text-motion/src/effects/slide.ts | 12 +- packages/text-motion/src/index.tsx | 7 +- packages/text-motion/src/presets/index.ts | 12 +- packages/text-motion/src/recipe/component.tsx | 36 +- .../text-motion/src/recipe/descriptors.ts | 17 +- packages/text-motion/src/recipe/recipe.ts | 122 ++- packages/text-motion/src/renderers/index.ts | 1 + .../text-motion/src/renderers/nativeText.tsx | 392 ++----- .../src/renderers/nativeTextControls.ts | 9 +- .../src/renderers/nativeTextPlayback.ts | 18 +- .../src/renderers/nativeTextProgress.ts | 45 - .../text-motion/src/renderers/overlayText.tsx | 938 +++++++++++++++++ .../src/renderers/overlayTextLayout.ts | 312 ++++++ .../src/renderers/rendererMotion.ts | 477 +++++++++ packages/text-motion/src/types/renderer.ts | 29 +- pnpm-lock.yaml | 3 + 40 files changed, 4562 insertions(+), 592 deletions(-) create mode 100644 packages/text-motion/scripts/check-declarations.mjs create mode 100644 packages/text-motion/src/__tests__/lineReveal.test.ts delete mode 100644 packages/text-motion/src/__tests__/nativeTextProgress.test.ts create mode 100644 packages/text-motion/src/__tests__/overlayText.test.tsx create mode 100644 packages/text-motion/src/__tests__/overlayTextLayout.test.ts create mode 100644 packages/text-motion/src/__tests__/rendererMotion.test.ts create mode 100644 packages/text-motion/src/effects/lineReveal.ts delete mode 100644 packages/text-motion/src/renderers/nativeTextProgress.ts create mode 100644 packages/text-motion/src/renderers/overlayText.tsx create mode 100644 packages/text-motion/src/renderers/overlayTextLayout.ts create mode 100644 packages/text-motion/src/renderers/rendererMotion.ts diff --git a/README.ko.md b/README.ko.md index a2c6313..096f8e1 100644 --- a/README.ko.md +++ b/README.ko.md @@ -20,6 +20,8 @@ split -> layout(renderer) -> timeline -> effect -> motion/accessibility -> compo 빠르게 쓰고 싶으면 preset을 사용하면 되고, tokenization, timing, effect, accessibility, renderer 선택을 직접 잡고 싶으면 recipe를 조합하면 됩니다. +TypeScript에서는 `.layout(...)`으로 renderer를 선택한 뒤에만 `.component()`를 사용할 수 있습니다. 완성 전 recipe를 확인하거나 전달해야 할 때는 layout 전에도 `.recipe()`를 사용할 수 있습니다. + Preset을 사용한 빠른 예시: ```tsx @@ -135,19 +137,41 @@ export function Headline() { `progress`가 제공되면 `.motion()`은 내부 autoplay를 실행하지 않습니다. 대신 shared value를 업데이트하는 곳에서 playback 느낌을 정하면 됩니다. 예를 들어 `progress.value = withTiming(1, { duration: 720 })` 또는 `progress.value = withSpring(1)`처럼 직접 움직입니다. `controls`와 `progress`는 함께 사용할 수 없습니다. +Line reveal은 React Native가 width, font, font scale, alignment, language shaping을 적용한 뒤 실제로 렌더링한 줄을 기준으로 움직입니다. `overlayText()`와 `lineReveal()`을 함께 쓰고 `.split(...)`은 추가하지 않습니다. + +```tsx +import { + defineTextMotion, + lineReveal, + overlayText, + stagger, +} from '@react-native-motion-kit/text-motion'; + +const HeroLines = defineTextMotion() + .layout(overlayText()) + .timeline(stagger(0.08)) + .effect(lineReveal({ y: 18 })) + .motion({ kind: 'timing', options: { duration: 420 } }) + .component(); +``` + +Responsive hero title, section heading, 짧은 product copy처럼 실제 시각적 줄바꿈이 중요한 곳에 사용하세요. `nativeText()`는 원문 word/grapheme token을 움직이고, `overlayText()`는 실제 rendered line을 움직입니다. `lines()`는 여전히 명시적인 `\n`만 나누는 splitter이며 자동 줄바꿈을 찾지 않습니다. TypeScript와 runtime capability check는 `overlayText()`와 `.split(...)` 조합을 거부합니다. + +`overlayText()`는 rendered line frame에 적용되는 built-in style-transform effect(`fade`, `rise`, `slide`, `shake`, `scale`, `pulse`)도 받습니다. 내부적으로 `lineReveal()`은 opacity를 line frame에 적용하고, vertical reveal offset만 line mask 안의 paragraph copy에 적용합니다. 그래서 `lineReveal().and(scale())` 또는 `pulse()` 조합은 measured line center를 기준으로 커지며 renderer 자신의 line mask에 잘리지 않습니다. + ## Stable MVP Scope - recipe API: `defineTextMotion().split().layout().timeline().effect().component()` - splitters: `graphemes`, `words`, `custom`, experimental newline-only `lines` -- renderer: `nativeText` +- renderers: `nativeText`, `overlayText` - timelines: `stagger`, `sequence`, `parallel`, `wave` -- effects: `fade`, `rise`, `slide`, `scale`, `pulse`, `shake` +- effects: `fade`, `rise`, `slide`, `scale`, `pulse`, `shake`, `lineReveal` - presets subpath: `@react-native-motion-kit/text-motion/presets` - event-driven play/replay/reset/stop을 위한 명시적인 playback `controls` - external Reanimated shared value 기반 controlled progress - accessibility default: parent label plus hidden decorative token nodes -`nativeText()`는 title, label, onboarding copy, 짧은 product copy를 위한 transform-first token renderer입니다. Animated token은 React Native 플랫폼에서 transform이 안정적으로 보이도록 animated `View` container와 내부 `Text`로 렌더링됩니다. Static token은 그대로 plain `Text`로 렌더링됩니다. 그래서 `nativeText()`는 split text motion에는 적합하지만 React Native `Text`의 완전한 drop-in 대체품은 아닙니다. `numberOfLines`, `ellipsizeMode`, `onTextLayout` 같은 layout prop은 stable MVP 계약 밖에 있습니다. +`nativeText()`는 title, label, onboarding copy, 짧은 product copy를 위한 transform-first token renderer입니다. Animated token은 React Native 플랫폼에서 transform이 안정적으로 보이도록 animated `View` container와 내부 `Text`로 렌더링됩니다. Static token은 그대로 plain `Text`로 렌더링됩니다. 그래서 `nativeText()`는 원문 word/grapheme motion에는 적합하지만 React Native `Text`의 완전한 drop-in 대체품은 아닙니다. `numberOfLines`, `ellipsizeMode`, `onTextLayout` 같은 layout prop은 stable MVP 계약 밖에 있습니다. `nativeText({ testIDPrefix })`를 사용하면 generated token `testID`는 animated token container를 가리킵니다. `allowFontScaling`, `maxFontSizeMultiplier`처럼 text rendering에 영향을 주는 prop은 내부 `Text`로 전달됩니다. 이 내용은 테스트와 compatibility를 위한 안내이지, token을 직접 스타일링하기 위한 확장 API가 아닙니다. 매우 긴 문장을 grapheme 단위로 애니메이션하면 native view가 많이 생기므로, 별도 performance checkpoint 전까지는 stress case로 봐야 합니다. @@ -156,17 +180,25 @@ Example app에는 이 checkpoint를 위한 `Playback -> Renderer Performance` 직접 입력한 hard line break는 지원합니다. 예를 들어 `"First line\nSecond line"`은 `nativeText()`에서 줄바꿈이 보존되어 렌더링되고, newline 자체는 motion index를 소비하지 않습니다. RN이 화면 너비 때문에 자동 -줄바꿈한 실제 줄을 token에 매핑하는 기능은 아직 deferred입니다. +줄바꿈한 실제 줄 자체를 motion unit으로 써야 한다면 `overlayText()`를 사용하세요. + +`overlayText()`는 blank 또는 whitespace-only rendered line을 시각적으로 보존하지만, 그 줄은 motion index를 소비하지 않습니다. 움직이는 rendered line마다 full paragraph copy 하나를 사용하므로 비용은 원문 word/grapheme 수가 아니라 visible rendered line 수에 비례합니다. Title, heading, product copy에 맞춘 기능입니다. 같은 case family에서 iOS와 Android evidence가 모두 있기 전에는 measured performance라고 말하지 않습니다. + +기본 playback은 lifecycle 기반입니다. mount되면 animated token 또는 line이 자동 실행되고, 같은 text/recipe로 parent rerender가 일어나면 진행 중인 progress를 유지하며, text/effect/timeline/motion이 바뀌면 affected animation을 다시 실행합니다. MVP에서 text change는 enter-only입니다. 이전 text를 남겨 exit animation, crossfade, token diff를 만들지는 않습니다. Component는 event-driven playback을 위한 `controls`와, raw external control을 위한 `progress`도 받습니다. `controls`는 현재 text에 대해 component recipe의 `.motion()`을 사용하고, `progress`는 앱이 소유하는 Reanimated `SharedValue`가 변경된 text도 현재 shared value에 맞춰 제어합니다. + +`overlayText()`에서 동일한 line measurement는 replay를 만들지 않습니다. Geometry만 바뀐 relayout은 현재 run을 보존합니다. Line topology가 바뀌면 component-owned playback은 새 rendered line sequence로 한 번 다시 시작합니다. External `progress`를 넘긴 경우 앱이 소유한 shared value가 유지되고 새 geometry에 그대로 적용됩니다. Reduced motion에서는 measurement/playback overlay 없이 최종 readable source text를 즉시 렌더링합니다. Malformed 또는 unsupported native geometry도 readable final source text로 fallback합니다. + +첫 valid line measurement가 오기 전에는 source paragraph가 layout 소유자입니다. Effect의 initial scale이 정확히 `1`이면 pending source는 다른 renderer와 같은 combined initial opacity/transform을 유지합니다. Initial scale이 정확히 `1`이 아니면 valid line geometry가 준비될 때까지 pending source를 시각적으로 숨깁니다. 이렇게 해야 처음 보이는 scaled frame이 paragraph center가 아니라 measured line center를 사용합니다. -기본 playback은 lifecycle 기반입니다. mount되면 animated token이 자동 실행되고, 같은 text/recipe로 parent rerender가 일어나면 진행 중인 progress를 유지하며, text/effect/timeline/motion이 바뀌면 affected token animation을 다시 실행합니다. MVP에서 text change는 enter-only입니다. 이전 text를 남겨 exit animation, crossfade, token diff를 만들지는 않습니다. Component는 event-driven playback을 위한 `controls`와, raw external control을 위한 `progress`도 받습니다. `controls`는 현재 text에 대해 component recipe의 `.motion()`을 사용하고, `progress`는 앱이 소유하는 Reanimated `SharedValue`가 변경된 text도 현재 shared value에 맞춰 제어합니다. +이 동작은 line 단위 scale/pulse가 renderer 자신의 mask에 잘리는 문제를 해결합니다. 큰 transform을 위해 layout 공간을 추가로 예약하지는 않으며, ancestor의 `overflow: hidden` clipping도 우회하지 않습니다. 둘 다 React Native transform의 정상적인 제약입니다. -Skia, stable line reveal, rich text, RN-rendered line-to-token mapping은 deferred 상태입니다. Context/provider playback wiring과 public playback ref는 의도적으로 이 디자인에 포함하지 않습니다. Playback command에 반응해야 하는 component에는 `controls`를 명시적으로 전달하세요. +Skia, rich text, RN-rendered line-to-token mapping은 deferred 상태입니다. Context/provider playback wiring과 public playback ref는 의도적으로 이 디자인에 포함하지 않습니다. Playback command에 반응해야 하는 component에는 `controls`를 명시적으로 전달하세요. ## Scope & Roadmap `text-motion`은 split text motion에 집중합니다. 즉 tokenization, layout-aware timing, effect, renderer, split token playback, accessibility, 그리고 title, label, onboarding copy, product copy를 위한 preset이 핵심입니다. -Text token 또는 text layout에 의존하는 기능은 이 패키지에서 고려할 수 있습니다. 예를 들면 typewriter, scramble, wipe, text-change transition, 그리고 명시적인 capability boundary 뒤에 있는 renderer-specific effect가 있습니다. 정확한 line-aware reveal은 실제 RN layout 측정과 token-to-line mapping 정책이 필요하므로 deferred로 둡니다. +Text token 또는 text layout에 의존하는 기능은 이 패키지에서 고려할 수 있습니다. 예를 들면 typewriter, scramble, wipe, text-change transition, mask나 word-in-line effect 같은 향후 rendered-line extension, 그리고 명시적인 capability boundary 뒤에 있는 renderer-specific effect가 있습니다. 다른 문제를 푸는 기능은 별도 package가 더 적합할 가능성이 큽니다. Number count-up, odometer, currency, percentage, timer, delta animation은 split-text layout 문제가 아니라 value formatting 문제입니다. 나중에 별도의 value-motion 계열 package가 Motion Kit convention을 공유할 수 있지만, 그 때문에 `text-motion` API surface를 넓히지는 않습니다. @@ -214,18 +246,19 @@ corepack pnpm run example:build - screen reader가 split token을 하나씩 읽지 않고 문장을 한 번만 읽어야 할 때 - button, screen focus, onboarding step에서 remount 없이 replay/reset/stop을 실행하고 싶을 때 - scroll, gesture, 또는 여러 UI 요소가 공유하는 Reanimated value로 text motion progress를 직접 제어하고 싶을 때 +- responsive hero title, heading, 짧은 product copy를 실제 rendered line 기준으로 reveal하고 싶을 때 - Skia를 필수 dependency로 넣지 않는 가벼운 core package가 필요할 때 다음이 필요하다면 이후 버전을 기다리는 편이 좋습니다. - `pause`, `seek`, `reverse` 같은 추가 playback API - first-class scroll, gesture, in-view driver -- 정확한 line reveal, masked reveal, token-to-line mapping +- token별 line mapping 또는 word-in-line animation - blur, glow, shader, mask, glyph distortion 같은 Skia effect - link, selectable text, rich nested text, 완전한 native `Text` layout parity - 긴 문장, virtualized list, dense UI에서 자체 성능 검증 없이 많이 쓰는 경우 -다음 초점은 실제 앱에서 controls API를 검증한 뒤, state-transition prop, `pause`/`seek`/`reverse`, screen focus helper, in-view helper, scroll/gesture driver를 first-class API로 올릴 가치가 있는지 판단하는 것입니다. Line-aware effect와 optional Skia renderer는 native renderer, layout 동작, 성능 기준이 더 검증된 뒤 다룹니다. +다음 초점은 line renderer performance와 platform behavior를 검증한 뒤, state-transition prop, `pause`/`seek`/`reverse`, screen focus helper, in-view helper, scroll/gesture driver, word-in-line effect, optional Skia renderer를 first-class API로 올릴 가치가 있는지 판단하는 것입니다. ## License diff --git a/README.md b/README.md index e1fc239..3de7f72 100644 --- a/README.md +++ b/README.md @@ -137,19 +137,41 @@ export function Headline() { When `progress` is provided, `.motion()` does not run internal autoplay. Choose the playback feel where you update the shared value instead, for example `progress.value = withTiming(1, { duration: 720 })` or `progress.value = withSpring(1)`. `controls` and `progress` cannot be used together. +Line reveal is for motion based on the lines React Native actually rendered after width, font, font scale, alignment, and language shaping are applied. Use `overlayText()` with `lineReveal()` and do not add `.split(...)`: + +```tsx +import { + defineTextMotion, + lineReveal, + overlayText, + stagger, +} from '@react-native-motion-kit/text-motion'; + +const HeroLines = defineTextMotion() + .layout(overlayText()) + .timeline(stagger(0.08)) + .effect(lineReveal({ y: 18 })) + .motion({ kind: 'timing', options: { duration: 420 } }) + .component(); +``` + +Use this for responsive hero titles, section headings, and concise product copy where the visual line breaks matter. `nativeText()` animates source word or grapheme tokens. `overlayText()` animates actual rendered lines. `lines()` is still only an explicit `\n` splitter; it does not discover automatic wrapping. TypeScript and runtime capability checks reject `.split(...)` with `overlayText()`. + +`overlayText()` also accepts the built-in style-transform effects (`fade`, `rise`, `slide`, `shake`, `scale`, and `pulse`) because they operate on the rendered line frame. `lineReveal()` is split internally: its opacity is applied to the line frame, while its vertical reveal offset moves only the paragraph copy inside the line mask. This lets combinations such as `lineReveal().and(scale())` and `pulse()` grow from the measured line center without being clipped by the renderer's own line mask. + ## Stable MVP Scope - recipe API: `defineTextMotion().split().layout().timeline().effect().component()` - splitters: `graphemes`, `words`, `custom`, experimental newline-only `lines` -- renderer: `nativeText` +- renderers: `nativeText`, `overlayText` - timelines: `stagger`, `sequence`, `parallel`, `wave` -- effects: `fade`, `rise`, `slide`, `scale`, `pulse`, `shake` +- effects: `fade`, `rise`, `slide`, `scale`, `pulse`, `shake`, `lineReveal` - presets subpath: `@react-native-motion-kit/text-motion/presets` - explicit playback `controls` for event-driven play/replay/reset/stop - controlled progress via external Reanimated shared values - accessibility default: parent label plus hidden decorative token nodes -`nativeText()` is a transform-first token renderer for titles, labels, onboarding copy, and short product copy. Animated tokens use an animated `View` container with an inner `Text` so transforms behave consistently across React Native platforms. Static tokens still render as plain `Text`. This makes `nativeText()` useful for split text motion, but it is not a full React Native `Text` drop-in. Layout props such as `numberOfLines`, `ellipsizeMode`, and `onTextLayout` are intentionally outside the stable MVP contract. +`nativeText()` is a transform-first token renderer for titles, labels, onboarding copy, and short product copy. Animated tokens use an animated `View` container with an inner `Text` so transforms behave consistently across React Native platforms. Static tokens still render as plain `Text`. This makes `nativeText()` useful for source word or grapheme motion, but it is not a full React Native `Text` drop-in. Layout props such as `numberOfLines`, `ellipsizeMode`, and `onTextLayout` are intentionally outside the stable MVP contract. When you pass `nativeText({ testIDPrefix })`, generated token `testID` values identify the animated token container. Text rendering props such as `allowFontScaling` and `maxFontSizeMultiplier` are forwarded to the inner `Text`. Treat this as testing and compatibility guidance, not as a styling extension API. Very long grapheme-by-grapheme animations create many native views and should be treated as stress cases until a dedicated performance checkpoint says otherwise. @@ -157,17 +179,26 @@ The example app includes `Playback -> Renderer Performance` for this checkpoint. Manual hard line breaks are supported: text such as `"First line\nSecond line"` keeps the newline as a visual break in `nativeText()`, and the newline does not consume a -motion index. Automatic RN-rendered line-to-token mapping is still deferred. +motion index. Use `overlayText()` when the motion unit should be the actual +rendered line after React Native wraps the paragraph. + +`overlayText()` preserves blank or whitespace-only rendered lines visually, but those lines do not consume a motion index. It uses one full paragraph copy per moving rendered line, so work scales with visible rendered lines rather than source words or graphemes. This is intended for titles, headings, and product copy. Do not treat performance as measured until the same case family has iOS and Android evidence. + +By default, playback is lifecycle-driven: animated tokens or lines autoplay on mount, ordinary parent rerenders with the same text/recipe preserve in-flight progress, and text/effect/timeline/motion changes replay the affected animation. Text changes are enter-only in the MVP: old text is not kept for exit animation, crossfade, or token diffing. Components also accept `controls` for event-driven playback and `progress`, a Reanimated `SharedValue` from `0` to `1`, for raw externally controlled progress. `controls` uses the component recipe `.motion()` against the current text; `progress` is app-owned and makes changed text follow the current shared value instead of starting autoplay. + +For `overlayText()`, identical line measurement does not replay. Geometry-only relayout preserves the current run. If line topology changes, component-owned playback restarts once for the new rendered lines. With external `progress`, the app-owned shared value is preserved and applied to the new geometry. Reduced motion renders the final readable source text without measurement or playback overlays. Malformed or unsupported native geometry also falls back to readable final source text. + +Before the first valid line measurement, the source paragraph remains the layout owner. If an effect starts at `scale: 1`, the pending source keeps the same combined initial opacity and transform as other renderers. If the initial scale is not exactly `1`, the pending source is visually hidden until valid line geometry is available so the first visible scaled frame uses the measured line center instead of the paragraph center. -By default, playback is lifecycle-driven: animated tokens autoplay on mount, ordinary parent rerenders with the same text/recipe preserve in-flight progress, and text/effect/timeline/motion changes replay the affected token animation. Text changes are enter-only in the MVP: old text is not kept for exit animation, crossfade, or token diffing. Components also accept `controls` for event-driven playback and `progress`, a Reanimated `SharedValue` from `0` to `1`, for raw externally controlled progress. `controls` uses the component recipe `.motion()` against the current text; `progress` is app-owned and makes changed text follow the current shared value instead of starting autoplay. +This fixes renderer self-clipping for line-level scale and pulse effects. It does not reserve extra layout space for large transforms and it cannot bypass clipping from an ancestor with `overflow: hidden`; those remain normal React Native transform constraints. -Skia, stable line reveal, rich text, and RN-rendered line-to-token mapping are deferred. Context/provider playback wiring and public playback refs are intentionally not part of the design; pass `controls` explicitly where a component should respond to playback commands. +Skia, rich text, and RN-rendered line-to-token mapping are deferred. Context/provider playback wiring and public playback refs are intentionally not part of the design; pass `controls` explicitly where a component should respond to playback commands. ## Scope & Roadmap `text-motion` stays focused on split text motion: tokenization, layout-aware timing, effects, renderers, split-token playback, accessibility, and presets for titles, labels, onboarding copy, and product copy. -Features that depend on text tokens or text layout can be considered here, including typewriter, scramble, wipe, text-change transitions, and renderer-specific effects behind explicit capability boundaries. Precise line-aware reveal remains deferred until RN layout measurement and token-to-line mapping policies are reliable enough to document. +Features that depend on text tokens or text layout can be considered here, including typewriter, scramble, wipe, text-change transitions, future rendered-line extensions such as masks or word-in-line effects, and renderer-specific effects behind explicit capability boundaries. Features that solve a different problem are likely better served by separate package candidates. Number count-up, odometer, currency, percentage, timer, and delta animations are value-formatting problems rather than split-text layout problems. A separate value-motion package could share Motion Kit conventions without expanding the `text-motion` API surface. @@ -215,18 +246,19 @@ Use this package today when you need: - accessible split text that screen readers read as one phrase - event-driven playback controls for replay/reset/stop without remounting - externally controlled raw progress for scroll, gesture, or synchronized text motion +- rendered-line reveal for responsive hero titles, headings, and concise product copy - a lightweight core package without Skia as a required dependency Wait for a later version if you need: - playback APIs such as `pause`, `seek`, or `reverse` - first-class scroll, gesture, or in-view drivers -- precise line reveal, masked reveal, or token-to-line mapping +- per-token line mapping or mixed word-in-line animation - Skia effects such as blur, glow, shaders, masks, or glyph distortion - rich nested text with links, selectable text, or full native `Text` layout parity - heavy use across long paragraphs, virtualized lists, or dense UI without your own performance validation -Next focus: proving controls in real app flows, then deciding whether state-transition props, `pause`/`seek`/`reverse`, in-view helpers, scroll/gesture drivers, line-aware effects, or optional Skia renderers deserve first-class APIs. +Next focus: verifying line renderer performance and platform behavior, then deciding whether state-transition props, `pause`/`seek`/`reverse`, in-view helpers, scroll/gesture drivers, word-in-line effects, or optional Skia renderers deserve first-class APIs. ## License diff --git a/example/package.json b/example/package.json index 341a014..c917106 100644 --- a/example/package.json +++ b/example/package.json @@ -24,6 +24,7 @@ "devDependencies": { "@babel/core": "^7.29.7", "@types/react": "^19.2.0", + "babel-preset-expo": "~56.0.15", "typescript": "catalog:" } } diff --git a/example/src/App.tsx b/example/src/App.tsx index 4e119d1..aa14e4b 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -3,8 +3,10 @@ import { defineTextMotion, fade, graphemes, + lineReveal, lines, nativeText, + overlayText, parallel, pulse, rise, @@ -28,6 +30,7 @@ import { useEffect, useRef, useState } from 'react'; import { Pressable, SafeAreaView, + ScrollView, StyleSheet, Text, View, @@ -256,6 +259,30 @@ const LineProbeMotionText = defineTextMotion() .motion({ kind: 'timing', options: { duration: 360 } }) .component(); +const OverlayLineRevealText = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'overlay-line' })) + .timeline(stagger(0.1)) + .effect(lineReveal({ y: 18 })) + .component(); + +const OverlayLineRevealScaleText = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'overlay-line' })) + .timeline(stagger(0.1)) + .effect(lineReveal({ y: 18 }).and(scale({ from: 1.25, to: 1 }))) + .component(); + +const OverlayPulseText = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'overlay-line' })) + .timeline(stagger(0.1)) + .effect(pulse({ scale: 1.05 })) + .component(); + +const OverlayLargeScaleText = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'overlay-line' })) + .timeline(stagger(0.1)) + .effect(scale({ from: 1.5, to: 1 })) + .component(); + type ControlsStressCase = { Component: TextMotionComponent; id: string; @@ -429,6 +456,17 @@ type LineProbeCase = { widthMode: 'comfortable' | 'narrow'; }; +type LineRevealProbeCase = { + align: 'auto' | 'center' | 'right'; + Component: TextMotionComponent; + id: string; + label: string; + lookFor: string; + note: string; + text: string; + widthMode: 'comfortable' | 'narrow'; +}; + type LineProbeLayoutInput = { lineText: string; measuredWidth: number; @@ -503,6 +541,169 @@ const lineProbeCases: readonly LineProbeCase[] = [ }, ]; +const lineRevealProbeCases: readonly LineRevealProbeCase[] = [ + { + align: 'auto', + Component: OverlayLineRevealText, + id: 'latin-two-line', + label: 'Short Latin title', + lookFor: + 'Each rendered line should reveal inside its own band and settle exactly over the readable source paragraph.', + note: 'Observe the actual line count; wrapping can differ by platform, font, and width.', + text: 'Design motion that feels native across screens', + widthMode: 'narrow', + }, + { + align: 'auto', + Component: OverlayLineRevealScaleText, + id: 'line-reveal-scale', + label: 'Reveal + scale', + lookFor: + 'The first and last glyphs should not be clipped by the line mask while scale shrinks to the final line.', + note: 'This combines mask-relative lineReveal with frame-level scale.', + text: 'Scale the rendered line without cutting edge glyphs', + widthMode: 'narrow', + }, + { + align: 'auto', + Component: OverlayPulseText, + id: 'pulse-small', + label: 'Pulse 1.05', + lookFor: + 'The pulse should grow from the actual line center without self-clipping at the left or right edge.', + note: 'Pulse is a frame transform; no reveal offset should move the paragraph copy.', + text: 'Pulse a rendered line while keeping the mask safe', + widthMode: 'narrow', + }, + { + align: 'center', + Component: OverlayLargeScaleText, + id: 'scale-large-stress', + label: 'Large scale stress', + lookFor: + 'Use this as a clipping stress check: renderer self-clipping should be gone, but parent overflow or neighboring overlap can still clip.', + note: 'This large scale is for visual QA, not a recommended default animation.', + text: 'Large scale makes clipping problems visible quickly', + widthMode: 'narrow', + }, + { + align: 'auto', + Component: OverlayLineRevealText, + id: 'width-reflow', + label: 'Width reflow', + lookFor: + 'At progress 0.5, toggling Width should keep the current progress on the newly measured lines.', + note: 'Toggle Width; the actual rendered line count should change.', + text: 'Responsive hero copy should follow the rendered line breaks', + widthMode: 'comfortable', + }, + { + align: 'auto', + Component: OverlayLineRevealText, + id: 'explicit-newline', + label: 'Explicit newline', + lookFor: 'Hard-authored line breaks should stay readable and not add extra motion for blanks.', + note: 'Hard line breaks stay visual, but motion follows rendered nonblank lines.', + text: 'First line is authored\nSecond line is authored', + widthMode: 'comfortable', + }, + { + align: 'auto', + Component: OverlayLineRevealText, + id: 'middle-blank-line', + label: 'Middle blank line', + lookFor: 'The blank line should preserve vertical spacing without consuming a stagger slot.', + note: 'The blank line remains visible spacing and consumes no motion index.', + text: 'Opening line\n\nClosing line', + widthMode: 'comfortable', + }, + { + align: 'auto', + Component: OverlayLineRevealText, + id: 'leading-newline', + label: 'Leading newline', + lookFor: + 'Leading vertical space should remain readable while the first nonblank line animates first.', + note: 'Leading whitespace can preserve vertical space without delaying motion.', + text: '\nLeading space before a title', + widthMode: 'comfortable', + }, + { + align: 'auto', + Component: OverlayLineRevealText, + id: 'trailing-newline', + label: 'Trailing newline', + lookFor: 'Trailing line breaks should not create duplicate text or a visible flash.', + note: 'Platform payloads may differ, but readable text should remain stable.', + text: 'Trailing line break stays readable\n', + widthMode: 'comfortable', + }, + { + align: 'center', + Component: OverlayLineRevealText, + id: 'center-align', + label: 'Center alignment', + lookFor: + 'Centered lines should animate around the measured line center without sideways drift.', + note: 'Centered line placement should match the final source paragraph.', + text: 'Centered lines reveal where React Native placed them', + widthMode: 'narrow', + }, + { + align: 'right', + Component: OverlayLineRevealText, + id: 'right-align', + label: 'Right alignment', + lookFor: + 'Right-aligned lines should use their measured center and finish without horizontal drift.', + note: 'Right-aligned line placement should not drift during reveal.', + text: 'Right aligned product copy follows real layout', + widthMode: 'narrow', + }, + { + align: 'auto', + Component: OverlayLineRevealText, + id: 'korean-cjk', + label: 'Korean / CJK', + lookFor: + 'CJK line breaks should follow the native rendered payload instead of JS token guessing.', + note: 'CJK wrapping should come from native text layout, not JS guessing.', + text: '한글과 日本語 문장이 실제 렌더링 줄 기준으로 나타납니다', + widthMode: 'narrow', + }, + { + align: 'auto', + Component: OverlayLineRevealText, + id: 'emoji-zwj', + label: 'Emoji / ZWJ', + lookFor: + 'Emoji clusters should stay intact inside each rendered line through replay and reset.', + note: 'Emoji sequences should stay visually intact inside each rendered line.', + text: 'Teams 👨‍👩‍👧‍👦 build expressive motion ✨ for mobile', + widthMode: 'narrow', + }, + { + align: 'auto', + Component: OverlayLineRevealText, + id: 'rtl', + label: 'RTL', + lookFor: 'Mixed-direction text should keep React Native shaping and not jump at progress 1.', + note: 'RTL shaping and ordering stay owned by React Native text layout.', + text: 'שלום עולם motion title with mixed direction', + widthMode: 'narrow', + }, + { + align: 'auto', + Component: OverlayLineRevealText, + id: 'stress-six-line', + label: 'Stress boundary (~6-line target)', + lookFor: 'Replay should not create duplicate overlays, missing copies, or sustained stutter.', + note: 'Use the displayed line count as the result, not a performance claim.', + text: 'Line reveal is intended for titles headings and concise product copy where the number of rendered lines stays small enough to inspect directly on target devices before shipping broader surfaces.', + widthMode: 'comfortable', + }, +]; + const demoGroups: readonly DemoGroup[] = [ { id: 'presets', title: 'Presets' }, { id: 'splitters', title: 'Splitters' }, @@ -658,7 +859,14 @@ const demos: readonly Demo[] = [ title: 'Spring Motion', }, { - caption: 'private onTextLayout probe / hard newline visual check', + caption: 'overlayText() + lineReveal() public API probe', + groupId: 'layout', + id: 'overlay-line-reveal-probe', + text: 'Rendered line reveal probe', + title: 'Line Reveal', + }, + { + caption: 'diagnostic line layout probe / hard newline visual check', groupId: 'layout', id: 'line-layout-probe', text: 'Measured line layout probe', @@ -718,6 +926,10 @@ function nextLineProbeCaseIndex(index: number): number { return (index + 1) % lineProbeCases.length; } +function nextLineRevealProbeCaseIndex(index: number): number { + return (index + 1) % lineRevealProbeCases.length; +} + function renderedLinesFromEvent( event: NativeSyntheticEvent, ): readonly NativeTextRenderedLine[] { @@ -731,6 +943,42 @@ function renderedLinesFromEvent( })); } +function countVisibleMotionLines(renderedLineItems: readonly NativeTextRenderedLine[]): number { + return renderedLineItems.filter((line) => line.text.trim().length > 0).length; +} + +function areRenderedLineCountsEqual( + previous: readonly NativeTextRenderedLine[], + next: readonly NativeTextRenderedLine[], +): boolean { + if (previous.length !== next.length) { + return false; + } + + return previous.every((line, index) => { + const nextLine = next[index]; + + return Boolean( + nextLine && + line.text === nextLine.text && + Math.round(line.y) === Math.round(nextLine.y) && + Math.round(line.height) === Math.round(nextLine.height), + ); + }); +} + +function createLineRevealTextAlignStyle(align: LineRevealProbeCase['align']) { + if (align === 'center') { + return styles.lineRevealTextCenter; + } + + if (align === 'right') { + return styles.lineRevealTextRight; + } + + return styles.lineRevealTextAuto; +} + function lineTextForSignature(renderedLines: readonly NativeTextRenderedLine[]): string { return renderedLines.map((line) => line.text).join('\n'); } @@ -1135,6 +1383,141 @@ function RendererPerformanceProbeDemo({ replaySignal }: { replaySignal: number } ); } +function LineRevealProbeDemo({ replaySignal }: { replaySignal: number }) { + const progress = useSharedValue(1); + const [caseIndex, setCaseIndex] = useState(0); + const [widthMode, setWidthMode] = useState( + lineRevealProbeCases[0].widthMode, + ); + const [renderedLines, setRenderedLines] = useState([]); + const replaySignalRef = useRef(replaySignal); + const probeCase = lineRevealProbeCases[caseIndex] ?? lineRevealProbeCases[0]; + const ProbeMotionText = probeCase.Component; + const lineTextAlignStyle = createLineRevealTextAlignStyle(probeCase.align); + const visibleMotionLineCount = countVisibleMotionLines(renderedLines); + + function replayProgress() { + progress.value = 0; + progress.value = withTiming(1, { duration: 820 }); + } + + function resetProgress() { + progress.value = 0; + } + + function stepProgress() { + const current = Number(progress.value); + + if (!Number.isFinite(current) || current >= 1) { + progress.value = 0; + return; + } + + progress.value = Math.min(1, Math.round((current + 0.25) * 100) / 100); + } + + function advanceCase() { + const nextCaseIndex = nextLineRevealProbeCaseIndex(caseIndex); + const nextCase = lineRevealProbeCases[nextCaseIndex] ?? lineRevealProbeCases[0]; + + setRenderedLines([]); + setCaseIndex(nextCaseIndex); + setWidthMode(nextCase.widthMode); + replayProgress(); + } + + function handleTextLayout(event: NativeSyntheticEvent) { + const nextLines = renderedLinesFromEvent(event); + + setRenderedLines((currentLines) => + areRenderedLineCountsEqual(currentLines, nextLines) ? currentLines : nextLines, + ); + } + + useEffect(() => { + if (replaySignalRef.current === replaySignal) { + return; + } + + replaySignalRef.current = replaySignal; + progress.value = 0; + progress.value = withTiming(1, { duration: 820 }); + }, [progress, replaySignal]); + + return ( + + + + {probeCase.label} + {probeCase.note} + + PUBLIC + + + + + {probeCase.text} + + + {probeCase.text} + + + + + rendered lines {renderedLines.length} + motion lines {visibleMotionLineCount} + animated overlays {visibleMotionLineCount} + paragraph copies {visibleMotionLineCount} + + + + What to look for + {probeCase.lookFor} + + + + + Replay + + + Reset + + { + setWidthMode((currentMode) => (currentMode === 'narrow' ? 'comfortable' : 'narrow')); + }} + style={styles.progressButton} + > + + {widthMode === 'narrow' ? 'Widen' : 'Narrow'} + + + + + + + Case + + + Progress +25% + + + + ); +} + function LineLayoutProbeDemo({ replaySignal }: { replaySignal: number }) { const controls = useTextMotionControls(); const [caseIndex, setCaseIndex] = useState(0); @@ -1401,6 +1784,7 @@ export default function App() { const controlsDemoSelected = demo.id === 'playback-controls'; const controlsStressDemoSelected = demo.id === 'controls-stress'; const controlledDemoSelected = demo.id === 'controlled-progress'; + const lineRevealProbeSelected = demo.id === 'overlay-line-reveal-probe'; const lineLayoutProbeSelected = demo.id === 'line-layout-probe'; const rendererPerformanceProbeSelected = demo.id === 'renderer-performance-probe'; @@ -1412,7 +1796,8 @@ export default function App() { @react-native-motion-kit/text-motion Motion player - Inspect motion recipes, playback behavior, and private layout probes one case at a time. + Inspect motion recipes, playback behavior, and diagnostic line layout probes one case at + a time. @@ -1449,13 +1834,19 @@ export default function App() { - + {controlsDemoSelected ? ( ) : controlsStressDemoSelected ? ( ) : rendererPerformanceProbeSelected ? ( + ) : lineRevealProbeSelected ? ( + ) : lineLayoutProbeSelected ? ( ) : controlledDemoSelected ? ( @@ -1470,7 +1861,7 @@ export default function App() { ) )} - + @@ -1672,12 +2063,59 @@ const styles = StyleSheet.create({ maxWidth: 360, width: '100%', }, - motionFrame: { + lineRevealDemo: { alignItems: 'center', + gap: 12, + width: '100%', + }, + lineRevealLookFor: { + color: '#475569', + fontSize: 12, + fontWeight: '700', + lineHeight: 17, + textAlign: 'center', + }, + lineRevealMeasurementText: { + left: 0, + opacity: 0, + position: 'absolute', + right: 0, + top: 0, + }, + lineRevealText: { + color: '#111827', + fontSize: 22, + fontWeight: '800', + lineHeight: 29, + }, + lineRevealTextAuto: { + textAlign: 'auto', + }, + lineRevealTextBox: { + maxWidth: 360, + minHeight: 96, + position: 'relative', + width: '100%', + }, + lineRevealTextBoxNarrow: { + maxWidth: 230, + }, + lineRevealTextCenter: { + textAlign: 'center', + }, + lineRevealTextRight: { + textAlign: 'right', + }, + motionFrame: { flex: 1, + }, + motionFrameContent: { + alignItems: 'center', + flexGrow: 1, justifyContent: 'center', minHeight: 210, paddingHorizontal: 18, + paddingVertical: 18, }, motionText: { color: '#111827', diff --git a/packages/text-motion/README.ko.md b/packages/text-motion/README.ko.md index 2b0cb65..5bf0ea6 100644 --- a/packages/text-motion/README.ko.md +++ b/packages/text-motion/README.ko.md @@ -174,6 +174,53 @@ const LineReveal = defineTextMotion() `lines()`는 experimental 기능이며 newline-only splitter입니다. 명시적인 `\n` 기준으로만 나눕니다. React Native가 화면에서 자동 줄바꿈한 실제 줄을 측정하지는 않습니다. +### Rendered Line Reveal + +Motion unit이 원문 token이 아니라 React Native가 width, font, font scale, alignment, language shaping, wrapping을 적용한 뒤 실제로 렌더링한 줄이어야 한다면 `overlayText()`와 `lineReveal()`을 사용하세요. + +```tsx +import { + defineTextMotion, + lineReveal, + overlayText, + stagger, +} from '@react-native-motion-kit/text-motion'; + +const HeroLines = defineTextMotion() + .layout(overlayText()) + .timeline(stagger(0.08)) + .effect(lineReveal({ y: 18 })) + .motion({ kind: 'timing', options: { duration: 420 } }) + .component(); + +export function HeroTitle() { + return Design motion that feels native; +} +``` + +`overlayText()`에는 `.split(...)`을 호출하지 않습니다. `overlayText()`는 layout 이후 rendered line을 소유하고, `nativeText()`는 원문 word, grapheme, custom, explicit newline token을 소비합니다. TypeScript는 맞지 않는 chain을 제거하고, runtime capability check는 unsafe JavaScript 또는 cast로 만든 잘못된 조합도 거부합니다. + +Responsive hero title, marketing heading, onboarding headline, 짧은 product copy처럼 최종 시각적 줄바꿈이 중요한 곳에 사용하세요. Word 또는 grapheme motion에는 `nativeText()`를 사용합니다. 작성자가 넣은 `\n`을 token boundary로 쓰고 싶을 때만 `lines()`를 사용하세요. `lines()`는 자동 줄바꿈을 찾지 않습니다. + +`overlayText()`는 각 rendered line frame에 적용되는 built-in style-transform effect(`fade`, `rise`, `slide`, `shake`, `scale`, `pulse`)도 받습니다. 내부적으로 `lineReveal()`은 opacity를 line frame에 적용하고, vertical reveal offset만 line mask 안의 paragraph copy에 적용합니다. 그래서 `lineReveal().and(scale())`, 더 큰 finite `scale()`, `pulse()`가 measured line center를 기준으로 커지며 renderer 자신의 mask에 잘리지 않습니다. + +Blank 또는 whitespace-only rendered line은 시각적 spacing으로 보존되지만 motion index를 소비하지 않습니다. `"First\n\nSecond"` 같은 title은 blank line을 유지하면서 두 nonblank line이 연속된 stagger slot으로 reveal됩니다. + +Relayout 동작은 예측 가능하게 고정되어 있습니다. + +- 동일한 line measurement는 replay를 만들지 않습니다. +- geometry만 바뀐 relayout은 현재 run을 보존합니다. +- line topology가 바뀌면 component-owned playback이 한 번 다시 시작합니다. +- external `progress`는 앱이 소유한 shared value를 유지하고 새 geometry에 적용합니다. +- reduced motion은 measurement 또는 playback overlay 없이 최종 readable source text를 렌더링합니다. +- malformed 또는 unsupported native geometry는 readable final source text로 fallback합니다. + +`overlayText()`는 움직이는 rendered line마다 full paragraph copy 하나를 만듭니다. 성능 비용은 원문 word/grapheme 수가 아니라 visible rendered line 수에 비례합니다. Title, heading, product copy에 맞춘 renderer로 보세요. 같은 case family에서 iOS와 Android evidence가 모두 있기 전에는 measured performance라고 주장하지 않습니다. + +Valid line geometry가 생기기 전에는 source paragraph가 layout을 소유합니다. Initial scale이 정확히 `1`이면 pending source는 다른 renderer와 같은 combined initial opacity/transform을 유지합니다. Initial scale이 정확히 `1`이 아니면 geometry가 valid해질 때까지 pending source를 시각적으로 숨겨, 처음 보이는 scaled frame이 measured line center를 사용하게 합니다. + +Renderer 자신의 line mask 때문에 생기던 self-clipping은 해결됩니다. 큰 transform을 위한 layout 공간을 추가로 예약하지는 않으며, parent 또는 ancestor의 `overflow: hidden` clipping도 우회하지 않습니다. 이는 React Native transform의 정상적인 limitation입니다. + ### Custom ```tsx @@ -235,6 +282,7 @@ slide({ x: -12, y: 8 }); scale({ from: 0.92, to: 1 }); pulse({ scale: 1.08 }); shake({ x: 6 }); +lineReveal({ y: 18 }); ``` Effect는 `.and(...)`로 조합할 수 있습니다. @@ -249,13 +297,15 @@ Effect factory는 입력 options를 타입으로 검증하지만, 반환되는 e Built-in effect의 숫자 입력은 finite number여야 합니다. `rise({ y })`, `slide({ x, y })`, `shake({ x })`처럼 방향을 의미하는 offset option은 음수도 허용합니다. +`lineReveal()`은 `overlayText()`가 제공하는 `line-mask` capability가 필요합니다. `nativeText()`와는 호환되지 않습니다. + ## Native Renderer Contract `nativeText()`는 stable MVP renderer입니다. Animated token은 React Native에서 `rise()`, `slide()`, `scale()`, `pulse()` 같은 transform effect가 실제로 보이도록 animated `View` container와 내부 `Text`로 렌더링됩니다. Static token은 그대로 plain `Text`로 렌더링됩니다. `nativeText()`는 `.layout(...)`에 전달하는 opaque renderer handle을 반환합니다. `kind`, `capabilities`, 구현 `Component` 같은 descriptor field는 root 사용자 API에서 숨깁니다. -이 renderer는 React Native `Text`의 완전한 drop-in 대체품이 아닙니다. 정확한 platform text layout보다 token별 transform의 신뢰성을 우선합니다. RN line-to-token mapping은 deferred 상태입니다. +이 renderer는 React Native `Text`의 완전한 drop-in 대체품이 아닙니다. 정확한 platform text layout보다 token별 transform의 신뢰성을 우선합니다. 실제 rendered line 기준 motion이 필요하면 `overlayText()`를 사용하세요. RN line-to-token mapping은 deferred 상태입니다. `nativeText()`로 만든 component는 의도적으로 좁은 prop surface만 지원합니다. @@ -476,9 +526,9 @@ React Native/Hermes 환경에서는 `Intl.Segmenter`가 항상 보장되지 않 - `defineTextMotion()` - `graphemes()`, `words()`, `custom()` - experimental newline-only `lines()` -- `nativeText()` +- `nativeText()`, `overlayText()` - `stagger()`, `sequence()`, `parallel()`, `wave()` -- `fade()`, `rise()`, `slide()`, `scale()`, `pulse()`, `shake()` +- `fade()`, `rise()`, `slide()`, `scale()`, `pulse()`, `shake()`, `lineReveal()` - 명시적인 `controls` prop과 `useTextMotionControls()` 기반 play/replay/reset/stop - external Reanimated shared value 기반 controlled `progress` - hidden animated token node를 사용하는 parent-label accessibility policy @@ -492,11 +542,9 @@ Custom effect factory와 renderer capability factory는 MVP root API에서 의 - custom effect factory API - renderer capability factory API -- `lineReveal` - `wipe` - `typewriter` - `scramble` -- stable `overlayText` - Skia renderer 또는 Skia-only effects - `pause`, `seek`, `reverse` 같은 추가 playback API - first-class screen focus, in-view, scroll, gesture driver @@ -527,7 +575,7 @@ Core text-motion 작업은 다음에 집중해야 합니다. Advanced text effect는 여전히 text token 또는 text layout에 의존할 때 고려할 수 있습니다. - typewriter, scramble, wipe, highlight sweep effect -- RN layout 측정과 token-to-line mapping 정책이 안정된 뒤 다룰 수 있는 신뢰 가능한 line-aware reveal +- source range 정책이 안정된 뒤 다룰 수 있는 word-in-line effect와 RN-rendered line-to-token mapping - old/new token set의 playback policy가 필요한 text-change transition Renderer-specific effect는 renderer capability boundary 뒤에 둬야 합니다. @@ -554,6 +602,7 @@ Renderer-specific effect는 renderer capability boundary 뒤에 둬야 합니다 - preset이나 `defineTextMotion()`으로 재사용 가능한 recipe component를 만들고 싶은 경우 - component를 remount하지 않고 replay/reset/stop을 실행해야 하는 경우 - external Reanimated shared value로 text motion progress를 직접 제어해야 하는 경우 +- responsive hero title, heading, 짧은 product copy를 실제 rendered line 기준으로 reveal해야 하는 경우 - 이미 Reanimated 4를 사용하고 Worklets 설정을 따를 수 있는 앱 - 전체 문장은 한 번만 읽히고, animated token은 장식처럼 처리되어야 하는 accessible text motion @@ -561,12 +610,12 @@ Renderer-specific effect는 renderer capability boundary 뒤에 둬야 합니다 - `pause`, `seek`, `reverse` 같은 추가 playback API - scroll progress, gesture progress, viewport/in-view trigger를 first-class driver로 쓰는 기능 -- 정확한 line-level animation, clipping, masking, token별 line measurement +- token별 line measurement 또는 word-in-line animation - blur, glow, shader, mask, glyph distortion 같은 Skia-only visual effect - rich nested text, inline link, selectable text, native `Text`와 완전히 같은 동작 - target app에서 성능 측정 없이 긴 문단이나 많은 animated row에 적용하는 경우 -다음 제품 초점은 실제 앱에서 controls API를 검증한 뒤, state-transition prop, `pause`/`seek`/`reverse`, screen focus helper, in-view helper, scroll/gesture driver를 first-class API로 올릴 가치가 있는지 판단하는 것입니다. Deferred에 적힌 항목은 release promise가 아닙니다. 동작, 예제, 테스트, 문서가 준비되었을 때만 stable API로 올리는 방향입니다. +다음 제품 초점은 line renderer performance와 platform behavior를 검증한 뒤, state-transition prop, `pause`/`seek`/`reverse`, screen focus helper, in-view helper, scroll/gesture driver, word-in-line effect, Skia renderer 후보를 first-class API로 올릴 가치가 있는지 판단하는 것입니다. Deferred에 적힌 항목은 release promise가 아닙니다. 동작, 예제, 테스트, 문서가 준비되었을 때만 stable API로 올리는 방향입니다. ## 개발 diff --git a/packages/text-motion/README.md b/packages/text-motion/README.md index a473519..f98565d 100644 --- a/packages/text-motion/README.md +++ b/packages/text-motion/README.md @@ -173,6 +173,53 @@ const LineReveal = defineTextMotion() `lines()` is experimental and newline-only. It splits on explicit `\n` characters. It does not measure automatic React Native line wrapping. +### Rendered Line Reveal + +Use `overlayText()` with `lineReveal()` when the motion unit should be the line React Native actually rendered after width, font, font scale, alignment, language shaping, and wrapping are applied. + +```tsx +import { + defineTextMotion, + lineReveal, + overlayText, + stagger, +} from '@react-native-motion-kit/text-motion'; + +const HeroLines = defineTextMotion() + .layout(overlayText()) + .timeline(stagger(0.08)) + .effect(lineReveal({ y: 18 })) + .motion({ kind: 'timing', options: { duration: 420 } }) + .component(); + +export function HeroTitle() { + return Design motion that feels native; +} +``` + +Do not call `.split(...)` with `overlayText()`. `overlayText()` owns rendered lines after layout, while `nativeText()` consumes source word, grapheme, custom, or explicit newline tokens. TypeScript removes incompatible chains, and runtime capability checks reject unsafe JavaScript or casted combinations. + +Use rendered line reveal for responsive hero titles, marketing headings, onboarding headlines, and concise product copy where the final visual line breaks matter. Use `nativeText()` for word or grapheme motion. Use `lines()` only when authored `\n` breaks are the token boundary you want; it does not discover automatic wrapping. + +`overlayText()` also accepts the built-in style-transform effects (`fade`, `rise`, `slide`, `shake`, `scale`, and `pulse`) because they apply to each rendered line frame. `lineReveal()` is split internally: opacity belongs to the line frame, while the vertical reveal offset moves only the paragraph copy inside the line mask. This lets `lineReveal().and(scale())`, larger finite `scale()` values, and `pulse()` grow from the measured line center without being clipped by the renderer's own mask. + +Blank and whitespace-only rendered lines are preserved as visual spacing, but they do not consume a motion index. A title such as `"First\n\nSecond"` keeps the blank line while the two nonblank lines reveal in adjacent stagger slots. + +Relayout behavior is intentionally predictable: + +- identical line measurement does not replay +- geometry-only relayout preserves the current run +- a line topology change restarts component-owned playback once +- external `progress` keeps the app-owned shared value and applies it to the new geometry +- reduced motion renders final readable source text without measurement or playback overlays +- malformed or unsupported native geometry falls back to readable final source text + +`overlayText()` creates one full paragraph copy per moving rendered line. Performance scales with visible rendered lines, not source word or grapheme count. Treat it as a title, heading, and product-copy renderer. Do not publish measured performance claims until the same case family has iOS and Android evidence. + +Before valid line geometry exists, the source paragraph remains responsible for layout. When the initial scale is exactly `1`, the pending source keeps the same combined initial opacity and transform users already see from other renderers. When the initial scale is not exactly `1`, the pending source is visually hidden until geometry is valid so the first visible scaled frame uses the measured line center. + +The renderer fixes self-clipping caused by its own line mask. It does not reserve extra layout space for large transforms and it cannot bypass clipping from a parent or ancestor with `overflow: hidden`; treat those as normal React Native transform limitations. + ### Custom ```tsx @@ -234,6 +281,7 @@ slide({ x: -12, y: 8 }); scale({ from: 0.92, to: 1 }); pulse({ scale: 1.08 }); shake({ x: 6 }); +lineReveal({ y: 18 }); ``` Effects compose with `.and(...)`: @@ -248,13 +296,15 @@ Effect factories validate their input options, but the returned effect is an opa Numeric built-in effect inputs must be finite numbers. Negative offsets are allowed where the option represents direction, such as `rise({ y })`, `slide({ x, y })`, and `shake({ x })`. +`lineReveal()` requires the `line-mask` capability from `overlayText()`. It is not compatible with `nativeText()`. + ## Native Renderer Contract `nativeText()` is the stable MVP renderer. Animated tokens use an animated `View` container with an inner `Text` so transform effects such as `rise()`, `slide()`, `scale()`, and `pulse()` are visible in React Native. Static tokens still render as plain `Text`. `nativeText()` returns an opaque renderer handle for `.layout(...)`. Descriptor fields such as `kind`, `capabilities`, and the implementation `Component` are intentionally hidden from the root user API. -This is not a full React Native `Text` drop-in. It favors reliable per-token transforms over exact platform text layout. Full RN line-to-token mapping remains deferred. +This is not a full React Native `Text` drop-in. It favors reliable per-token transforms over exact platform text layout. Use `overlayText()` when you need motion by actual rendered line. Full RN line-to-token mapping remains deferred. Components created with `nativeText()` intentionally support a narrow prop surface: @@ -475,9 +525,9 @@ The fallback is designed for resilient UI motion, not full ICU-level locale segm - `defineTextMotion()` - `graphemes()`, `words()`, `custom()` - experimental newline-only `lines()` -- `nativeText()` +- `nativeText()`, `overlayText()` - `stagger()`, `sequence()`, `parallel()`, `wave()` -- `fade()`, `rise()`, `slide()`, `scale()`, `pulse()`, `shake()` +- `fade()`, `rise()`, `slide()`, `scale()`, `pulse()`, `shake()`, `lineReveal()` - `useTextMotionControls()` with explicit `controls` prop for event-driven play/replay/reset/stop - controlled `progress` via external Reanimated shared values - parent-label accessibility policy with hidden animated token nodes @@ -491,11 +541,9 @@ These are intentionally not stable exports in the MVP: - custom effect factory API - renderer capability factory API -- `lineReveal` - `wipe` - `typewriter` - `scramble` -- stable `overlayText` - Skia renderer or Skia-only effects - playback APIs such as `pause`, `seek`, or `reverse` - first-class screen focus, in-view, scroll, or gesture drivers @@ -526,7 +574,7 @@ Core text-motion work should stay focused on: Advanced text effects can be considered when they still depend on text tokens or text layout: - typewriter, scramble, wipe, and highlight sweep effects -- reliable line-aware reveal based on actual rendered layout, once RN layout measurement and token-to-line mapping policies are stable enough to document +- word-in-line effects and RN-rendered line-to-token mapping when source range policies are stable enough to document - text-change transitions where old and new token sets need a clear playback policy Renderer-specific effects should stay behind renderer capability boundaries: @@ -553,6 +601,7 @@ Use it today for: - reusable recipe components created from presets or `defineTextMotion()` - event-driven replay/reset/stop controls without remounting the component - raw progress with an external Reanimated shared value +- rendered-line reveal for responsive hero titles, headings, and concise product copy - apps that already use Reanimated 4 and can follow its Worklets setup - accessible decorative text motion where the full phrase should remain readable once @@ -560,12 +609,12 @@ Wait for a later version if your feature depends on: - playback APIs such as `pause`, `seek`, or `reverse` - scroll progress, gesture progress, or viewport/in-view triggers as first-class drivers -- exact line-level animation, clipping, masking, or per-token line measurement +- per-token line measurement or mixed word-in-line animation - Skia-only visual effects such as blur, glow, shaders, masks, or glyph distortion - rich nested text, inline links, selectable text, or exact native `Text` behavior - long paragraphs or many animated rows without measuring performance in your target app -The next product focus is proving the controls API in real apps and then deciding whether state-transition props, `pause`/`seek`/`reverse`, screen focus helpers, in-view helpers, or scroll/gesture drivers deserve first-class APIs. Items listed under Deferred are not release promises. They should move into the stable API only when the behavior, examples, tests, and documentation are ready. +The next product focus is verifying line renderer performance and platform behavior, then deciding whether state-transition props, `pause`/`seek`/`reverse`, screen focus helpers, in-view helpers, scroll/gesture drivers, word-in-line effects, or Skia renderer candidates deserve first-class APIs. Items listed under Deferred are not release promises. They should move into the stable API only when the behavior, examples, tests, and documentation are ready. ## Development diff --git a/packages/text-motion/package.json b/packages/text-motion/package.json index a6d9e18..82590c5 100644 --- a/packages/text-motion/package.json +++ b/packages/text-motion/package.json @@ -57,7 +57,7 @@ "registry": "https://registry.npmjs.org/" }, "scripts": { - "build": "bob build", + "build": "bob build && node scripts/check-declarations.mjs", "clean": "del-cli lib", "prepare": "pnpm run build", "test": "jest", diff --git a/packages/text-motion/scripts/check-declarations.mjs b/packages/text-motion/scripts/check-declarations.mjs new file mode 100644 index 0000000..91f60ac --- /dev/null +++ b/packages/text-motion/scripts/check-declarations.mjs @@ -0,0 +1,82 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const checkedDeclarationFiles = [ + 'lib/typescript/src/effects/lineReveal.d.ts', + 'lib/typescript/src/recipe/recipe.d.ts', + 'lib/typescript/src/renderers/index.d.ts', + 'lib/typescript/src/renderers/overlayText.d.ts', + 'lib/typescript/src/types/renderer.d.ts', +]; +const publicOwnershipDeclarationFiles = [ + 'lib/typescript/src/recipe/recipe.d.ts', + 'lib/typescript/src/renderers/overlayText.d.ts', +]; +const rootDeclarationFiles = [ + 'lib/typescript/src/index.d.ts', + 'lib/typescript/src/recipe/index.d.ts', +]; + +const forbiddenPatterns = [ + /\bTextMotionOwnsLayoutRenderer\b/, + /\bTextMotionSplittableRenderer\b/, + /\bLINE_MASK_CAPABILITY\b/, + /\bTextMotionOverlayStyleTransformStatePair\b/, + /\bcreateOverlayTextStyleTransformStatePair\b/, + /\brevealContent\b/, +]; +const forbiddenPublicOwnershipPatterns = [/\bsplitter\?\s*:\s*never\b/]; +const forbiddenRootPatterns = [/\bTextMotionSplitRenderableRecipeBuilder\b/]; +const requiredPresetPatterns = [ + /import type \{ TextMotionSplitRenderableRecipeBuilder \} from '\.\.\/recipe\/recipe\.js';/, + /type NativeTextPresetBuilder = TextMotionSplitRenderableRecipeBuilder<'native-text' \| 'style-transform'>;/, + /export declare function editorialRise\(\): NativeTextPresetBuilder;/, + /export declare function softWave\(\): NativeTextPresetBuilder;/, + /export declare function gentleEmphasis\(\): NativeTextPresetBuilder;/, +]; + +function createPatternFailures(files, patterns) { + return files.flatMap((file) => { + const declaration = readFileSync(join(process.cwd(), file), 'utf8'); + + return patterns.flatMap((pattern) => { + if (!pattern.test(declaration)) { + return []; + } + + return `${file}: ${pattern.source}`; + }); + }); +} + +const failures = [ + ...createPatternFailures(checkedDeclarationFiles, forbiddenPatterns), + ...createPatternFailures(publicOwnershipDeclarationFiles, forbiddenPublicOwnershipPatterns), + ...createPatternFailures(rootDeclarationFiles, forbiddenRootPatterns), +]; + +const presetDeclaration = readFileSync( + join(process.cwd(), 'lib/typescript/src/presets/index.d.ts'), + 'utf8', +); + +const missingPresetPatterns = requiredPresetPatterns.flatMap((pattern) => { + if (pattern.test(presetDeclaration)) { + return []; + } + + return `lib/typescript/src/presets/index.d.ts missing: ${pattern.source}`; +}); + +failures.push(...missingPresetPatterns); + +if (failures.length === 0) { + console.log('Declaration surface check passed.'); + process.exit(0); +} + +console.error('Declaration surface check failed. Forbidden internal names were emitted:'); +failures.forEach((failure) => { + console.error(`- ${failure}`); +}); +process.exit(1); diff --git a/packages/text-motion/src/__tests__/capability-types.test.ts b/packages/text-motion/src/__tests__/capability-types.test.ts index cc84f4e..79ad829 100644 --- a/packages/text-motion/src/__tests__/capability-types.test.ts +++ b/packages/text-motion/src/__tests__/capability-types.test.ts @@ -4,8 +4,11 @@ import { defineTextMotion, fade, graphemes, + lineReveal, nativeText, + overlayText, parentLabelPolicy, + scale, stagger, useTextMotionControls, wave, @@ -13,12 +16,29 @@ import { type TextMotionControls, type TextMotionComponentProps, type TextMotionEffect, + type TextMotionRenderer, type TextMotionRendererProps, } from '@react-native-motion-kit/text-motion'; +import type { TextMotionRecipeConfig } from '../types/recipe'; + import { createTextMotionEffect } from '../effects/compose'; +import { editorialRise, softWave } from '../presets'; +import { createTextMotionRendererHandle } from '../recipe/descriptors'; import { createTextMotionRendererCapability } from '../types/renderer'; +const sourceTokenRenderer = createTextMotionRendererHandle({ + kind: 'test-source-token', + capabilities: ['style-transform'], + Component: () => null, +}); +const renderedLineRenderer = createTextMotionRendererHandle({ + kind: 'test-rendered-line', + capabilities: ['style-transform'], + motionUnit: 'rendered-line', + Component: () => null, +}); + describe('renderer capability types', () => { it('keeps capability checks in the TypeScript surface', () => { expect(true).toBe(true); @@ -192,10 +212,89 @@ function expectRendererHandlesDoNotExposeDescriptors() { // @ts-expect-error renderer handles do not expose implementation components. void renderer.Component; + + // @ts-expect-error renderer handles do not expose internal motion units. + void renderer.motionUnit; } void expectRendererHandlesDoNotExposeDescriptors; +function expectRendererMotionUnitCompatibility() { + const publicRendererAnnotation: TextMotionRenderer<'native-text' | 'style-transform'> = + nativeText(); + const publicRecipeAnnotation: TextMotionRecipeConfig<'native-text' | 'style-transform'> = + defineTextMotion().layout(nativeText()).recipe(); + + void publicRendererAnnotation; + void publicRecipeAnnotation; + + defineTextMotion().split(words()).layout(nativeText()).effect(fade()).component(); + defineTextMotion().layout(nativeText()).split(words()).effect(fade()).component(); + defineTextMotion().split(words()).layout(sourceTokenRenderer).effect(fade()).component(); + defineTextMotion().layout(renderedLineRenderer).effect(fade()).component(); + defineTextMotion().layout(overlayText()).effect(lineReveal()).component(); + defineTextMotion() + .layout(overlayText()) + .effect(lineReveal().and(scale({ from: 0.98 }))); + defineTextMotion().split(words()).layout(nativeText()).layout(nativeText()).effect(fade()); + defineTextMotion().layout(nativeText()).split(words()).layout(nativeText()).effect(fade()); + defineTextMotion().layout(nativeText()).layout(overlayText()).effect(lineReveal()); + + // @ts-expect-error rendered-line renderers own rendered lines and cannot accept source splitters. + defineTextMotion().layout(renderedLineRenderer).split(words()); + + // @ts-expect-error splitter-selected builders reject rendered-line renderers. + defineTextMotion().split(words()).layout(renderedLineRenderer); + + // @ts-expect-error overlayText owns rendered lines and cannot accept source splitters. + defineTextMotion().layout(overlayText()).split(words()); + + // @ts-expect-error splitter-selected builders reject overlayText. + defineTextMotion().split(words()).layout(overlayText()); + + // @ts-expect-error source-token layout replacement after split keeps rejecting overlayText. + defineTextMotion().split(words()).layout(nativeText()).layout(overlayText()); + + // @ts-expect-error source-token layout replacement after split keeps rejecting overlayText. + defineTextMotion().layout(nativeText()).split(words()).layout(overlayText()); + + // @ts-expect-error lineReveal requires line-mask, not nativeText. + defineTextMotion().split(words()).layout(nativeText()).effect(lineReveal()); +} + +void expectRendererMotionUnitCompatibility; + +function expectPresetSplitStateCompatibility() { + editorialRise().effect(fade()).component(); + softWave().layout(nativeText()).effect(fade()).component(); + + // @ts-expect-error presets already include words(), so rendered-line replacement is rejected. + editorialRise().layout(overlayText()); + + // @ts-expect-error presets preserve splitter-present state through their exported return type. + softWave().layout(overlayText()); +} + +void expectPresetSplitStateCompatibility; + +function expectPublicTypesDoNotAcceptMotionUnitGenerics() { + // @ts-expect-error TextMotionRenderer does not expose a motion-unit generic parameter. + type RendererWithMotionUnit = TextMotionRenderer<'style-transform', unknown, unknown>; + + // @ts-expect-error TextMotionRecipeConfig does not expose a motion-unit generic parameter. + type RecipeWithMotionUnit = TextMotionRecipeConfig< + 'style-transform', + never, + readonly TextMotionEffect[], + unknown + >; + + const useAliases = undefined as unknown as RendererWithMotionUnit | RecipeWithMotionUnit; + void useAliases; +} + +void expectPublicTypesDoNotAcceptMotionUnitGenerics; + function expectPublicRendererRecipeDoesNotWidenDescriptorOptions() { // @ts-expect-error public renderer props expose effect handles, not descriptors. void publicRendererProps.recipe.effects[0]?.options; diff --git a/packages/text-motion/src/__tests__/effects.test.ts b/packages/text-motion/src/__tests__/effects.test.ts index 54bc425..5af1360 100644 --- a/packages/text-motion/src/__tests__/effects.test.ts +++ b/packages/text-motion/src/__tests__/effects.test.ts @@ -1,5 +1,13 @@ import * as textMotion from '@react-native-motion-kit/text-motion'; -import { fade, pulse, rise, scale, shake, slide } from '@react-native-motion-kit/text-motion'; +import { + fade, + lineReveal, + pulse, + rise, + scale, + shake, + slide, +} from '@react-native-motion-kit/text-motion'; import { readTextMotionEffectDescriptor } from '../recipe/descriptors'; @@ -32,6 +40,10 @@ describe('effects', () => { createEffect: () => shake({ x: Number.POSITIVE_INFINITY }), message: 'shake x must be a finite number', }, + { + createEffect: () => lineReveal({ y: Number.NaN }), + message: 'lineReveal y must be a finite number', + }, ]; it('uses native-text capability for stable effects', () => { @@ -42,17 +54,19 @@ describe('effects', () => { scale({ from: 0.9 }), pulse({ scale: 1.02 }), shake({ x: 3 }), + lineReveal({ fromOpacity: 0.2, y: 10 }), ]; expect( effects.map((effect) => readTextMotionEffectDescriptor(effect).requiredCapabilities), ).toEqual([ - ['native-text'], - ['native-text'], - ['native-text'], - ['native-text'], - ['native-text'], - ['native-text'], + ['style-transform'], + ['style-transform'], + ['style-transform'], + ['style-transform'], + ['style-transform'], + ['style-transform'], + ['line-mask', 'style-transform'], ]); }); @@ -73,7 +87,7 @@ describe('effects', () => { }); it('does not expose deferred stable APIs', () => { - expect('lineReveal' in textMotion).toBe(false); + expect('lineReveal' in textMotion).toBe(true); expect('typewriter' in textMotion).toBe(false); expect('scramble' in textMotion).toBe(false); expect('wipe' in textMotion).toBe(false); diff --git a/packages/text-motion/src/__tests__/index.test.tsx b/packages/text-motion/src/__tests__/index.test.tsx index 9c324b3..6f7460e 100644 --- a/packages/text-motion/src/__tests__/index.test.tsx +++ b/packages/text-motion/src/__tests__/index.test.tsx @@ -3,6 +3,7 @@ import { fade, parentLabelPolicy, rise, + words, type TextMotionMotionConfig, type TextMotionRenderer, } from '@react-native-motion-kit/text-motion'; @@ -12,9 +13,16 @@ import { readTextMotionEffectDescriptor, } from '../recipe/descriptors'; -const testRenderer: TextMotionRenderer<'native-text'> = createTextMotionRendererHandle({ - kind: 'test-native-text', - capabilities: ['native-text'], +const testRenderer: TextMotionRenderer<'native-text' | 'style-transform'> = + createTextMotionRendererHandle({ + kind: 'test-native-text', + capabilities: ['native-text', 'style-transform'], + Component: () => null, + }); +const renderedLineRenderer = createTextMotionRendererHandle({ + kind: 'test-rendered-line', + capabilities: ['style-transform'], + motionUnit: 'rendered-line', Component: () => null, }); @@ -43,7 +51,7 @@ describe('defineTextMotion', () => { expect(effect ? readTextMotionEffectDescriptor(effect).name : undefined).toBe('fade+rise'); expect( effect ? readTextMotionEffectDescriptor(effect).requiredCapabilities : undefined, - ).toEqual(['native-text']); + ).toEqual(['style-transform']); expect(typeof Component).toBe('function'); }); @@ -61,6 +69,18 @@ describe('defineTextMotion', () => { expect(() => Component({ children: 42 })).toThrow('expects a string child'); }); + it('rejects rendered-line renderers combined with splitters through unsafe paths', () => { + const Component = defineTextMotion() + .split(words()) + .layout(renderedLineRenderer as unknown as typeof testRenderer) + .component(); + const UnsafeComponent = Component as (props: { children: string }) => unknown; + + expect(() => UnsafeComponent({ children: 'Unsafe split' })).toThrow( + 'rendered-line renderers cannot be combined with .split(...)', + ); + }); + it('rejects non-finite numeric motion options before storing the recipe', () => { expect(() => defineTextMotion().motion({ diff --git a/packages/text-motion/src/__tests__/lineReveal.test.ts b/packages/text-motion/src/__tests__/lineReveal.test.ts new file mode 100644 index 0000000..4e7e00d --- /dev/null +++ b/packages/text-motion/src/__tests__/lineReveal.test.ts @@ -0,0 +1,70 @@ +import { + defineTextMotion, + lineReveal, + nativeText, + overlayText, + scale, +} from '@react-native-motion-kit/text-motion'; +import { render } from '@testing-library/react-native'; +import { createElement } from 'react'; + +import { readTextMotionEffectDescriptor } from '../recipe/descriptors'; +import { createTextMotionStyleTransformStatePair } from '../renderers/rendererMotion'; + +describe('lineReveal', () => { + it('creates a private line-mask style-transform effect descriptor', () => { + const descriptor = readTextMotionEffectDescriptor(lineReveal({ fromOpacity: 0.4, y: -8 })); + + expect(descriptor).toMatchObject({ + name: 'lineReveal', + options: { fromOpacity: 0.4, y: -8 }, + requiredCapabilities: ['line-mask', 'style-transform'], + }); + }); + + it('validates numeric options at the factory boundary', () => { + expect(() => lineReveal({ y: Number.NaN })).toThrow('lineReveal y must be a finite number'); + expect(() => lineReveal({ fromOpacity: Number.POSITIVE_INFINITY })).toThrow( + 'lineReveal fromOpacity must be a finite number', + ); + }); + + it('composes with common style-transform effects deterministically', () => { + expect( + createTextMotionStyleTransformStatePair([ + lineReveal({ fromOpacity: 0.2, y: 10 }).and(scale({ from: 0.9 })), + ]), + ).toEqual({ + initial: { + opacity: 0.2, + scale: 0.9, + translateX: 0, + translateY: 10, + }, + pulseScale: 1, + target: { + opacity: 1, + scale: 1, + translateX: 0, + translateY: 0, + }, + }); + }); + + it('builds a rendered-line recipe without a public splitter', () => { + const Component = defineTextMotion().layout(overlayText()).effect(lineReveal()).component(); + + expect(typeof Component).toBe('function'); + }); + + it('throws when unsafe JavaScript combines lineReveal with nativeText', async () => { + const Broken = defineTextMotion() + .layout(nativeText()) + .effect(lineReveal() as never) + .component(); + + await expect(render(createElement(Broken, undefined, 'Unsafe line reveal'))).rejects.toThrow( + '@react-native-motion-kit/text-motion effect "lineReveal" requires capability "line-mask", but renderer "nativeText" does not provide it.', + ); + }); +}); diff --git a/packages/text-motion/src/__tests__/nativeText.test.tsx b/packages/text-motion/src/__tests__/nativeText.test.tsx index 20cea20..cfbbbeb 100644 --- a/packages/text-motion/src/__tests__/nativeText.test.tsx +++ b/packages/text-motion/src/__tests__/nativeText.test.tsx @@ -8,6 +8,8 @@ import { parentLabelPolicy, pulse, rise, + scale, + slide, stagger, words, } from '@react-native-motion-kit/text-motion'; @@ -149,8 +151,11 @@ describe('nativeText', () => { jest.useRealTimers(); }); - it('declares native-text capability', () => { - expect(readTextMotionRendererDescriptor(nativeText()).capabilities).toEqual(['native-text']); + it('declares source-token native text and style-transform support', () => { + expect(readTextMotionRendererDescriptor(nativeText())).toMatchObject({ + capabilities: ['native-text', 'style-transform'], + motionUnit: 'source-token', + }); }); it('keeps descriptor fields off the public renderer runtime object', () => { @@ -383,6 +388,38 @@ describe('nativeText', () => { }); }); + it('composes built-in transform effects without changing the nativeText render tree', async () => { + const Reveal = defineTextMotion() + .split(words()) + .layout(nativeText({ testIDPrefix: 'word' })) + .effect( + fade({ from: 0.25 }) + .and(slide({ x: 6, y: 8 })) + .and(scale({ from: 0.5, to: 1.1 })), + ) + .motion({ kind: 'timing', options: { duration: 400 } }) + .component(); + + await render(Hello); + + const tokenContainer = getHiddenToken('word-0'); + const tokenText = getHiddenTokenText('word-0'); + + expect(tokenText).toHaveTextContent('Hello'); + expect(tokenContainer).not.toHaveProp('allowFontScaling'); + expect(tokenContainer).toHaveAnimatedStyle({ + opacity: 0.25, + transform: [{ translateX: 6 }, { translateY: 8 }, { scale: 0.5 }], + }); + + jest.advanceTimersByTime(400); + + expect(tokenContainer).toHaveAnimatedStyle({ + opacity: 1, + transform: [{ translateX: 0 }, { translateY: 0 }, { scale: 1.1 }], + }); + }); + it('does not count whitespace tokens in timeline delays', async () => { const Reveal = defineTextMotion() .split(words()) diff --git a/packages/text-motion/src/__tests__/nativeTextProgress.test.ts b/packages/text-motion/src/__tests__/nativeTextProgress.test.ts deleted file mode 100644 index a15c075..0000000 --- a/packages/text-motion/src/__tests__/nativeTextProgress.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { - clampNativeTextProgress, - createNativeTextControlledTimelineSpan, - mapNativeTextControlledProgressToTokenProgress, -} from '../renderers/nativeTextProgress'; - -describe('nativeTextProgress', () => { - it('keeps finite progress inside the normalized range', () => { - expect(clampNativeTextProgress(0.4)).toBe(0.4); - }); - - it('clamps negative progress to the initial state', () => { - expect(clampNativeTextProgress(-0.1)).toBe(0); - }); - - it('clamps overflowing progress to the final state', () => { - expect(clampNativeTextProgress(1.4)).toBe(1); - }); - - it('treats non-finite progress as the initial state', () => { - expect(clampNativeTextProgress(Number.NaN)).toBe(0); - expect(clampNativeTextProgress(Number.POSITIVE_INFINITY)).toBe(0); - expect(clampNativeTextProgress(Number.NEGATIVE_INFINITY)).toBe(0); - }); - - it('uses one token span when no animated token delays exist', () => { - expect(createNativeTextControlledTimelineSpan([])).toBe(1); - }); - - it('adds the fixed token span to the last token delay', () => { - expect(createNativeTextControlledTimelineSpan([0, 0.25, 0.8])).toBe(1.8); - }); - - it('keeps token progress at zero before its delay starts', () => { - expect( - mapNativeTextControlledProgressToTokenProgress({ - progress: 0.25, - tokenDelaySeconds: 0.75, - totalTimelineSpan: 2, - }), - ).toBe(0); - }); - - it('returns partial token progress after the token delay starts', () => { - expect( - mapNativeTextControlledProgressToTokenProgress({ - progress: 0.5, - tokenDelaySeconds: 0.25, - totalTimelineSpan: 2, - }), - ).toBe(0.75); - }); - - it('keeps token progress at one after its span completes', () => { - expect( - mapNativeTextControlledProgressToTokenProgress({ - progress: 1, - tokenDelaySeconds: 0.25, - totalTimelineSpan: 2, - }), - ).toBe(1); - }); - - it('matches the existing staggered renderer mapping example', () => { - const totalTimelineSpan = createNativeTextControlledTimelineSpan([0, 0.5, 1]); - - expect( - mapNativeTextControlledProgressToTokenProgress({ - progress: 0.5, - tokenDelaySeconds: 0, - totalTimelineSpan, - }), - ).toBe(1); - expect( - mapNativeTextControlledProgressToTokenProgress({ - progress: 0.5, - tokenDelaySeconds: 0.5, - totalTimelineSpan, - }), - ).toBe(0.5); - expect( - mapNativeTextControlledProgressToTokenProgress({ - progress: 0.5, - tokenDelaySeconds: 1, - totalTimelineSpan, - }), - ).toBe(0); - }); -}); diff --git a/packages/text-motion/src/__tests__/overlayText.test.tsx b/packages/text-motion/src/__tests__/overlayText.test.tsx new file mode 100644 index 0000000..570e163 --- /dev/null +++ b/packages/text-motion/src/__tests__/overlayText.test.tsx @@ -0,0 +1,971 @@ +import { + defineTextMotion, + fade, + lineReveal, + overlayText, + parentLabelPolicy, + pulse, + scale, + stagger, +} from '@react-native-motion-kit/text-motion'; +import { act, fireEvent, render, screen } from '@testing-library/react-native'; +import * as Reanimated from 'react-native-reanimated'; + +import type { TextMotionAccessibilityPolicy, TextMotionComponentProps } from '../types'; + +import { + createTextMotionControlsHandle, + readTextMotionControlsDescriptor, +} from '../controls/descriptors'; +import { readTextMotionRendererDescriptor } from '../recipe/descriptors'; + +const TWO_LINE_PAYLOAD = { + nativeEvent: { + lines: [ + { height: 20, text: 'Hello', width: 80, x: 0, y: 0 }, + { height: 20, text: 'motion', width: 96, x: 0, y: 20 }, + ], + }, +}; +const TWO_LINE_GEOMETRY_ONLY_PAYLOAD = { + nativeEvent: { + lines: [ + { height: 20, text: 'Hello', width: 80, x: 0, y: 0 }, + { height: 20, text: 'motion', width: 96, x: 0, y: 20.75 }, + ], + }, +}; +const THREE_LINE_TOPOLOGY_PAYLOAD = { + nativeEvent: { + lines: [ + { height: 20, text: 'Hello', width: 80, x: 0, y: 0 }, + { height: 20, text: 'native', width: 84, x: 0, y: 20 }, + { height: 20, text: 'motion', width: 96, x: 0, y: 40 }, + ], + }, +}; +const FRESH_TWO_LINE_PAYLOAD = { + nativeEvent: { + lines: [ + { height: 20, text: 'Fresh', width: 80, x: 0, y: 0 }, + { height: 20, text: 'motion', width: 96, x: 0, y: 20 }, + ], + }, +}; +const CENTERED_LINE_PAYLOAD = { + nativeEvent: { + lines: [{ height: 20, text: 'Hello motion', width: 100, x: 50, y: 0 }], + }, +}; +const RIGHT_TWO_LINE_GEOMETRY_ONLY_PAYLOAD = { + nativeEvent: { + lines: [ + { height: 20, text: 'Hello', width: 80, x: 100, y: 0 }, + { height: 20, text: 'motion', width: 96, x: 104, y: 20.75 }, + ], + }, +}; +const INVALID_LAYOUT_PAYLOAD = { + nativeEvent: { + lines: [{ height: 0, text: 'Hello', width: 80, x: 0, y: 0 }], + }, +}; +const SOURCE_ACCESSIBLE_POLICY = { + hideTokensFromAccessibility: false, + kind: 'source-accessible', + parentLabel: false, + reducedMotion: 'system', +} as const satisfies TextMotionAccessibilityPolicy; + +function getHidden(testID: string) { + return screen.getByTestId(testID, { includeHiddenElements: true }); +} + +function expectLineOverlayPending() { + expect(getHidden('line-overlay').props.style).toEqual( + expect.arrayContaining([expect.objectContaining({ opacity: 0 })]), + ); +} + +function expectLineOverlayReady() { + expect(getHidden('line-overlay').props.style).not.toEqual( + expect.arrayContaining([expect.objectContaining({ opacity: 0 })]), + ); +} + +function expectLineFrameStyle(testID: string, opacity: number, scaleValue = 1) { + expect(getHidden(testID)).toHaveAnimatedStyle({ + opacity, + transform: [{ translateX: 0 }, { translateY: 0 }, { scale: scaleValue }], + }); +} + +function expectRevealContentStyle(testID: string, translateY: number) { + expect(getHidden(testID)).toHaveAnimatedStyle({ + opacity: 1, + transform: [{ translateX: 0 }, { translateY }, { scale: 1 }], + }); +} + +function expectTwoLineMaskTopology() { + expect(getHidden('line-0')).toBeTruthy(); + expect(getHidden('line-1')).toBeTruthy(); + expect(screen.queryByTestId('line-2', { includeHiddenElements: true })).toBeNull(); +} + +function getSourceText(text: string) { + const source = screen.getAllByText(text, { includeHiddenElements: true })[0]; + + if (!source) { + throw new Error(`Missing source text "${text}".`); + } + + return source; +} + +function getTextLayoutHandler(source: ReturnType) { + const onTextLayout = source.props.onTextLayout; + + if (typeof onTextLayout !== 'function') { + throw new Error('Expected source Text to expose onTextLayout.'); + } + + return onTextLayout; +} + +async function emitTextLayout( + source: ReturnType, + event: { nativeEvent: { lines: readonly unknown[] } }, +) { + const onTextLayout = getTextLayoutHandler(source); + + await act(async () => { + onTextLayout(event); + }); +} + +function repeatSequentially(count: number, operation: (index: number) => Promise) { + return Array.from({ length: count }, (_, index) => index).reduce( + (sequence, index) => sequence.then(() => operation(index)), + Promise.resolve(), + ); +} + +describe('overlayText', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.mocked(Reanimated.useReducedMotion).mockReturnValue(false); + }); + + afterEach(() => { + jest.mocked(Reanimated.useReducedMotion).mockReset(); + jest.useRealTimers(); + }); + + it('declares rendered-line line-mask and style-transform support', () => { + expect(readTextMotionRendererDescriptor(overlayText())).toMatchObject({ + capabilities: ['line-mask', 'style-transform'], + motionUnit: 'rendered-line', + }); + expect(Object.keys(overlayText())).toEqual([]); + }); + + it('renders pending source with the initial visual state, then line masks after layout', async () => { + const Reveal = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'line' })) + .effect(lineReveal({ fromOpacity: 0.25, y: 12 })) + .motion({ kind: 'timing', options: { duration: 300 } }) + .component(); + + await render(Hello motion); + const source = getSourceText('Hello motion'); + + expect(screen.getByTestId('headline')).toHaveAccessibleName('Hello motion'); + expect(source).toHaveProp('accessible', false); + expect(source.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + opacity: 0.25, + transform: [{ translateX: 0 }, { translateY: 12 }, { scale: 1 }], + }), + ]), + ); + expect(screen.queryByTestId('line-overlay', { includeHiddenElements: true })).toBeNull(); + + await emitTextLayout(source, TWO_LINE_PAYLOAD); + + expect(getHidden('line-0').props.style).toEqual( + expect.arrayContaining([expect.objectContaining({ height: 20, top: 0 })]), + ); + expect(getHidden('line-1').props.style).toEqual( + expect.arrayContaining([expect.objectContaining({ height: 20, top: 20 })]), + ); + expect(getHidden('line-0-copy').props.style).toEqual( + expect.arrayContaining([expect.objectContaining({ top: 0 })]), + ); + expect(getHidden('line-1-copy').props.style).toEqual( + expect.arrayContaining([expect.objectContaining({ top: -20 })]), + ); + expect(getHidden('line-0').props.style).toEqual( + expect.arrayContaining([expect.objectContaining({ transformOrigin: [40, 10, 0] })]), + ); + expectLineFrameStyle('line-0', 0.25); + expectRevealContentStyle('line-0-motion', 12); + }); + + it('maps external progress over rendered lines without internal autoplay', async () => { + expect.hasAssertions(); + + const progress = Reanimated.makeMutable(0.5); + const Reveal = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'line' })) + .timeline(stagger(0.5)) + .effect(lineReveal({ y: 10 })) + .motion({ kind: 'timing', options: { duration: 100 } }) + .component(); + + const result = await render( + + Hello motion + , + ); + + await emitTextLayout(getSourceText('Hello motion'), TWO_LINE_PAYLOAD); + + await act(async () => { + await jest.advanceTimersByTimeAsync(200); + }); + + expectLineFrameStyle('line-0', 0.75); + expectLineFrameStyle('line-1', 0.25); + expectRevealContentStyle('line-0-motion', 2.5); + expectRevealContentStyle('line-1-motion', 7.5); + + await result.rerender( + + Hello motion + , + ); + + expectLineOverlayPending(); + expectLineFrameStyle('line-0', 0.75); + expectRevealContentStyle('line-0-motion', 2.5); + + await emitTextLayout(getSourceText('Hello motion'), TWO_LINE_GEOMETRY_ONLY_PAYLOAD); + + expectLineOverlayReady(); + expectLineFrameStyle('line-0', 0.75); + expectLineFrameStyle('line-1', 0.25); + expectRevealContentStyle('line-0-motion', 2.5); + expectRevealContentStyle('line-1-motion', 7.5); + }); + + it('places scale and pulse on the outer frame so the reveal content remains mask-relative', async () => { + const progress = Reanimated.makeMutable(0.5); + const Reveal = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'line' })) + .effect( + lineReveal({ y: 10 }) + .and(scale({ from: 1.5, to: 1 })) + .and(pulse({ scale: 1.2 })), + ) + .component(); + + await render(Hello motion); + await emitTextLayout(getSourceText('Hello motion'), CENTERED_LINE_PAYLOAD); + + expect(getHidden('line-0').props.style).toEqual( + expect.arrayContaining([expect.objectContaining({ transformOrigin: [100, 10, 0] })]), + ); + expectLineFrameStyle('line-0', 0.5, 1.5); + expectRevealContentStyle('line-0-motion', 5); + }); + + it('renders final overlay channel states from already-advanced controlled progress', async () => { + expect.hasAssertions(); + + const progress = Reanimated.makeMutable(1); + const Reveal = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'line' })) + .effect( + lineReveal({ y: 10 }) + .and(scale({ from: 1.5, to: 1 })) + .and(pulse({ scale: 1.2 })), + ) + .component(); + + await render(Hello motion); + await emitTextLayout(getSourceText('Hello motion'), CENTERED_LINE_PAYLOAD); + + expectLineFrameStyle('line-0', 1); + expectRevealContentStyle('line-0-motion', 0); + }); + + it('keeps non-identity initial scale invisible until valid line geometry is ready', async () => { + const ScaleReveal = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'line' })) + .effect(scale({ from: 1.5, to: 1 })) + .component(); + + await render(Hello motion); + + expect(getSourceText('Hello motion').props.style).toEqual( + expect.arrayContaining([expect.objectContaining({ opacity: 0 })]), + ); + expect(screen.queryByTestId('line-overlay', { includeHiddenElements: true })).toBeNull(); + + await emitTextLayout(getSourceText('Hello motion'), CENTERED_LINE_PAYLOAD); + + expectLineOverlayReady(); + expectLineFrameStyle('line-0', 1, 1.5); + expectRevealContentStyle('line-0-motion', 0); + }); + + it('keeps exact identity initial scale on the combined pending source style', async () => { + const PulseReveal = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'line' })) + .effect(pulse({ scale: 1.05 })) + .component(); + + await render(Hello motion); + + expect(getSourceText('Hello motion').props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + opacity: 1, + transform: [{ translateX: 0 }, { translateY: 0 }, { scale: 1 }], + }), + ]), + ); + }); + + it('treats near-identity initial scale as non-identity for pending visibility', async () => { + const NearIdentityReveal = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'line' })) + .effect(scale({ from: 1.0001, to: 1 })) + .component(); + + await render(Hello motion); + + expect(getSourceText('Hello motion').props.style).toEqual( + expect.arrayContaining([expect.objectContaining({ opacity: 0 })]), + ); + }); + + it('lets fade set overlay frame opacity after lineReveal in composed order', async () => { + expect.hasAssertions(); + + const progress = Reanimated.makeMutable(0); + const RevealThenFade = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'line' })) + .effect(lineReveal({ fromOpacity: 0.2 }).and(fade({ from: 0.4, to: 0.7 }))) + .component(); + await render(Hello motion); + + await emitTextLayout(getSourceText('Hello motion'), CENTERED_LINE_PAYLOAD); + + expectLineFrameStyle('line-0', 0.4); + }); + + it('lets lineReveal set overlay frame opacity after fade without overwriting fade target', async () => { + expect.hasAssertions(); + + const progress = Reanimated.makeMutable(1); + const FadeThenReveal = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'line' })) + .effect(fade({ from: 0.4, to: 0.7 }).and(lineReveal({ fromOpacity: 0.2 }))) + .component(); + + await render(Hello motion); + await emitTextLayout(getSourceText('Hello motion'), CENTERED_LINE_PAYLOAD); + + expectLineFrameStyle('line-0', 0.7); + }); + + it('keeps geometry-only relayout on the current run and restarts topology changes', async () => { + const Reveal = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'line' })) + .effect(lineReveal()) + .motion({ kind: 'timing', options: { duration: 300 } }) + .component(); + + await render(Hello motion); + const source = getSourceText('Hello motion'); + + await emitTextLayout(source, TWO_LINE_PAYLOAD); + + await act(async () => { + await jest.advanceTimersByTimeAsync(150); + }); + + expectLineFrameStyle('line-0', 0.5); + expectRevealContentStyle('line-0-motion', 8); + + await emitTextLayout(source, TWO_LINE_GEOMETRY_ONLY_PAYLOAD); + + expectLineFrameStyle('line-0', 0.5); + expectRevealContentStyle('line-0-motion', 8); + + await emitTextLayout(source, RIGHT_TWO_LINE_GEOMETRY_ONLY_PAYLOAD); + + expect(getHidden('line-0').props.style).toEqual( + expect.arrayContaining([expect.objectContaining({ transformOrigin: [140, 10, 0] })]), + ); + expectLineFrameStyle('line-0', 0.5); + expectRevealContentStyle('line-0-motion', 8); + + await emitTextLayout(source, THREE_LINE_TOPOLOGY_PAYLOAD); + + expectLineFrameStyle('line-0', 0); + expectRevealContentStyle('line-0-motion', 16); + }); + + it('hides retained masks while children are pending and restarts changed topology once', async () => { + const Reveal = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'line' })) + .effect(lineReveal()) + .motion({ kind: 'timing', options: { duration: 300 } }) + .component(); + const result = await render(Hello motion); + + await emitTextLayout(getSourceText('Hello motion'), TWO_LINE_PAYLOAD); + + await act(async () => { + await jest.advanceTimersByTimeAsync(150); + }); + + expectLineFrameStyle('line-0', 0.5); + expectRevealContentStyle('line-0-motion', 8); + expect(getHidden('line-0-copy')).toHaveTextContent('Hello motion'); + + await result.rerender(Fresh motion); + + expectLineOverlayPending(); + expect(getHidden('line-0-copy')).toHaveTextContent('Fresh motion'); + expectLineFrameStyle('line-0', 0.5); + expectRevealContentStyle('line-0-motion', 8); + + await emitTextLayout(getSourceText('Fresh motion'), FRESH_TWO_LINE_PAYLOAD); + + expectLineOverlayReady(); + expectLineFrameStyle('line-0', 0); + expectRevealContentStyle('line-0-motion', 16); + + await act(async () => { + await jest.advanceTimersByTimeAsync(150); + }); + + await emitTextLayout(getSourceText('Fresh motion'), FRESH_TWO_LINE_PAYLOAD); + + expectLineFrameStyle('line-0', 0.5); + expectRevealContentStyle('line-0-motion', 8); + }); + + it('restarts owned playback when accepted source text changes with identical line topology', async () => { + expect.hasAssertions(); + + const Reveal = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'line' })) + .effect(lineReveal()) + .motion({ kind: 'timing', options: { duration: 300 } }) + .component(); + const result = await render(Hello motion); + + await emitTextLayout(getSourceText('Hello motion'), TWO_LINE_PAYLOAD); + + await act(async () => { + await jest.advanceTimersByTimeAsync(150); + }); + + expectLineFrameStyle('line-0', 0.5); + expectRevealContentStyle('line-0-motion', 8); + + await result.rerender({'Hello motion\n'}); + await emitTextLayout(getSourceText('Hello motion\n'), TWO_LINE_PAYLOAD); + + expectLineFrameStyle('line-0', 0); + expectRevealContentStyle('line-0-motion', 16); + }); + + it.each([ + ['identical geometry', TWO_LINE_PAYLOAD], + ['geometry-only change', TWO_LINE_GEOMETRY_ONLY_PAYLOAD], + ])('preserves playback through a cross-input %s rebind', async (_layoutKind, nextPayload) => { + const controls = createTextMotionControlsHandle(); + const descriptor = readTextMotionControlsDescriptor(controls); + const Reveal = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'line' })) + .effect(lineReveal()) + .motion({ kind: 'timing', options: { duration: 300 } }) + .component(); + const result = await render( + + Hello motion + , + ); + + await emitTextLayout(getSourceText('Hello motion'), TWO_LINE_PAYLOAD); + + await act(async () => { + await jest.advanceTimersByTimeAsync(150); + }); + + expectLineFrameStyle('line-0', 0.5); + expectRevealContentStyle('line-0-motion', 8); + expect(descriptor.getListenerCount()).toBe(2); + + await result.rerender( + + Hello motion + , + ); + + expectLineOverlayPending(); + expect(getSourceText('Hello motion').props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + opacity: 0, + transform: [{ translateX: 0 }, { translateY: 16 }, { scale: 1 }], + }), + ]), + ); + expectLineFrameStyle('line-0', 0.5); + expectRevealContentStyle('line-0-motion', 8); + expect(descriptor.getListenerCount()).toBe(2); + + await emitTextLayout(getSourceText('Hello motion'), nextPayload); + + expectLineOverlayReady(); + expectLineFrameStyle('line-0', 0.5); + expectRevealContentStyle('line-0-motion', 8); + expect(descriptor.getListenerCount()).toBe(2); + }); + + it('preserves mask listeners and in-flight progress for 100 identical layout payloads', async () => { + const controls = createTextMotionControlsHandle(); + const descriptor = readTextMotionControlsDescriptor(controls); + const Reveal = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'line' })) + .effect(lineReveal()) + .motion({ kind: 'timing', options: { duration: 300 } }) + .component(); + + await render(Hello motion); + await emitTextLayout(getSourceText('Hello motion'), TWO_LINE_PAYLOAD); + + await act(async () => { + await jest.advanceTimersByTimeAsync(150); + }); + + expectTwoLineMaskTopology(); + expect(descriptor.getListenerCount()).toBe(2); + expectLineFrameStyle('line-0', 0.5); + expectRevealContentStyle('line-0-motion', 8); + + const onTextLayout = getTextLayoutHandler(getSourceText('Hello motion')); + + await act(async () => { + Array.from({ length: 100 }).forEach(() => { + onTextLayout(TWO_LINE_PAYLOAD); + }); + }); + + expectTwoLineMaskTopology(); + expect(descriptor.getListenerCount()).toBe(2); + expectLineFrameStyle('line-0', 0.5); + expectRevealContentStyle('line-0-motion', 8); + }); + + it('keeps two line listeners and masks after ten controls replay commands', async () => { + const controls = createTextMotionControlsHandle(); + const descriptor = readTextMotionControlsDescriptor(controls); + const Reveal = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'line' })) + .effect(lineReveal()) + .component(); + + await render(Hello motion); + await emitTextLayout(getSourceText('Hello motion'), TWO_LINE_PAYLOAD); + + expectTwoLineMaskTopology(); + expect(descriptor.getListenerCount()).toBe(2); + + await act(async () => { + Array.from({ length: 10 }).forEach(() => { + controls.replay(); + }); + }); + + expectTwoLineMaskTopology(); + expect(descriptor.getListenerCount()).toBe(2); + }); + + it('preserves mask topology across repeated external progress writes without relayout', async () => { + expect.hasAssertions(); + + const progress = Reanimated.makeMutable(0.75); + const Reveal = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'line' })) + .effect(lineReveal()) + .component(); + + const result = await render(Hello motion); + await emitTextLayout(getSourceText('Hello motion'), TWO_LINE_PAYLOAD); + + expectTwoLineMaskTopology(); + expectLineFrameStyle('line-0', 0.75); + expectRevealContentStyle('line-0-motion', 4); + + await repeatSequentially(20, async (index) => { + const nextProgress = index % 2 === 0 ? 0.25 : 0.75; + + await act(async () => { + progress.value = nextProgress; + }); + + await result.rerender(Hello motion); + + expectTwoLineMaskTopology(); + }); + + expectTwoLineMaskTopology(); + expectLineOverlayReady(); + expectLineFrameStyle('line-0', 0.75); + expectLineFrameStyle('line-1', 0.75); + expectRevealContentStyle('line-0-motion', 4); + expectRevealContentStyle('line-1-motion', 4); + }); + + it('returns shared controls listener count to zero after each overlay mount cycle', async () => { + const controls = createTextMotionControlsHandle(); + const descriptor = readTextMotionControlsDescriptor(controls); + const Reveal = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'line' })) + .effect(lineReveal()) + .component(); + + await repeatSequentially(20, async (cycle) => { + expect(descriptor.getListenerCount()).toBe(0); + + const result = await render( + {`Hello motion cycle ${cycle}`}, + ); + + await emitTextLayout(getSourceText(`Hello motion cycle ${cycle}`), TWO_LINE_PAYLOAD); + + expect(descriptor.getListenerCount()).toBe(2); + + await act(async () => { + result.unmount(); + }); + + expect(descriptor.getListenerCount()).toBe(0); + }); + }); + + it('keeps structurally equal fresh style objects on the current layout run', async () => { + expect.hasAssertions(); + + const Reveal = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'line' })) + .effect(lineReveal()) + .motion({ kind: 'timing', options: { duration: 300 } }) + .component(); + const result = await render( + Hello motion, + ); + + await emitTextLayout(getSourceText('Hello motion'), TWO_LINE_PAYLOAD); + + await act(async () => { + await jest.advanceTimersByTimeAsync(150); + }); + + await result.rerender( + Hello motion, + ); + + expectLineFrameStyle('line-0', 0.5); + expectRevealContentStyle('line-0-motion', 8); + }); + + it('keeps the current layout run for color-only changes and updates paragraph copies', async () => { + const Reveal = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'line' })) + .effect(lineReveal()) + .motion({ kind: 'timing', options: { duration: 300 } }) + .component(); + const result = await render( + Hello motion, + ); + + await emitTextLayout(getSourceText('Hello motion'), TWO_LINE_PAYLOAD); + + await act(async () => { + await jest.advanceTimersByTimeAsync(150); + }); + + await result.rerender(Hello motion); + + expectLineFrameStyle('line-0', 0.5); + expectRevealContentStyle('line-0-motion', 8); + expect(getHidden('line-0-copy').props.style).toEqual( + expect.arrayContaining([expect.objectContaining({ color: 'blue' })]), + ); + }); + + it('does not create listeners for structural blank lines', async () => { + const controls = createTextMotionControlsHandle(); + const descriptor = readTextMotionControlsDescriptor(controls); + const Reveal = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'line' })) + .effect(lineReveal()) + .component(); + + await render({'Hello\n\nmotion'}); + + await emitTextLayout(getSourceText('Hello\n\nmotion'), { + nativeEvent: { + lines: [ + { height: 20, text: 'Hello', width: 80, x: 0, y: 0 }, + { height: 20, text: '', width: 0, x: 0, y: 20 }, + { height: 20, text: 'motion', width: 96, x: 0, y: 40 }, + ], + }, + }); + + expect(descriptor.getListenerCount()).toBe(2); + expect(screen.queryByTestId('line-2', { includeHiddenElements: true })).toBeNull(); + + controls.reset(); + + expectLineFrameStyle('line-0', 0); + expectRevealContentStyle('line-0-motion', 16); + }); + + it('falls back to readable final text for invalid layout and warns once in dev', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const Reveal = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'line' })) + .effect(lineReveal()) + .component(); + + await render(Hello motion); + const source = getSourceText('Hello motion'); + const onTextLayout = getTextLayoutHandler(source); + + await act(async () => { + onTextLayout(INVALID_LAYOUT_PAYLOAD); + onTextLayout(INVALID_LAYOUT_PAYLOAD); + }); + + expect(screen.getByTestId('headline')).toHaveAccessibleName('Hello motion'); + expect(screen.queryByTestId('line-0', { includeHiddenElements: true })).toBeNull(); + expect(warn).toHaveBeenCalledTimes(1); + + warn.mockRestore(); + }); + + it('keeps final-source layout stretch when invalid layout falls back', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const Reveal = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'line' })) + .effect(lineReveal()) + .component(); + + await render( + + Hello motion + , + ); + + await emitTextLayout(getSourceText('Hello motion'), INVALID_LAYOUT_PAYLOAD); + + expect(getSourceText('Hello motion').props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ textAlign: 'right' }), + expect.objectContaining({ alignSelf: 'stretch' }), + ]), + ); + + warn.mockRestore(); + }); + + it('recovers from fallback when the next input has a valid layout', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const Reveal = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'line' })) + .effect(lineReveal()) + .component(); + const result = await render(Hello motion); + const onTextLayout = getTextLayoutHandler(getSourceText('Hello motion')); + + await act(async () => { + onTextLayout(INVALID_LAYOUT_PAYLOAD); + }); + + expect(screen.queryByTestId('line-0', { includeHiddenElements: true })).toBeNull(); + + await result.rerender(Fresh motion); + + await emitTextLayout(getSourceText('Fresh motion'), { + nativeEvent: { + lines: [ + { height: 20, text: 'Fresh', width: 80, x: 0, y: 0 }, + { height: 20, text: 'motion', width: 96, x: 0, y: 20 }, + ], + }, + }); + + expect(getHidden('line-0-copy')).toHaveTextContent('Fresh motion'); + + warn.mockRestore(); + }); + + it('renders final state immediately for reduced motion and forwards accessibility props', async () => { + jest.mocked(Reanimated.useReducedMotion).mockReturnValue(true); + + const Reveal = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'line' })) + .effect(lineReveal()) + .accessibility(parentLabelPolicy({ reducedMotion: 'final-state' })) + .component(); + + await render( + + Hello motion + , + ); + + expect(screen.getByTestId('headline')).toHaveAccessibleName('Custom label'); + expect(screen.getByTestId('headline')).toHaveProp('accessibilityHint', 'Motion headline'); + expect(screen.getByTestId('headline')).toHaveProp('accessibilityRole', 'header'); + expect(screen.getByTestId('headline')).toHaveProp('accessibilityState', { busy: false }); + expect(getSourceText('Hello motion').props.onTextLayout).toBeUndefined(); + expect(screen.queryByTestId('line-0', { includeHiddenElements: true })).toBeNull(); + }); + + it('keeps source text accessible through pending, ready, and fallback states when policy requests it', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const Reveal = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'line' })) + .effect(lineReveal()) + .accessibility(SOURCE_ACCESSIBLE_POLICY) + .component(); + const result = await render(Hello motion); + const pendingSource = getSourceText('Hello motion'); + + expect(pendingSource).not.toHaveProp('accessible', false); + expect(pendingSource).not.toHaveProp('importantForAccessibility', 'no-hide-descendants'); + + await emitTextLayout(pendingSource, TWO_LINE_PAYLOAD); + + expect(getSourceText('Hello motion')).not.toHaveProp('accessible', false); + expect(getHidden('line-0-copy')).toHaveProp('accessible', false); + + await result.rerender(Fresh motion); + await emitTextLayout(getSourceText('Fresh motion'), INVALID_LAYOUT_PAYLOAD); + + const fallbackSource = getSourceText('Fresh motion'); + + expect(fallbackSource.props.onTextLayout).toBeUndefined(); + expect(fallbackSource).not.toHaveProp('accessible', false); + expect(fallbackSource).not.toHaveProp('importantForAccessibility', 'no-hide-descendants'); + + warn.mockRestore(); + }); + + it('keeps final source text accessible when reduced motion skips overlay measurement', async () => { + jest.mocked(Reanimated.useReducedMotion).mockReturnValue(true); + const Reveal = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'line' })) + .effect(lineReveal()) + .accessibility(SOURCE_ACCESSIBLE_POLICY) + .component(); + + await render(Hello motion); + + const source = getSourceText('Hello motion'); + + expect(source.props.onTextLayout).toBeUndefined(); + expect(source).not.toHaveProp('accessible', false); + expect(source).not.toHaveProp('importantForAccessibility', 'no-hide-descendants'); + }); + + it('keeps final-source layout stretch when reduced motion skips measurement', async () => { + jest.mocked(Reanimated.useReducedMotion).mockReturnValue(true); + + const Reveal = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'line' })) + .effect(lineReveal()) + .accessibility(parentLabelPolicy({ reducedMotion: 'final-state' })) + .component(); + + await render( + + Hello motion + , + ); + + expect(getSourceText('Hello motion').props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ textAlign: 'center' }), + expect.objectContaining({ alignSelf: 'stretch' }), + ]), + ); + expect(getSourceText('Hello motion').props.onTextLayout).toBeUndefined(); + }); + + it('keeps system reduced motion on the final source without measurement or playback', async () => { + jest.mocked(Reanimated.useReducedMotion).mockReturnValue(true); + const controls = createTextMotionControlsHandle(); + const descriptor = readTextMotionControlsDescriptor(controls); + + const Reveal = defineTextMotion() + .layout(overlayText({ testIDPrefix: 'line' })) + .effect(lineReveal()) + .accessibility(parentLabelPolicy({ reducedMotion: 'system' })) + .component(); + + await render(Hello motion); + + const source = getSourceText('Hello motion'); + + expect(source.props.style).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + opacity: 0, + }), + ]), + ); + expect(source.props.onTextLayout).toBeUndefined(); + expect(descriptor.getListenerCount()).toBe(0); + + await fireEvent(source, 'textLayout', TWO_LINE_PAYLOAD); + + expect(screen.queryByTestId('line-0', { includeHiddenElements: true })).toBeNull(); + expect(screen.queryByTestId('line-0-copy', { includeHiddenElements: true })).toBeNull(); + expect(descriptor.getListenerCount()).toBe(0); + }); + + it('rejects controls and progress together at runtime', async () => { + const controls = createTextMotionControlsHandle(); + const progress = Reanimated.makeMutable(0); + const Reveal = defineTextMotion().layout(overlayText()).effect(lineReveal()).component(); + const unsafeProps = { + children: 'Hello', + controls, + progress, + } as unknown as TextMotionComponentProps; + + await expect(render()).rejects.toThrow( + '@react-native-motion-kit/text-motion cannot receive both progress and controls. Use progress for raw app-owned values, or controls for event-driven playback.', + ); + }); +}); diff --git a/packages/text-motion/src/__tests__/overlayTextLayout.test.ts b/packages/text-motion/src/__tests__/overlayTextLayout.test.ts new file mode 100644 index 0000000..e42c640 --- /dev/null +++ b/packages/text-motion/src/__tests__/overlayTextLayout.test.ts @@ -0,0 +1,148 @@ +import { + compareOverlayTextLayouts, + createOverlayTextLayout, + type OverlayTextLayoutReady, +} from '../renderers/overlayTextLayout'; + +function expectReady(result: ReturnType): OverlayTextLayoutReady { + if (result.kind !== 'ready') { + throw new Error(`Expected ready layout, received ${result.kind}.`); + } + + return result; +} + +describe('overlayTextLayout', () => { + it('normalizes valid native line geometry and assigns continuous motion indices', () => { + const layout = expectReady( + createOverlayTextLayout('one\n\ntwo', { + lines: [ + { height: 18.004, text: 'one', width: 48.002, x: 0.001, y: -0.001 }, + { height: 18, text: '', width: 0, x: 0, y: 18.0001 }, + { height: 18, text: 'two', width: 42, x: 6, y: 36 }, + ], + }), + ); + + expect(layout.lines).toEqual([ + expect.objectContaining({ isStructuralBlank: false, motionIndex: 0, text: 'one', y: 0 }), + expect.objectContaining({ isStructuralBlank: true, motionIndex: undefined, text: '' }), + expect.objectContaining({ isStructuralBlank: false, motionIndex: 1, text: 'two' }), + ]); + expect(layout.motionLines.map((line) => line.motionIndex)).toEqual([0, 1]); + expect(layout.motionCount).toBe(2); + }); + + it('returns static final source for empty or whitespace-only source', () => { + expect(createOverlayTextLayout(' \n ', { lines: [] })).toEqual({ + kind: 'static', + reason: 'empty-source', + }); + }); + + it('does not throw for malformed or unsupported geometry', () => { + expect(createOverlayTextLayout('text', {})).toEqual({ + kind: 'fallback', + reason: 'malformed-payload', + }); + expect( + createOverlayTextLayout('text', { lines: [{ height: 12, width: 20, x: 0, y: 0 }] }), + ).toEqual({ + kind: 'fallback', + reason: 'missing-line-text', + }); + expect( + createOverlayTextLayout('text', { + lines: [{ height: 0, text: 'text', width: 20, x: 0, y: 0 }], + }), + ).toEqual({ + kind: 'fallback', + reason: 'non-positive-visible-height', + }); + expect( + createOverlayTextLayout('text', { + lines: [{ height: 12, text: 'text', width: 20, x: 0, y: Number.NaN }], + }), + ).toEqual({ + kind: 'fallback', + reason: 'non-finite-geometry', + }); + }); + + it('rejects meaningful unordered and overlapping bands', () => { + expect( + createOverlayTextLayout('one two', { + lines: [ + { height: 12, text: 'one', width: 20, x: 0, y: 20 }, + { height: 12, text: 'two', width: 20, x: 0, y: 10 }, + ], + }), + ).toEqual({ kind: 'fallback', reason: 'unordered-bands' }); + expect( + createOverlayTextLayout('one two', { + lines: [ + { height: 12, text: 'one', width: 20, x: 0, y: 0 }, + { height: 12, text: 'two', width: 20, x: 0, y: 8 }, + ], + }), + ).toEqual({ kind: 'fallback', reason: 'overlapping-bands' }); + }); + + it('separates identical, geometry-only, and topology changes', () => { + const base = expectReady( + createOverlayTextLayout('one two', { + lines: [ + { height: 12.001, text: 'one', width: 20, x: 0, y: 0 }, + { height: 12, text: 'two', width: 20, x: 0, y: 12 }, + ], + }), + ); + const identicalNoise = expectReady( + createOverlayTextLayout('one two', { + lines: [ + { height: 12.002, text: 'one', width: 20, x: 0, y: 0.002 }, + { height: 12, text: 'two', width: 20, x: 0, y: 12 }, + ], + }), + ); + const geometry = expectReady( + createOverlayTextLayout('one two', { + lines: [ + { height: 12, text: 'one', width: 20, x: 0, y: 0 }, + { height: 12, text: 'two', width: 20, x: 0, y: 12.75 }, + ], + }), + ); + const topology = expectReady( + createOverlayTextLayout('one two three', { + lines: [ + { height: 12, text: 'one', width: 20, x: 0, y: 0 }, + { height: 12, text: 'two three', width: 40, x: 0, y: 12 }, + ], + }), + ); + + expect(compareOverlayTextLayouts(base, identicalNoise)).toBe('identical'); + expect(compareOverlayTextLayouts(base, geometry)).toBe('geometry'); + expect(compareOverlayTextLayouts(base, topology)).toBe('topology'); + }); + + it('keeps topology signatures distinct when line text contains signature delimiters', () => { + const oneLine = expectReady( + createOverlayTextLayout('a:visible|1:b', { + lines: [{ height: 12, text: 'a:visible|1:b', width: 80, x: 0, y: 0 }], + }), + ); + const twoLines = expectReady( + createOverlayTextLayout('a\nb', { + lines: [ + { height: 12, text: 'a', width: 20, x: 0, y: 0 }, + { height: 12, text: 'b', width: 20, x: 0, y: 12 }, + ], + }), + ); + + expect(oneLine.topologySignature).not.toBe(twoLines.topologySignature); + expect(compareOverlayTextLayouts(oneLine, twoLines)).toBe('topology'); + }); +}); diff --git a/packages/text-motion/src/__tests__/rendererMotion.test.ts b/packages/text-motion/src/__tests__/rendererMotion.test.ts new file mode 100644 index 0000000..f3ce5d9 --- /dev/null +++ b/packages/text-motion/src/__tests__/rendererMotion.test.ts @@ -0,0 +1,401 @@ +import { + fade, + lineReveal, + pulse, + rise, + scale, + shake, + slide, + stagger, +} from '@react-native-motion-kit/text-motion'; + +import { createTextMotionEffect } from '../effects/compose'; +import { createTextMotionTimelineHandle } from '../recipe/descriptors'; +import { + clampTextMotionProgress, + createTextMotionControlledTimelineSpan, + createTextMotionDelaySecondsByItemIndex, + createTextMotionItemMotion, + createOverlayTextStyleTransformStatePair, + createTextMotionStyleTransformStatePair, + mapTextMotionControlledProgressToItemProgress, + shouldRenderTextMotionFinalState, +} from '../renderers/rendererMotion'; + +describe('rendererMotion', () => { + it('resolves composed built-in effect style states', () => { + expect( + createTextMotionStyleTransformStatePair([ + fade({ from: 0.2, to: 0.9 }) + .and(rise({ y: 10 })) + .and(scale({ from: 0.5, to: 1.1 })), + pulse({ scale: 1.08 }), + slide({ x: 4, y: 6 }), + ]), + ).toEqual({ + initial: { + opacity: 0.2, + scale: 0.5, + translateX: 4, + translateY: 16, + }, + pulseScale: 1.08, + target: { + opacity: 0.9, + scale: 1.1, + translateX: 0, + translateY: 0, + }, + }); + }); + + it('keeps native/shared state unchanged while overlay uses private frame and reveal-content channels', () => { + const effects = [ + fade({ from: 0.2, to: 0.9 }) + .and(rise({ y: 10 })) + .and(scale({ from: 0.5, to: 1.1 })), + pulse({ scale: 1.08 }), + slide({ x: 4, y: 6 }), + ]; + + expect(createTextMotionStyleTransformStatePair(effects)).toEqual({ + initial: { + opacity: 0.2, + scale: 0.5, + translateX: 4, + translateY: 16, + }, + pulseScale: 1.08, + target: { + opacity: 0.9, + scale: 1.1, + translateX: 0, + translateY: 0, + }, + }); + expect(createOverlayTextStyleTransformStatePair(effects)).toEqual({ + frame: { + initial: { + opacity: 0.2, + scale: 0.5, + translateX: 4, + translateY: 16, + }, + pulseScale: 1.08, + target: { + opacity: 0.9, + scale: 1.1, + translateX: 0, + translateY: 0, + }, + }, + revealContent: { + initial: { + opacity: 1, + scale: 1, + translateX: 0, + translateY: 0, + }, + pulseScale: 1, + target: { + opacity: 1, + scale: 1, + translateX: 0, + translateY: 0, + }, + }, + }); + }); + + it('splits lineReveal opacity onto the overlay frame and relative y onto reveal content', () => { + expect( + createOverlayTextStyleTransformStatePair([lineReveal({ fromOpacity: 0.25, y: 18 })]), + ).toEqual({ + frame: { + initial: { + opacity: 0.25, + scale: 1, + translateX: 0, + translateY: 0, + }, + pulseScale: 1, + target: { + opacity: 1, + scale: 1, + translateX: 0, + translateY: 0, + }, + }, + revealContent: { + initial: { + opacity: 1, + scale: 1, + translateX: 0, + translateY: 18, + }, + pulseScale: 1, + target: { + opacity: 1, + scale: 1, + translateX: 0, + translateY: 0, + }, + }, + }); + }); + + it.each([ + ['fade', fade({ from: 0.4, to: 0.8 })], + ['rise', rise({ y: 14 })], + ['slide', slide({ x: 3, y: 5 })], + ['shake', shake({ x: 7 })], + ['scale', scale({ from: 1.4, to: 1 })], + ['pulse', pulse({ scale: 1.12 })], + ])('targets %s at the overlay frame only', (_name, effect) => { + expect(createOverlayTextStyleTransformStatePair([effect]).revealContent).toEqual({ + initial: { + opacity: 1, + scale: 1, + translateX: 0, + translateY: 0, + }, + pulseScale: 1, + target: { + opacity: 1, + scale: 1, + translateX: 0, + translateY: 0, + }, + }); + }); + + it('preserves composition order for overlay frame opacity while keeping lineReveal y private', () => { + expect( + createOverlayTextStyleTransformStatePair([ + lineReveal({ fromOpacity: 0.2, y: 10 }) + .and(scale({ from: 0.8 })) + .and(pulse({ scale: 1.1 })), + ]), + ).toEqual({ + frame: { + initial: { + opacity: 0.2, + scale: 0.8, + translateX: 0, + translateY: 0, + }, + pulseScale: 1.1, + target: { + opacity: 1, + scale: 1, + translateX: 0, + translateY: 0, + }, + }, + revealContent: { + initial: { + opacity: 1, + scale: 1, + translateX: 0, + translateY: 10, + }, + pulseScale: 1, + target: { + opacity: 1, + scale: 1, + translateX: 0, + translateY: 0, + }, + }, + }); + expect( + createOverlayTextStyleTransformStatePair([ + lineReveal({ fromOpacity: 0.2 }).and(fade({ from: 0.4, to: 0.7 })), + ]).frame.initial.opacity, + ).toBe(0.4); + expect( + createOverlayTextStyleTransformStatePair([ + fade({ from: 0.4, to: 0.7 }).and(lineReveal({ fromOpacity: 0.2 })), + ]).frame.initial.opacity, + ).toBe(0.2); + expect( + createOverlayTextStyleTransformStatePair([ + fade({ from: 0.4, to: 0.7 }).and(lineReveal({ fromOpacity: 0.2 })), + ]).frame.target.opacity, + ).toBe(0.7); + }); + + it('rejects non-finite style-transform effect options', () => { + const customFade = createTextMotionEffect('fade', { from: Number.NaN }, ['style-transform']); + + expect(() => createTextMotionStyleTransformStatePair([customFade])).toThrow( + 'effect option "from" must be a finite number', + ); + }); + + it('rejects effects outside the style-transform shared capability', () => { + const customEffect = createTextMotionEffect('blur', {}, ['style-transform']); + + expect(() => createTextMotionStyleTransformStatePair([customEffect])).toThrow( + 'style-transform renderer does not implement the "blur" effect', + ); + }); + + it('plans finite timeline delays by item count', () => { + expect( + createTextMotionDelaySecondsByItemIndex( + { + effects: [], + timeline: stagger(0.25), + }, + 3, + ), + ).toEqual([0, 0.25, 0.5]); + }); + + it('rejects invalid timeline delays and unsafe millisecond conversion', () => { + const negativeDelay = createTextMotionTimelineHandle({ + kind: 'timeline', + name: 'negative', + delayFor: () => -0.1, + }); + const overflowingDelay = Number.MAX_SAFE_INTEGER / 1000 + 1; + + expect(() => + createTextMotionDelaySecondsByItemIndex({ effects: [], timeline: negativeDelay }, 1), + ).toThrow('timeline delay must be a finite number greater than or equal to 0'); + expect(() => + createTextMotionItemMotion( + { effects: [] }, + createTextMotionStyleTransformStatePair([]), + overflowingDelay, + ), + ).toThrow('timeline delay must convert to a safe integer millisecond value'); + }); + + it('creates shared item motion from recipe style, delay, motion, and reduced-motion policy', () => { + const styleState = createTextMotionStyleTransformStatePair([fade({ from: 0.25 })]); + + expect( + createTextMotionItemMotion( + { + accessibility: { + hideTokensFromAccessibility: true, + kind: 'parent-label', + parentLabel: true, + reducedMotion: 'final-state', + }, + effects: [], + motion: { kind: 'timing', options: { duration: 420 } }, + }, + styleState, + 0.12, + ), + ).toEqual({ + delayMs: 120, + initial: { + opacity: 0.25, + scale: 1, + translateX: 0, + translateY: 0, + }, + motion: { kind: 'timing', options: { duration: 420 } }, + pulseScale: 1, + reducedMotion: 'final-state', + target: { + opacity: 1, + scale: 1, + translateX: 0, + translateY: 0, + }, + }); + }); + + it('keeps finite progress inside the normalized range', () => { + expect(clampTextMotionProgress(0.4)).toBe(0.4); + }); + + it('clamps negative progress to the initial state', () => { + expect(clampTextMotionProgress(-0.1)).toBe(0); + }); + + it('clamps overflowing progress to the final state', () => { + expect(clampTextMotionProgress(1.4)).toBe(1); + }); + + it('treats non-finite progress as the initial state', () => { + expect(clampTextMotionProgress(Number.NaN)).toBe(0); + expect(clampTextMotionProgress(Number.POSITIVE_INFINITY)).toBe(0); + expect(clampTextMotionProgress(Number.NEGATIVE_INFINITY)).toBe(0); + }); + + it('uses one item span when no animated item delays exist', () => { + expect(createTextMotionControlledTimelineSpan([])).toBe(1); + }); + + it('adds the fixed item span to the last item delay', () => { + expect(createTextMotionControlledTimelineSpan([0, 0.25, 0.8])).toBe(1.8); + }); + + it('keeps item progress at zero before its delay starts', () => { + expect( + mapTextMotionControlledProgressToItemProgress({ + progress: 0.25, + itemDelaySeconds: 0.75, + totalTimelineSpan: 2, + }), + ).toBe(0); + }); + + it('returns partial item progress after the item delay starts', () => { + expect( + mapTextMotionControlledProgressToItemProgress({ + progress: 0.5, + itemDelaySeconds: 0.25, + totalTimelineSpan: 2, + }), + ).toBe(0.75); + }); + + it('keeps item progress at one after its span completes', () => { + expect( + mapTextMotionControlledProgressToItemProgress({ + progress: 1, + itemDelaySeconds: 0.25, + totalTimelineSpan: 2, + }), + ).toBe(1); + }); + + it('matches the existing staggered renderer mapping example', () => { + const totalTimelineSpan = createTextMotionControlledTimelineSpan([0, 0.5, 1]); + + expect( + mapTextMotionControlledProgressToItemProgress({ + progress: 0.5, + itemDelaySeconds: 0, + totalTimelineSpan, + }), + ).toBe(1); + expect( + mapTextMotionControlledProgressToItemProgress({ + progress: 0.5, + itemDelaySeconds: 0.5, + totalTimelineSpan, + }), + ).toBe(0.5); + expect( + mapTextMotionControlledProgressToItemProgress({ + progress: 0.5, + itemDelaySeconds: 1, + totalTimelineSpan, + }), + ).toBe(0); + }); + + it('renders final state only for final-state reduced motion policy', () => { + expect(shouldRenderTextMotionFinalState(true, 'final-state')).toBe(true); + expect(shouldRenderTextMotionFinalState(false, 'final-state')).toBe(false); + expect(shouldRenderTextMotionFinalState(true, 'system')).toBe(false); + }); +}); diff --git a/packages/text-motion/src/effects/fade.ts b/packages/text-motion/src/effects/fade.ts index d86181f..433c456 100644 --- a/packages/text-motion/src/effects/fade.ts +++ b/packages/text-motion/src/effects/fade.ts @@ -11,8 +11,12 @@ export type FadeOptions = { /** Fade each token between opacity values. */ export function fade(options: FadeOptions = {}) { - return createTextMotionEffect('fade', { - from: validateFiniteEffectNumber(options.from ?? 0, 'fade from'), - to: validateFiniteEffectNumber(options.to ?? 1, 'fade to'), - }); + return createTextMotionEffect( + 'fade', + { + from: validateFiniteEffectNumber(options.from ?? 0, 'fade from'), + to: validateFiniteEffectNumber(options.to ?? 1, 'fade to'), + }, + ['style-transform'], + ); } diff --git a/packages/text-motion/src/effects/index.ts b/packages/text-motion/src/effects/index.ts index b508577..cacbae3 100644 --- a/packages/text-motion/src/effects/index.ts +++ b/packages/text-motion/src/effects/index.ts @@ -1,5 +1,6 @@ export { composeTextMotionEffects, createTextMotionEffect } from './compose'; export { fade, type FadeOptions } from './fade'; +export { lineReveal, type LineRevealOptions } from './lineReveal'; export { pulse, type PulseOptions } from './pulse'; export { rise, type RiseOptions } from './rise'; export { scale, type ScaleOptions } from './scale'; diff --git a/packages/text-motion/src/effects/lineReveal.ts b/packages/text-motion/src/effects/lineReveal.ts new file mode 100644 index 0000000..20484b9 --- /dev/null +++ b/packages/text-motion/src/effects/lineReveal.ts @@ -0,0 +1,28 @@ +import type { TextMotionLineMaskCapability } from '../types/renderer'; + +import { createTextMotionRendererCapability } from '../types/renderer'; +import { createTextMotionEffect } from './compose'; +import { validateFiniteEffectNumber } from './validation'; + +const LINE_MASK_CAPABILITY: TextMotionLineMaskCapability = + createTextMotionRendererCapability('line-mask'); + +/** Options for {@link lineReveal}. */ +export type LineRevealOptions = { + /** Initial downward offset in points. Defaults to `16`. */ + y?: number; + /** Initial opacity before each rendered line reveals. Defaults to `0`. */ + fromOpacity?: number; +}; + +/** Reveal each React Native rendered line through an `overlayText()` line mask. */ +export function lineReveal(options: LineRevealOptions = {}) { + return createTextMotionEffect( + 'lineReveal', + { + fromOpacity: validateFiniteEffectNumber(options.fromOpacity ?? 0, 'lineReveal fromOpacity'), + y: validateFiniteEffectNumber(options.y ?? 16, 'lineReveal y'), + }, + [LINE_MASK_CAPABILITY, 'style-transform'], + ); +} diff --git a/packages/text-motion/src/effects/pulse.ts b/packages/text-motion/src/effects/pulse.ts index bc310c9..85bd3fe 100644 --- a/packages/text-motion/src/effects/pulse.ts +++ b/packages/text-motion/src/effects/pulse.ts @@ -9,7 +9,11 @@ export type PulseOptions = { /** Briefly emphasize each token by scaling it up and returning to its final scale. */ export function pulse(options: PulseOptions = {}) { - return createTextMotionEffect('pulse', { - scale: validateFiniteEffectNumber(options.scale ?? 1.04, 'pulse scale'), - }); + return createTextMotionEffect( + 'pulse', + { + scale: validateFiniteEffectNumber(options.scale ?? 1.04, 'pulse scale'), + }, + ['style-transform'], + ); } diff --git a/packages/text-motion/src/effects/rise.ts b/packages/text-motion/src/effects/rise.ts index 39577c3..605547e 100644 --- a/packages/text-motion/src/effects/rise.ts +++ b/packages/text-motion/src/effects/rise.ts @@ -9,7 +9,11 @@ export type RiseOptions = { /** Move each token upward from a vertical offset. */ export function rise(options: RiseOptions = {}) { - return createTextMotionEffect('rise', { - y: validateFiniteEffectNumber(options.y ?? 12, 'rise y'), - }); + return createTextMotionEffect( + 'rise', + { + y: validateFiniteEffectNumber(options.y ?? 12, 'rise y'), + }, + ['style-transform'], + ); } diff --git a/packages/text-motion/src/effects/scale.ts b/packages/text-motion/src/effects/scale.ts index d5ab2e1..6d92929 100644 --- a/packages/text-motion/src/effects/scale.ts +++ b/packages/text-motion/src/effects/scale.ts @@ -11,8 +11,12 @@ export type ScaleOptions = { /** Scale each token between two scale values. */ export function scale(options: ScaleOptions = {}) { - return createTextMotionEffect('scale', { - from: validateFiniteEffectNumber(options.from ?? 0.96, 'scale from'), - to: validateFiniteEffectNumber(options.to ?? 1, 'scale to'), - }); + return createTextMotionEffect( + 'scale', + { + from: validateFiniteEffectNumber(options.from ?? 0.96, 'scale from'), + to: validateFiniteEffectNumber(options.to ?? 1, 'scale to'), + }, + ['style-transform'], + ); } diff --git a/packages/text-motion/src/effects/shake.ts b/packages/text-motion/src/effects/shake.ts index 3d8198b..cf3f8ce 100644 --- a/packages/text-motion/src/effects/shake.ts +++ b/packages/text-motion/src/effects/shake.ts @@ -9,7 +9,11 @@ export type ShakeOptions = { /** Offset each token horizontally for a shake-like entrance. */ export function shake(options: ShakeOptions = {}) { - return createTextMotionEffect('shake', { - x: validateFiniteEffectNumber(options.x ?? 4, 'shake x'), - }); + return createTextMotionEffect( + 'shake', + { + x: validateFiniteEffectNumber(options.x ?? 4, 'shake x'), + }, + ['style-transform'], + ); } diff --git a/packages/text-motion/src/effects/slide.ts b/packages/text-motion/src/effects/slide.ts index b4039af..92717b6 100644 --- a/packages/text-motion/src/effects/slide.ts +++ b/packages/text-motion/src/effects/slide.ts @@ -11,8 +11,12 @@ export type SlideOptions = { /** Move each token from an x/y offset into place. */ export function slide(options: SlideOptions = {}) { - return createTextMotionEffect('slide', { - x: validateFiniteEffectNumber(options.x ?? 0, 'slide x'), - y: validateFiniteEffectNumber(options.y ?? 12, 'slide y'), - }); + return createTextMotionEffect( + 'slide', + { + x: validateFiniteEffectNumber(options.x ?? 0, 'slide x'), + y: validateFiniteEffectNumber(options.y ?? 12, 'slide y'), + }, + ['style-transform'], + ); } diff --git a/packages/text-motion/src/index.tsx b/packages/text-motion/src/index.tsx index ab07321..fcf49bf 100644 --- a/packages/text-motion/src/index.tsx +++ b/packages/text-motion/src/index.tsx @@ -1,21 +1,22 @@ export { parentLabelPolicy } from './accessibility'; export { useTextMotionControls } from './controls'; -export { fade, pulse, rise, scale, shake, slide } from './effects'; +export { fade, lineReveal, pulse, rise, scale, shake, slide } from './effects'; export { defineTextMotion } from './recipe'; -export { nativeText } from './renderers'; +export { nativeText, overlayText } from './renderers'; export { custom, graphemes, lines, words } from './split'; export { parallel, sequence, stagger, wave } from './timeline'; export type { ParentLabelPolicyOptions } from './accessibility'; export type { TextMotionControls } from './controls'; export type { FadeOptions, + LineRevealOptions, PulseOptions, RiseOptions, ScaleOptions, ShakeOptions, SlideOptions, } from './effects'; -export type { NativeTextRendererOptions } from './renderers'; +export type { NativeTextRendererOptions, OverlayTextRendererOptions } from './renderers'; export type { TextMotionCustomSplit, TextMotionCustomSplitResult, diff --git a/packages/text-motion/src/presets/index.ts b/packages/text-motion/src/presets/index.ts index ec95bda..2bee2d1 100644 --- a/packages/text-motion/src/presets/index.ts +++ b/packages/text-motion/src/presets/index.ts @@ -1,11 +1,17 @@ +import type { TextMotionSplitRenderableRecipeBuilder } from '../recipe/recipe'; + import { fade, pulse, rise, scale } from '../effects'; import { defineTextMotion } from '../recipe'; import { nativeText } from '../renderers'; import { words } from '../split'; import { stagger, wave } from '../timeline'; +type NativeTextPresetBuilder = TextMotionSplitRenderableRecipeBuilder< + 'native-text' | 'style-transform' +>; + /** Editorial title reveal with centered stagger, rise, fade, and subtle scale. */ -export function editorialRise() { +export function editorialRise(): NativeTextPresetBuilder { return defineTextMotion() .split(words()) .layout(nativeText()) @@ -18,7 +24,7 @@ export function editorialRise() { } /** Soft wave reveal for friendly product copy. */ -export function softWave() { +export function softWave(): NativeTextPresetBuilder { return defineTextMotion() .split(words()) .layout(nativeText()) @@ -27,7 +33,7 @@ export function softWave() { } /** Gentle emphasis preset for small labels and inline highlight copy. */ -export function gentleEmphasis() { +export function gentleEmphasis(): NativeTextPresetBuilder { return defineTextMotion() .split(words()) .layout(nativeText()) diff --git a/packages/text-motion/src/recipe/component.tsx b/packages/text-motion/src/recipe/component.tsx index f713468..2f0da0c 100644 --- a/packages/text-motion/src/recipe/component.tsx +++ b/packages/text-motion/src/recipe/component.tsx @@ -2,10 +2,32 @@ import type { TextMotionComponent, TextMotionComponentProps, TextMotionInternalRecipeConfig, + TextMotionRendererCapability, } from '../types'; import type { TextMotionToken } from '../types/token'; -import { readTextMotionRendererDescriptor, readTextMotionSplitterDescriptor } from './descriptors'; +import { + flattenTextMotionEffectDescriptors, + readTextMotionRendererDescriptor, + readTextMotionSplitterDescriptor, +} from './descriptors'; + +function validateTextMotionRendererCapabilities( + recipe: TextMotionInternalRecipeConfig, + renderer: ReturnType, +): void { + flattenTextMotionEffectDescriptors(recipe.effects).forEach((effect) => { + effect.requiredCapabilities.forEach((capability) => { + if (renderer.capabilities.includes(capability)) { + return; + } + + throw new Error( + `@react-native-motion-kit/text-motion effect "${effect.name}" requires capability "${capability}", but renderer "${renderer.kind}" does not provide it.`, + ); + }); + }); +} function createDefaultToken(text: string): TextMotionToken<'custom'> { return { @@ -19,7 +41,7 @@ function createDefaultToken(text: string): TextMotionToken<'custom'> { } export function createTextMotionComponent( - recipe: TextMotionInternalRecipeConfig, + recipe: TextMotionInternalRecipeConfig, ): TextMotionComponent { function TextMotionComponent({ children, ...textProps }: TextMotionComponentProps) { if (typeof children !== 'string') { @@ -34,6 +56,14 @@ export function createTextMotionComponent( const renderer = recipe.renderer ? readTextMotionRendererDescriptor(recipe.renderer) : undefined; + const rendererMotionUnit = renderer?.motionUnit ?? 'source-token'; + + if (recipe.splitter && rendererMotionUnit === 'rendered-line') { + throw new Error( + '@react-native-motion-kit/text-motion rendered-line renderers cannot be combined with .split(...). Remove the splitter or choose a source-token renderer such as nativeText().', + ); + } + const tokens = splitter ? splitter.split(children) : [createDefaultToken(children)]; const Renderer = renderer?.Component; @@ -43,6 +73,8 @@ export function createTextMotionComponent( ); } + validateTextMotionRendererCapabilities(recipe, renderer); + return ( {children} diff --git a/packages/text-motion/src/recipe/descriptors.ts b/packages/text-motion/src/recipe/descriptors.ts index a826474..087e1f0 100644 --- a/packages/text-motion/src/recipe/descriptors.ts +++ b/packages/text-motion/src/recipe/descriptors.ts @@ -14,6 +14,8 @@ import type { TextMotionRenderer, TextMotionRendererCapability, TextMotionRendererDescriptor, + TextMotionRenderedLineRenderer, + TextMotionSourceTokenRenderer, } from '../types/renderer'; import type { TextMotionSplitter, @@ -29,6 +31,14 @@ const rendererDescriptors = new WeakMap< TextMotionRendererDescriptor >(); +type TextMotionRendererHandle< + Capabilities extends TextMotionRendererCapability, + Recipe, + MotionUnit extends 'source-token' | 'rendered-line', +> = MotionUnit extends 'rendered-line' + ? TextMotionRenderedLineRenderer + : TextMotionSourceTokenRenderer; + export function attachTextMotionEffectDescriptor< RequiredCapabilities extends TextMotionRendererCapability, Options extends TextMotionEffectDescriptorOptions, @@ -119,10 +129,11 @@ export function readTextMotionSplitterDescriptor( - descriptor: TextMotionRendererDescriptor, -): TextMotionRenderer { - const renderer = {} as TextMotionRenderer; + descriptor: TextMotionRendererDescriptor, +): TextMotionRendererHandle { + const renderer = {} as TextMotionRendererHandle; rendererDescriptors.set( renderer, descriptor as TextMotionRendererDescriptor, diff --git a/packages/text-motion/src/recipe/recipe.ts b/packages/text-motion/src/recipe/recipe.ts index d6081c2..0a8d1aa 100644 --- a/packages/text-motion/src/recipe/recipe.ts +++ b/packages/text-motion/src/recipe/recipe.ts @@ -6,11 +6,15 @@ import type { TextMotionEffect, TextMotionMotionConfig, TextMotionRecipeConfig, - TextMotionCompatibleEffect, TextMotionRenderer, TextMotionRendererCapability, TextMotionSplitter, } from '../types'; +import type { + TextMotionCompatibleEffect, + TextMotionRenderedLineRenderer, + TextMotionSourceTokenRenderer, +} from '../types/renderer'; import { createTextMotionComponent } from './component'; @@ -26,18 +30,22 @@ type RecipeDraft = { type RequiredCapabilitiesOfEffect = Effect extends TextMotionEffect ? RequiredCapabilities : never; +type CapabilitiesOfRenderer = + Renderer extends TextMotionRenderer ? Capabilities : never; + +type AnyTextMotionSourceTokenRenderer = TextMotionSourceTokenRenderer< + TextMotionRendererCapability, + unknown +>; +type AnyTextMotionRenderedLineRenderer = TextMotionRenderedLineRenderer< + TextMotionRendererCapability, + unknown +>; + type TextMotionRecipeBuilderMethods< RendererCapabilities extends TextMotionRendererCapability, CurrentBuilder, > = { - /** Choose how the input string is split into tokens. */ - split(splitter: TextMotionSplitter): CurrentBuilder; - - /** Choose the renderer responsible for measuring and drawing tokens. */ - layout( - renderer: TextMotionRenderer, - ): TextMotionRenderableRecipeBuilder; - /** Choose the timing strategy for token delays. */ timeline(timeline: TextMotionAnyTimeline): CurrentBuilder; @@ -66,7 +74,36 @@ export interface TextMotionRecipeBuilder< > extends TextMotionRecipeBuilderMethods< RendererCapabilities, TextMotionRecipeBuilder -> {} +> { + /** Choose how the input string is split into tokens. */ + split(splitter: TextMotionSplitter): TextMotionSplitRecipeBuilder; + + /** Choose the renderer responsible for measuring and drawing split input. */ + layout( + renderer: Renderer, + ): TextMotionRenderableRecipeBuilder>; + + /** Choose the renderer responsible for its own layout unit. */ + layout( + renderer: Renderer, + ): TextMotionRenderedLineRecipeBuilder>; +} + +/** Builder state after a splitter has been selected before renderer layout. */ +interface TextMotionSplitRecipeBuilder< + RendererCapabilities extends TextMotionRendererCapability = never, +> extends TextMotionRecipeBuilderMethods< + RendererCapabilities, + TextMotionSplitRecipeBuilder +> { + /** Choose how the input string is split into tokens. */ + split(splitter: TextMotionSplitter): TextMotionSplitRecipeBuilder; + + /** Choose the renderer responsible for measuring and drawing split input. */ + layout( + renderer: Renderer, + ): TextMotionSplitRenderableRecipeBuilder>; +} /** Builder state after a renderer has been selected. */ export interface TextMotionRenderableRecipeBuilder< @@ -75,6 +112,59 @@ export interface TextMotionRenderableRecipeBuilder< RendererCapabilities, TextMotionRenderableRecipeBuilder > { + /** Choose how the input string is split into tokens. */ + split(splitter: TextMotionSplitter): TextMotionSplitRenderableRecipeBuilder; + + /** Choose the renderer responsible for measuring and drawing split input. */ + layout( + renderer: Renderer, + ): TextMotionRenderableRecipeBuilder>; + + /** Choose the renderer responsible for its own layout unit. */ + layout( + renderer: Renderer, + ): TextMotionRenderedLineRecipeBuilder>; + + /** Create a React component from the current recipe. */ + component(): TextMotionComponent; +} + +/** Builder state after a splittable renderer and splitter have been selected. */ +export interface TextMotionSplitRenderableRecipeBuilder< + RendererCapabilities extends TextMotionRendererCapability, +> extends TextMotionRecipeBuilderMethods< + RendererCapabilities, + TextMotionSplitRenderableRecipeBuilder +> { + /** Choose how the input string is split into tokens. */ + split(splitter: TextMotionSplitter): TextMotionSplitRenderableRecipeBuilder; + + /** Choose the renderer responsible for measuring and drawing split input. */ + layout( + renderer: Renderer, + ): TextMotionSplitRenderableRecipeBuilder>; + + /** Create a React component from the current recipe. */ + component(): TextMotionComponent; +} + +/** Builder state after a rendered-line renderer has been selected. */ +interface TextMotionRenderedLineRecipeBuilder< + RendererCapabilities extends TextMotionRendererCapability, +> extends TextMotionRecipeBuilderMethods< + RendererCapabilities, + TextMotionRenderedLineRecipeBuilder +> { + /** Choose the renderer responsible for measuring and drawing split input. */ + layout( + renderer: Renderer, + ): TextMotionRenderableRecipeBuilder>; + + /** Choose the renderer responsible for its own layout unit. */ + layout( + renderer: Renderer, + ): TextMotionRenderedLineRecipeBuilder>; + /** Create a React component from the current recipe. */ component(): TextMotionComponent; } @@ -178,11 +268,9 @@ function cloneDraft( }; } -class TextMotionRecipeBuilderImpl - implements - TextMotionRecipeBuilder, - TextMotionRenderableRecipeBuilder -{ +class TextMotionRecipeBuilderImpl< + RendererCapabilities extends TextMotionRendererCapability = never, +> { private readonly draft: RecipeDraft; constructor(draft: Partial> = {}) { @@ -206,7 +294,7 @@ class TextMotionRecipeBuilderImpl( - renderer: TextMotionRenderer, + renderer: TextMotionRenderer, ): TextMotionRecipeBuilderImpl { return new TextMotionRecipeBuilderImpl({ ...cloneDraft(this.draft), @@ -267,5 +355,5 @@ class TextMotionRecipeBuilderImpl number; -type NativeTextStyleStateTransform = (state: NativeTextStyleState) => NativeTextStyleState; - -type EffectStyleStateChange = { - initial?: NativeTextStyleStateTransform; - pulseScale?: NativeTextNumberTransform; - target?: NativeTextStyleStateTransform; -}; - -type EffectStyleStateResolver = (effect: TextMotionAnyEffectDescriptor) => EffectStyleStateChange; - -type NativeTextStyleNumberKey = keyof NativeTextStyleState; - -type NativeTextProgressAnimationConfig = Pick< - NativeTextTokenMotion, - 'delayMs' | 'motion' | 'reducedMotion' ->; - type StaticNativeTextTokenProps = AnimatedTextProps & { tokenMotion?: undefined; }; type ControlledNativeTextTokenProps = AnimatedTextProps & { controlledProgress: SharedValue; - controlledProgressPlan: NativeTextControlledProgressPlan; - tokenMotion: NativeTextTokenMotion; + controlledProgressPlan: TextMotionControlledProgressPlan; + tokenMotion: TextMotionItemMotion; }; type UncontrolledNativeTextTokenProps = AnimatedTextProps & { controls?: TextMotionControls; playbackText: string; - tokenMotion: NativeTextTokenMotion; + tokenMotion: TextMotionItemMotion; }; type NativeTextTokenProps = @@ -109,28 +77,28 @@ type NativeTextTokenProps = type NativeTextAnimatedStyleOptions = | { controlled: true; - controlledProgressPlan: NativeTextControlledProgressPlan; + controlledProgressPlan: TextMotionControlledProgressPlan; progress: SharedValue; renderFinalState: boolean; - tokenMotion: NativeTextTokenMotion; + tokenMotion: TextMotionItemMotion; } | { controlled: false; progress: SharedValue; renderFinalState: boolean; - tokenMotion: NativeTextTokenMotion; + tokenMotion: TextMotionItemMotion; }; type ControlledAnimatedNativeTextTokenProps = AnimatedTextProps & { controlledProgress: SharedValue; - controlledProgressPlan: NativeTextControlledProgressPlan; - tokenMotion: NativeTextTokenMotion; + controlledProgressPlan: TextMotionControlledProgressPlan; + tokenMotion: TextMotionItemMotion; }; type UncontrolledAnimatedNativeTextTokenProps = AnimatedTextProps & { controls?: TextMotionControls; playbackText: string; - tokenMotion: NativeTextTokenMotion; + tokenMotion: TextMotionItemMotion; }; type NativeTextAnimatedTokenContainerProps = Pick< @@ -203,13 +171,6 @@ const TEXT_ALIGN_TO_JUSTIFY_CONTENT = { right: 'flex-end', } as const satisfies Record, ViewStyle['justifyContent']>; -const DEFAULT_STYLE_STATE: NativeTextStyleState = { - opacity: 1, - scale: 1, - translateX: 0, - translateY: 0, -}; - const NATIVE_TEXT_LINE_BREAK_PATTERN = /\r\n|\n/g; const NATIVE_TEXT_LINE_BREAK_MARKER_STYLE = { flexBasis: '100%', @@ -226,202 +187,6 @@ const NATIVE_TEXT_ANIMATED_TOKEN_CONTAINER_STYLE = { overflow: 'visible', } as const satisfies ViewStyle; -const preserveStyleState: NativeTextStyleStateTransform = (state) => state; -const preserveNumber: NativeTextNumberTransform = (value) => value; - -const REDUCE_MOTION_CONFIG_BY_POLICY = { - 'final-state': ReduceMotion.Never, - system: ReduceMotion.System, -} as const satisfies Record; - -const effectStyleStateResolvers: Record = { - fade(effect) { - return { - initial: setStyleNumber('opacity', numberOption(effect.options, 'from', 0)), - target: setStyleNumber('opacity', numberOption(effect.options, 'to', 1)), - }; - }, - pulse(effect) { - return { - pulseScale: (scale) => scale * numberOption(effect.options, 'scale', 1.04), - }; - }, - rise(effect) { - return { - initial: mapStyleNumber( - 'translateY', - (translateY) => translateY + numberOption(effect.options, 'y', 12), - ), - target: preserveStyleState, - }; - }, - scale(effect) { - return { - initial: mapStyleNumber( - 'scale', - (scale) => scale * numberOption(effect.options, 'from', 0.96), - ), - target: mapStyleNumber('scale', (scale) => scale * numberOption(effect.options, 'to', 1)), - }; - }, - shake(effect) { - return { - initial: mapStyleNumber( - 'translateX', - (translateX) => translateX + numberOption(effect.options, 'x', 4), - ), - target: preserveStyleState, - }; - }, - slide(effect) { - return { - initial: composeStyleTransforms( - mapStyleNumber( - 'translateX', - (translateX) => translateX + numberOption(effect.options, 'x', 0), - ), - mapStyleNumber( - 'translateY', - (translateY) => translateY + numberOption(effect.options, 'y', 12), - ), - ), - target: preserveStyleState, - }; - }, -}; - -function numberOption( - options: TextMotionEffectDescriptorOptions | undefined, - key: string, - fallback: number, -): number { - const value = options?.[key]; - - if (value === undefined) { - return fallback; - } - - if (typeof value === 'number' && Number.isFinite(value)) { - return value; - } - - throw new Error( - `@react-native-motion-kit/text-motion effect option "${key}" must be a finite number.`, - ); -} - -function validateDelaySeconds(delay: number): number { - if (Number.isFinite(delay) && delay >= 0) { - return delay; - } - - throw new Error( - '@react-native-motion-kit/text-motion timeline delay must be a finite number greater than or equal to 0.', - ); -} - -function createDelayMs(delaySeconds: number): number { - const delayMs = Math.round(delaySeconds * 1000); - - if (Number.isSafeInteger(delayMs)) { - return delayMs; - } - - throw new Error( - '@react-native-motion-kit/text-motion timeline delay must convert to a safe integer millisecond value.', - ); -} - -function createDefaultStyleStatePair(): NativeTextStyleStatePair { - return { - initial: { ...DEFAULT_STYLE_STATE }, - pulseScale: 1, - target: { ...DEFAULT_STYLE_STATE }, - }; -} - -function mapStyleNumber( - key: NativeTextStyleNumberKey, - mapValue: (value: number) => number, -): NativeTextStyleStateTransform { - return (state) => { - const nextState: NativeTextStyleState = { - ...state, - }; - - nextState[key] = mapValue(state[key]); - - return nextState; - }; -} - -function setStyleNumber( - key: NativeTextStyleNumberKey, - value: number, -): NativeTextStyleStateTransform { - return mapStyleNumber(key, () => value); -} - -function composeStyleTransforms( - ...transforms: readonly NativeTextStyleStateTransform[] -): NativeTextStyleStateTransform { - return (state) => transforms.reduce((current, transform) => transform(current), state); -} - -function resolveEffectStyleStateChange( - effect: TextMotionAnyEffectDescriptor, -): EffectStyleStateChange { - const resolver = effectStyleStateResolvers[effect.name]; - - if (resolver) { - return resolver(effect); - } - - throw new Error( - `nativeText() does not implement the "${effect.name}" effect. Use a built-in nativeText effect or a renderer that handles this effect.`, - ); -} - -function applyEffectStyleStateChange( - styleState: NativeTextStyleStatePair, - effect: TextMotionAnyEffectDescriptor, -): NativeTextStyleStatePair { - const styleStateChange = resolveEffectStyleStateChange(effect); - const initial = styleStateChange.initial ?? preserveStyleState; - const pulseScale = styleStateChange.pulseScale ?? preserveNumber; - const target = styleStateChange.target ?? preserveStyleState; - - return { - initial: initial(styleState.initial), - pulseScale: pulseScale(styleState.pulseScale), - target: target(styleState.target), - }; -} - -function createNativeTextStyleStatePair( - effects: readonly TextMotionAnyEffect[], -): NativeTextStyleStatePair { - return flattenTextMotionEffectDescriptors(effects).reduce( - applyEffectStyleStateChange, - createDefaultStyleStatePair(), - ); -} - -function createNativeTextTokenMotion( - recipe: NativeTextRendererProps['recipe'], - styleState: NativeTextStyleStatePair, - delaySeconds: number, -): NativeTextTokenMotion { - return { - delayMs: createDelayMs(delaySeconds), - initial: styleState.initial, - motion: recipe.motion, - pulseScale: styleState.pulseScale, - reducedMotion: recipe.accessibility?.reducedMotion ?? 'system', - target: styleState.target, - }; -} - function shouldAnimateToken(token: TextMotionToken): boolean { return token.text.trim().length > 0; } @@ -442,20 +207,16 @@ function createTokenDelaySecondsByMotionIndex( recipe: NativeTextRendererProps['recipe'], count: number, ): readonly number[] { - const timeline = recipe.timeline ? readTextMotionTimelineDescriptor(recipe.timeline) : undefined; - - return Array.from({ length: count }, (_, index) => - validateDelaySeconds(timeline?.delayFor(index, count) ?? 0), - ); + return createTextMotionDelaySecondsByItemIndex(recipe, count); } function createTokenMotion( recipe: NativeTextRendererProps['recipe'], motionIndex: number, - styleState: NativeTextStyleStatePair, + styleState: TextMotionStyleTransformStatePair, delaySecondsByMotionIndex: readonly number[], -): NativeTextTokenMotion { - return createNativeTextTokenMotion( +): TextMotionItemMotion { + return createTextMotionItemMotion( recipe, styleState, delaySecondsByMotionIndex[motionIndex] ?? 0, @@ -466,22 +227,12 @@ function createControlledProgressPlan( motionIndex: number, delaySecondsByMotionIndex: readonly number[], totalTimelineSpan: number, -): NativeTextControlledProgressPlan { - return { - tokenDelaySeconds: delaySecondsByMotionIndex[motionIndex] ?? 0, +): TextMotionControlledProgressPlan { + return createTextMotionControlledProgressPlan( + motionIndex, + delaySecondsByMotionIndex, totalTimelineSpan, - }; -} - -function resolveReduceMotionConfig(reducedMotion: NativeTextTokenMotion['reducedMotion']) { - return REDUCE_MOTION_CONFIG_BY_POLICY[reducedMotion]; -} - -function shouldRenderFinalState( - reducedMotionEnabled: boolean, - reducedMotion: NativeTextTokenMotion['reducedMotion'], -): boolean { - return reducedMotionEnabled && reducedMotion !== 'system'; + ); } function createNativeTextTextFragment( @@ -639,36 +390,6 @@ function createNativeTextLineBreakTestID( return prefix ? `${prefix}-line-break-${fragment.renderIndex}` : undefined; } -function applyTokenDelay(animation: number, delayMs: number, reduceMotion: ReduceMotion): number { - return delayMs > 0 ? withDelay(delayMs, animation, reduceMotion) : animation; -} - -function createProgressAnimation({ - delayMs, - motion, - reducedMotion, -}: NativeTextProgressAnimationConfig): number { - const reduceMotion = resolveReduceMotionConfig(reducedMotion); - - if (motion?.kind === 'spring') { - const animation = withSpring(1, { - ...motion.options, - reduceMotion, - }); - - return applyTokenDelay(animation, delayMs, reduceMotion); - } - - const timingOptions = motion?.kind === 'timing' ? motion.options : undefined; - const animation = withTiming(1, { - duration: 300, - ...timingOptions, - reduceMotion, - }); - - return applyTokenDelay(animation, delayMs, reduceMotion); -} - function StaticNativeTextToken({ children, style, ...tokenProps }: AnimatedTextProps) { return ( @@ -714,20 +435,21 @@ function useNativeTextAnimatedStyle(options: NativeTextAnimatedStyleOptions) { const targetTranslateX = tokenMotion.target.translateX; const targetTranslateY = tokenMotion.target.translateY; const pulseScale = tokenMotion.pulseScale; - const tokenDelaySeconds = controlled ? options.controlledProgressPlan.tokenDelaySeconds : 0; + const itemDelaySeconds = controlled ? options.controlledProgressPlan.itemDelaySeconds : 0; const totalTimelineSpan = controlled ? options.controlledProgressPlan.totalTimelineSpan : 1; return useAnimatedStyle(() => { - const rawProgress = renderFinalState ? 1 : progress.value; - const uncontrolledProgress = Number.isFinite(rawProgress) ? rawProgress : 0; - const current = controlled - ? mapNativeTextControlledProgressToTokenProgress({ - progress: rawProgress, - tokenDelaySeconds, - totalTimelineSpan, - }) - : uncontrolledProgress; - const clampedCurrent = clampNativeTextProgress(current); + const current = readTextMotionProgressForAnimatedStyle({ + controlledProgressPlan: controlled + ? { + itemDelaySeconds, + totalTimelineSpan, + } + : undefined, + progress, + renderFinalState, + }); + const clampedCurrent = clampTextMotionProgress(current); const baseScale = initialScale + (targetScale - initialScale) * current; const pulseProgress = 1 - Math.abs(clampedCurrent * 2 - 1); const pulseMultiplier = 1 + (pulseScale - 1) * pulseProgress; @@ -759,7 +481,7 @@ function useNativeTextAnimatedStyle(options: NativeTextAnimatedStyleOptions) { targetScale, targetTranslateX, targetTranslateY, - tokenDelaySeconds, + itemDelaySeconds, totalTimelineSpan, ]); } @@ -774,7 +496,7 @@ function ControlledAnimatedNativeTextToken({ }: ControlledAnimatedNativeTextTokenProps) { const reducedMotion = tokenMotion.reducedMotion; const reducedMotionEnabled = useReducedMotion(); - const renderFinalState = shouldRenderFinalState(reducedMotionEnabled, reducedMotion); + const renderFinalState = shouldRenderTextMotionFinalState(reducedMotionEnabled, reducedMotion); const { containerProps, textProps } = splitAnimatedTokenProps(tokenProps); const animatedStyle = useNativeTextAnimatedStyle({ controlled: true, @@ -806,7 +528,7 @@ function UncontrolledAnimatedNativeTextToken({ }: UncontrolledAnimatedNativeTextTokenProps) { const reducedMotion = tokenMotion.reducedMotion; const reducedMotionEnabled = useReducedMotion(); - const renderFinalState = shouldRenderFinalState(reducedMotionEnabled, reducedMotion); + const renderFinalState = shouldRenderTextMotionFinalState(reducedMotionEnabled, reducedMotion); const progress = useSharedValue(renderFinalState ? 1 : 0); const playbackRun = createNativeTextPlaybackRun(tokenMotion, renderFinalState, playbackText); const previousPlaybackRun = useRef(undefined); @@ -831,12 +553,12 @@ function UncontrolledAnimatedNativeTextToken({ } progress.value = 0; - progress.value = createProgressAnimation(playbackRun); + progress.value = createTextMotionProgressAnimation(playbackRun); }); useNativeTextControlsPlayback({ controls, - createProgressAnimation, + createProgressAnimation: createTextMotionProgressAnimation, progress, renderFinalState, tokenMotion, @@ -978,10 +700,10 @@ function createNativeTextRendererComponent( const tokenAccessibilityProps = createHiddenTokenAccessibilityProps(policy); const containerProps = createContainerProps(textProps, parentAccessibilityProps); const tokenTextProps = createTokenTextProps(textProps); - const styleState = createNativeTextStyleStatePair(recipe.effects); + const styleState = createTextMotionStyleTransformStatePair(recipe.effects, 'nativeText()'); const { motionCount, motionIndexByTokenId } = createMotionIndexByTokenId(tokens); const delaySecondsByMotionIndex = createTokenDelaySecondsByMotionIndex(recipe, motionCount); - const totalTimelineSpan = createNativeTextControlledTimelineSpan(delaySecondsByMotionIndex); + const totalTimelineSpan = createTextMotionControlledTimelineSpan(delaySecondsByMotionIndex); const externalProgress = textProps?.progress; const controls = textProps?.controls; const fragments = createNativeTextRenderFragments(tokens, text); @@ -1073,10 +795,14 @@ function createNativeTextRendererComponent( /** Render text as wrapping React Native Text tokens animated by Reanimated. */ export function nativeText( options: NativeTextRendererOptions = {}, -): TextMotionRenderer<'native-text', NativeTextRendererProps['recipe']> { +): TextMotionSourceTokenRenderer< + 'native-text' | 'style-transform', + NativeTextRendererProps['recipe'] +> { return createTextMotionRendererHandle({ kind: 'nativeText', - capabilities: ['native-text'], + capabilities: ['native-text', 'style-transform'], + motionUnit: 'source-token', Component: createNativeTextRendererComponent(options), }); } diff --git a/packages/text-motion/src/renderers/nativeTextControls.ts b/packages/text-motion/src/renderers/nativeTextControls.ts index a1cae35..1903cdf 100644 --- a/packages/text-motion/src/renderers/nativeTextControls.ts +++ b/packages/text-motion/src/renderers/nativeTextControls.ts @@ -2,22 +2,21 @@ import { useLayoutEffect, useRef } from 'react'; import { cancelAnimation, type SharedValue } from 'react-native-reanimated'; import type { TextMotionControls } from '../controls'; -import type { NativeTextTokenMotion } from './nativeTextPlayback'; import { readTextMotionControlsDescriptor, type TextMotionControlCommand, type TextMotionControlCommandKind, } from '../controls/descriptors'; -import { clampNativeTextProgress } from './nativeTextProgress'; +import { clampTextMotionProgress, type TextMotionItemMotion } from './rendererMotion'; -type NativeTextProgressAnimationFactory = (tokenMotion: NativeTextTokenMotion) => number; +type NativeTextProgressAnimationFactory = (tokenMotion: TextMotionItemMotion) => number; type NativeTextOwnedPlaybackCommandOptions = { createProgressAnimation: NativeTextProgressAnimationFactory; progress: SharedValue; renderFinalState: boolean; - tokenMotion: NativeTextTokenMotion; + tokenMotion: TextMotionItemMotion; }; type NativeTextPlaybackCommandHandler = (options: NativeTextOwnedPlaybackCommandOptions) => void; @@ -68,7 +67,7 @@ function playOwnedProgress({ return; } - const currentProgress = clampNativeTextProgress(progress.value); + const currentProgress = clampTextMotionProgress(progress.value); const delayMs = currentProgress <= 0 ? tokenMotion.delayMs : 0; progress.value = createProgressAnimation({ diff --git a/packages/text-motion/src/renderers/nativeTextPlayback.ts b/packages/text-motion/src/renderers/nativeTextPlayback.ts index fbcdf05..b87266f 100644 --- a/packages/text-motion/src/renderers/nativeTextPlayback.ts +++ b/packages/text-motion/src/renderers/nativeTextPlayback.ts @@ -1,20 +1,8 @@ import type { TextMotionMotionConfig } from '../types/recipe'; +import type { TextMotionItemMotion, TextMotionStyleTransformState } from './rendererMotion'; -export type NativeTextStyleState = { - opacity: number; - scale: number; - translateX: number; - translateY: number; -}; - -export type NativeTextTokenMotion = { - delayMs: number; - initial: NativeTextStyleState; - target: NativeTextStyleState; - motion?: TextMotionMotionConfig; - pulseScale: number; - reducedMotion: 'final-state' | 'system'; -}; +export type NativeTextStyleState = TextMotionStyleTransformState; +export type NativeTextTokenMotion = TextMotionItemMotion; type NativeTextMotionOptionSnapshotValue = | null diff --git a/packages/text-motion/src/renderers/nativeTextProgress.ts b/packages/text-motion/src/renderers/nativeTextProgress.ts deleted file mode 100644 index 5b618e9..0000000 --- a/packages/text-motion/src/renderers/nativeTextProgress.ts +++ /dev/null @@ -1,45 +0,0 @@ -export const NATIVE_TEXT_CONTROLLED_TOKEN_TIMELINE_SPAN = 1; - -export type NativeTextControlledProgressPlan = { - tokenDelaySeconds: number; - totalTimelineSpan: number; -}; - -type NativeTextControlledProgressInput = NativeTextControlledProgressPlan & { - progress: number; -}; - -export function clampNativeTextProgress(progress: number): number { - 'worklet'; - - if (!Number.isFinite(progress)) { - return 0; - } - - return Math.min(1, Math.max(0, progress)); -} - -export function createNativeTextControlledTimelineSpan( - delaySecondsByMotionIndex: readonly number[], -): number { - const maxDelaySeconds = delaySecondsByMotionIndex.reduce( - (maxDelay, delaySeconds) => Math.max(maxDelay, delaySeconds), - 0, - ); - - return maxDelaySeconds + NATIVE_TEXT_CONTROLLED_TOKEN_TIMELINE_SPAN; -} - -export function mapNativeTextControlledProgressToTokenProgress({ - progress, - tokenDelaySeconds, - totalTimelineSpan, -}: NativeTextControlledProgressInput): number { - 'worklet'; - - const virtualTime = clampNativeTextProgress(progress) * totalTimelineSpan; - const tokenProgress = - (virtualTime - tokenDelaySeconds) / NATIVE_TEXT_CONTROLLED_TOKEN_TIMELINE_SPAN; - - return clampNativeTextProgress(tokenProgress); -} diff --git a/packages/text-motion/src/renderers/overlayText.tsx b/packages/text-motion/src/renderers/overlayText.tsx new file mode 100644 index 0000000..5eb9003 --- /dev/null +++ b/packages/text-motion/src/renderers/overlayText.tsx @@ -0,0 +1,938 @@ +import { + useCallback, + useLayoutEffect, + useRef, + useState, + type ComponentType, + type MutableRefObject, + type ReactElement, +} from 'react'; +import { + StyleSheet, + Text, + View, + type NativeSyntheticEvent, + type StyleProp, + type TextLayoutEvent, + type TextStyle, + type ViewStyle, +} from 'react-native'; +import { + createAnimatedComponent, + useAnimatedStyle, + useReducedMotion, + useSharedValue, + type SharedValue, +} from 'react-native-reanimated'; + +import type { TextMotionControls } from '../controls'; +import type { TextMotionComponentTextProps, TextMotionInternalRecipeConfig } from '../types/recipe'; +import type { + TextMotionLineMaskCapability, + TextMotionRenderedLineRenderer, + TextMotionRendererProps, +} from '../types/renderer'; + +import { + createHiddenTokenAccessibilityProps, + createParentAccessibilityProps, + parentLabelPolicy, + resolveAccessibleText, +} from '../accessibility'; +import { createTextMotionRendererHandle } from '../recipe/descriptors'; +import { createTextMotionRendererCapability } from '../types/renderer'; +import { useNativeTextControlsPlayback } from './nativeTextControls'; +import { + areNativeTextPlaybackRunsEqual, + createNativeTextPlaybackRun, + type NativeTextPlaybackRun, +} from './nativeTextPlayback'; +import { + compareOverlayTextLayouts, + createOverlayTextLayout, + type OverlayTextLayoutReady, + type OverlayTextMotionLine, +} from './overlayTextLayout'; +import { + clampTextMotionProgress, + createTextMotionControlledProgressPlan, + createTextMotionControlledTimelineSpan, + createTextMotionDelaySecondsByItemIndex, + createTextMotionItemMotion, + createOverlayTextStyleTransformStatePair, + createTextMotionProgressAnimation, + createTextMotionStyleTransformStatePair, + readTextMotionProgressForAnimatedStyle, + shouldRenderTextMotionFinalState, + type TextMotionControlledProgressPlan, + type TextMotionItemMotion, +} from './rendererMotion'; + +type OverlayTextRendererProps = Omit & { + recipe: TextMotionInternalRecipeConfig; +}; + +type OverlayTextContainerProps = Pick< + TextMotionComponentTextProps, + | 'accessibilityActions' + | 'accessibilityHint' + | 'accessibilityLanguage' + | 'accessibilityRole' + | 'accessibilityState' + | 'accessibilityValue' + | 'nativeID' + | 'onAccessibilityAction' + | 'testID' +> & + ReturnType; + +type OverlayTextTypographyProps = Pick< + TextMotionComponentTextProps, + 'allowFontScaling' | 'maxFontSizeMultiplier' +>; + +type OverlayTextFinalSourceProps = { + children: OverlayTextRendererProps['children']; + containerProps: OverlayTextContainerProps; + sourceAccessibilityProps: ReturnType; + textStyle: StyleProp; + typographyProps: OverlayTextTypographyProps; +}; + +type OverlayTextActiveRendererProps = { + children: OverlayTextRendererProps['children']; + containerProps: OverlayTextContainerProps; + recipe: OverlayTextRendererProps['recipe']; + sourceAccessibilityProps: ReturnType; + text: string; + textProps: OverlayTextRendererProps['textProps']; + textStyle: StyleProp; + typographyProps: OverlayTextTypographyProps; +}; + +type OverlayTextAnimatedStyleOptions = + | { + controlled: true; + controlledProgressPlan: TextMotionControlledProgressPlan; + progress: SharedValue; + renderFinalState: boolean; + tokenMotion: TextMotionItemMotion; + } + | { + controlled: false; + progress: SharedValue; + renderFinalState: boolean; + tokenMotion: TextMotionItemMotion; + }; + +type OverlayTextAnimatedLineProps = { + children: string; + line: OverlayTextMotionLine; + maskTestID?: string; + motionTestID?: string; + paragraphTestID?: string; + revealContentMotion: TextMotionItemMotion; + textStyle: StyleProp; + frameMotion: TextMotionItemMotion; + typographyProps: OverlayTextTypographyProps; +}; + +type OverlayTextUncontrolledLineProps = OverlayTextAnimatedLineProps & { + controls?: TextMotionControls; + playbackIdentity: string; +}; + +type OverlayTextControlledLineProps = OverlayTextAnimatedLineProps & { + controlledProgress: SharedValue; + controlledProgressPlan: TextMotionControlledProgressPlan; +}; + +type OverlayTextLineMaskProps = Pick< + OverlayTextAnimatedLineProps, + | 'children' + | 'line' + | 'maskTestID' + | 'motionTestID' + | 'paragraphTestID' + | 'textStyle' + | 'typographyProps' +> & { + frameAnimatedStyle: ReturnType; + revealContentAnimatedStyle: ReturnType; +}; + +type OverlayTextLayoutResult = { + fallbackReason?: string; + inputKey: string; + layout?: OverlayTextLayoutReady; +}; + +export type OverlayTextRendererOptions = { + /** Prefix used for generated line mask `testID` values. */ + testIDPrefix?: string; +}; + +const AnimatedView = createAnimatedComponent(View); +const LINE_MASK_CAPABILITY: TextMotionLineMaskCapability = + createTextMotionRendererCapability('line-mask'); +const HIDDEN_ACCESSIBILITY_PROPS = { + accessibilityElementsHidden: true, + accessible: false, + importantForAccessibility: 'no-hide-descendants', +} as const; +const OVERLAY_CONTAINER_STYLE = { + ...StyleSheet.absoluteFill, + pointerEvents: 'none', +} as const satisfies ViewStyle; +const PENDING_OVERLAY_CONTAINER_STYLE = { + opacity: 0, +} as const satisfies ViewStyle; +const SOURCE_LAYOUT_STYLE = { + alignSelf: 'stretch', +} as const satisfies TextStyle; +const READY_SOURCE_STYLE = { + opacity: 0, +} as const satisfies TextStyle; +const MASK_STYLE = { + left: 0, + overflow: 'hidden', + position: 'absolute', + right: 0, +} as const satisfies ViewStyle; +const LINE_ANIMATION_STYLE = { + left: 0, + position: 'absolute', + top: 0, + width: '100%', +} as const satisfies ViewStyle; +const PARAGRAPH_COPY_STYLE = { + left: 0, + position: 'absolute', + width: '100%', +} as const satisfies TextStyle; +const OVERLAY_TEXT_SHAPING_STYLE_KEYS = [ + 'direction', + 'fontFamily', + 'fontSize', + 'fontStyle', + 'fontVariant', + 'fontWeight', + 'includeFontPadding', + 'letterSpacing', + 'lineHeight', + 'textAlign', + 'textAlignVertical', + 'textTransform', + 'verticalAlign', + 'writingDirection', +] as const satisfies readonly (keyof TextStyle)[]; +const OVERLAY_TEXT_BOX_LAYOUT_STYLE_KEYS = [ + 'alignSelf', + 'aspectRatio', + 'borderBottomWidth', + 'borderEndWidth', + 'borderLeftWidth', + 'borderRightWidth', + 'borderStartWidth', + 'borderTopWidth', + 'borderWidth', + 'bottom', + 'boxSizing', + 'display', + 'end', + 'flex', + 'flexBasis', + 'flexGrow', + 'flexShrink', + 'height', + 'inset', + 'insetBlock', + 'insetBlockEnd', + 'insetBlockStart', + 'insetInline', + 'insetInlineEnd', + 'insetInlineStart', + 'left', + 'margin', + 'marginBlock', + 'marginBlockEnd', + 'marginBlockStart', + 'marginBottom', + 'marginEnd', + 'marginHorizontal', + 'marginInline', + 'marginInlineEnd', + 'marginInlineStart', + 'marginLeft', + 'marginRight', + 'marginStart', + 'marginTop', + 'marginVertical', + 'maxHeight', + 'maxWidth', + 'minHeight', + 'minWidth', + 'padding', + 'paddingBlock', + 'paddingBlockEnd', + 'paddingBlockStart', + 'paddingBottom', + 'paddingEnd', + 'paddingHorizontal', + 'paddingInline', + 'paddingInlineEnd', + 'paddingInlineStart', + 'paddingLeft', + 'paddingRight', + 'paddingStart', + 'paddingTop', + 'paddingVertical', + 'position', + 'right', + 'start', + 'top', + 'width', +] as const satisfies readonly (keyof TextStyle)[]; +const OVERLAY_TEXT_LAYOUT_STYLE_KEYS = [ + ...OVERLAY_TEXT_SHAPING_STYLE_KEYS, + ...OVERLAY_TEXT_BOX_LAYOUT_STYLE_KEYS, +] as const; + +type OverlayTextLayoutStyleSnapshot = Partial< + Record<(typeof OVERLAY_TEXT_LAYOUT_STYLE_KEYS)[number], unknown> +>; + +function createContainerProps( + textProps: TextMotionComponentTextProps | undefined, + parentAccessibilityProps: ReturnType, +): OverlayTextContainerProps { + return { + accessibilityActions: textProps?.accessibilityActions, + accessibilityHint: textProps?.accessibilityHint, + accessibilityLabel: + textProps?.accessibilityLabel ?? parentAccessibilityProps.accessibilityLabel, + accessibilityLanguage: textProps?.accessibilityLanguage, + accessibilityRole: textProps?.accessibilityRole, + accessibilityState: textProps?.accessibilityState, + accessibilityValue: textProps?.accessibilityValue, + accessible: parentAccessibilityProps.accessible, + nativeID: textProps?.nativeID, + onAccessibilityAction: textProps?.onAccessibilityAction, + testID: textProps?.testID, + }; +} + +function createTypographyProps( + textProps: TextMotionComponentTextProps | undefined, +): OverlayTextTypographyProps { + return { + allowFontScaling: textProps?.allowFontScaling, + maxFontSizeMultiplier: textProps?.maxFontSizeMultiplier, + }; +} + +function OverlayTextFinalSource({ + children, + containerProps, + sourceAccessibilityProps, + textStyle, + typographyProps, +}: OverlayTextFinalSourceProps) { + return ( + + + {children} + + + ); +} + +function createInitialTextStyle(tokenMotion: TextMotionItemMotion): StyleProp { + return { + opacity: tokenMotion.initial.opacity, + transform: [ + { translateX: tokenMotion.initial.translateX }, + { translateY: tokenMotion.initial.translateY }, + { scale: tokenMotion.initial.scale }, + ], + }; +} + +function createSourceTextMotionStyle( + layout: OverlayTextLayoutReady | undefined, + pendingMotion: TextMotionItemMotion, +): StyleProp { + if (layout) { + return READY_SOURCE_STYLE; + } + + if (pendingMotion.initial.scale !== 1) { + return PENDING_OVERLAY_CONTAINER_STYLE; + } + + return createInitialTextStyle(pendingMotion); +} + +function createLineFrameStyle(line: OverlayTextMotionLine): StyleProp { + return { + height: line.height, + top: line.y, + transformOrigin: [line.x + line.width / 2, line.height / 2, 0], + }; +} + +function isSerializableRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function createStableLayoutInputValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(createStableLayoutInputValue); + } + + if (!isSerializableRecord(value)) { + return value; + } + + const entries = Object.entries(value); + + entries.sort(([left], [right]) => left.localeCompare(right)); + + return Object.fromEntries( + entries.map(([key, nestedValue]) => [key, createStableLayoutInputValue(nestedValue)]), + ); +} + +function createOverlayTextLayoutStyleSnapshot( + style: StyleProp, +): OverlayTextLayoutStyleSnapshot { + const flattenedStyle = StyleSheet.flatten(style) ?? {}; + + return OVERLAY_TEXT_LAYOUT_STYLE_KEYS.reduce((snapshot, key) => { + const value = flattenedStyle[key]; + + if (value === undefined) { + return snapshot; + } + + return { + ...snapshot, + [key]: createStableLayoutInputValue(value), + }; + }, {}); +} + +function createOverlayTextInputKey( + text: string, + textProps: TextMotionComponentTextProps | undefined, +): string { + return JSON.stringify({ + allowFontScaling: textProps?.allowFontScaling, + maxFontSizeMultiplier: textProps?.maxFontSizeMultiplier, + style: createOverlayTextLayoutStyleSnapshot(textProps?.style), + text, + }); +} + +function createParagraphCopyOffset(line: OverlayTextMotionLine): number { + return line.y === 0 ? 0 : -line.y; +} + +function OverlayTextLineMask({ + children, + frameAnimatedStyle, + line, + maskTestID, + motionTestID, + paragraphTestID, + revealContentAnimatedStyle, + textStyle, + typographyProps, +}: OverlayTextLineMaskProps): ReactElement { + return ( + + } + testID={motionTestID} + > + + {children} + + + + ); +} + +function useOverlayTextAnimatedStyle(options: OverlayTextAnimatedStyleOptions) { + const { controlled, progress, renderFinalState, tokenMotion } = options; + const initialOpacity = tokenMotion.initial.opacity; + const initialScale = tokenMotion.initial.scale; + const initialTranslateX = tokenMotion.initial.translateX; + const initialTranslateY = tokenMotion.initial.translateY; + const targetOpacity = tokenMotion.target.opacity; + const targetScale = tokenMotion.target.scale; + const targetTranslateX = tokenMotion.target.translateX; + const targetTranslateY = tokenMotion.target.translateY; + const pulseScale = tokenMotion.pulseScale; + const itemDelaySeconds = controlled ? options.controlledProgressPlan.itemDelaySeconds : 0; + const totalTimelineSpan = controlled ? options.controlledProgressPlan.totalTimelineSpan : 1; + + return useAnimatedStyle(() => { + const current = readTextMotionProgressForAnimatedStyle({ + controlledProgressPlan: controlled + ? { + itemDelaySeconds, + totalTimelineSpan, + } + : undefined, + progress, + renderFinalState, + }); + const clampedCurrent = clampTextMotionProgress(current); + const baseScale = initialScale + (targetScale - initialScale) * current; + const pulseProgress = 1 - Math.abs(clampedCurrent * 2 - 1); + const pulseMultiplier = 1 + (pulseScale - 1) * pulseProgress; + + return { + opacity: initialOpacity + (targetOpacity - initialOpacity) * current, + transform: [ + { + translateX: initialTranslateX + (targetTranslateX - initialTranslateX) * current, + }, + { + translateY: initialTranslateY + (targetTranslateY - initialTranslateY) * current, + }, + { + scale: baseScale * pulseMultiplier, + }, + ], + }; + }, [ + initialOpacity, + initialScale, + initialTranslateX, + initialTranslateY, + controlled, + progress, + pulseScale, + renderFinalState, + targetOpacity, + targetScale, + targetTranslateX, + targetTranslateY, + itemDelaySeconds, + totalTimelineSpan, + ]); +} + +function OverlayTextUncontrolledLine({ + children, + controls, + line, + maskTestID, + motionTestID, + paragraphTestID, + playbackIdentity, + revealContentMotion, + textStyle, + frameMotion, + typographyProps, +}: OverlayTextUncontrolledLineProps) { + const reducedMotionEnabled = useReducedMotion(); + const renderFinalState = shouldRenderTextMotionFinalState( + reducedMotionEnabled, + frameMotion.reducedMotion, + ); + const progress = useSharedValue(renderFinalState ? 1 : 0); + const playbackRun = createNativeTextPlaybackRun(frameMotion, renderFinalState, playbackIdentity); + const previousPlaybackRun = useRef(undefined); + const frameAnimatedStyle = useOverlayTextAnimatedStyle({ + controlled: false, + progress, + renderFinalState, + tokenMotion: frameMotion, + }); + const revealContentAnimatedStyle = useOverlayTextAnimatedStyle({ + controlled: false, + progress, + renderFinalState, + tokenMotion: revealContentMotion, + }); + + useLayoutEffect(() => { + if (areNativeTextPlaybackRunsEqual(previousPlaybackRun.current, playbackRun)) { + return; + } + + previousPlaybackRun.current = playbackRun; + + if (playbackRun.renderFinalState) { + progress.value = 1; + return; + } + + progress.value = 0; + progress.value = createTextMotionProgressAnimation(playbackRun); + }); + + useNativeTextControlsPlayback({ + controls, + createProgressAnimation: createTextMotionProgressAnimation, + progress, + renderFinalState, + tokenMotion: frameMotion, + }); + + return ( + + {children} + + ); +} + +function OverlayTextControlledLine({ + children, + controlledProgress, + controlledProgressPlan, + line, + maskTestID, + motionTestID, + paragraphTestID, + revealContentMotion, + textStyle, + frameMotion, + typographyProps, +}: OverlayTextControlledLineProps) { + const reducedMotionEnabled = useReducedMotion(); + const renderFinalState = shouldRenderTextMotionFinalState( + reducedMotionEnabled, + frameMotion.reducedMotion, + ); + const frameAnimatedStyle = useOverlayTextAnimatedStyle({ + controlled: true, + controlledProgressPlan, + progress: controlledProgress, + renderFinalState, + tokenMotion: frameMotion, + }); + const revealContentAnimatedStyle = useOverlayTextAnimatedStyle({ + controlled: true, + controlledProgressPlan, + progress: controlledProgress, + renderFinalState, + tokenMotion: revealContentMotion, + }); + + return ( + + {children} + + ); +} + +function warnOverlayTextFallbackOnce(warnedRef: MutableRefObject, reason: string) { + if (warnedRef.current) { + return; + } + + warnedRef.current = true; + + if (typeof __DEV__ !== 'undefined' && __DEV__) { + console.warn( + `@react-native-motion-kit/text-motion overlayText() received unsupported line layout (${reason}); rendering readable final text instead.`, + ); + } +} + +function isTextLayoutEventRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function readTextLayoutNativeEvent(event: unknown): unknown { + if (!isTextLayoutEventRecord(event) || !('nativeEvent' in event)) { + return event; + } + + const nativeEvent = event.nativeEvent; + + if (!isTextLayoutEventRecord(nativeEvent) || 'lines' in nativeEvent) { + return nativeEvent; + } + + return 'nativeEvent' in nativeEvent ? nativeEvent.nativeEvent : nativeEvent; +} + +function createMaskTestID(prefix: string | undefined, line: OverlayTextMotionLine) { + return prefix ? `${prefix}-${line.motionIndex}` : undefined; +} + +function createParagraphTestID(prefix: string | undefined, line: OverlayTextMotionLine) { + return prefix ? `${prefix}-${line.motionIndex}-copy` : undefined; +} + +function createMotionTestID(prefix: string | undefined, line: OverlayTextMotionLine) { + return prefix ? `${prefix}-${line.motionIndex}-motion` : undefined; +} + +function createOverlayTestID(prefix: string | undefined) { + return prefix ? `${prefix}-overlay` : undefined; +} + +function createOverlayTextRendererComponent( + options: OverlayTextRendererOptions, +): ComponentType { + function OverlayTextActiveRenderer({ + children, + containerProps, + recipe, + sourceAccessibilityProps, + text, + textProps, + textStyle, + typographyProps, + }: OverlayTextActiveRendererProps) { + const inputKey = createOverlayTextInputKey(text, textProps); + const styleState = createTextMotionStyleTransformStatePair(recipe.effects, 'overlayText()'); + const overlayStyleState = createOverlayTextStyleTransformStatePair(recipe.effects); + const delaySecondsByLineIndex = createTextMotionDelaySecondsByItemIndex(recipe, 1); + const pendingMotion = createTextMotionItemMotion( + recipe, + styleState, + delaySecondsByLineIndex[0] ?? 0, + ); + const [layoutResult, setLayoutResult] = useState( + undefined, + ); + const boundInputKey = layoutResult?.inputKey; + const retainedLayout = layoutResult?.layout; + const currentLayout = boundInputKey === inputKey ? retainedLayout : undefined; + const fallbackReason = boundInputKey === inputKey ? layoutResult?.fallbackReason : undefined; + const layoutPending = Boolean(retainedLayout && boundInputKey !== inputKey); + const warnedFallback = useRef(false); + const externalProgress = textProps?.progress; + const controls = textProps?.controls; + const delaySecondsByMotionIndex = retainedLayout + ? createTextMotionDelaySecondsByItemIndex(recipe, retainedLayout.motionCount) + : []; + const totalTimelineSpan = createTextMotionControlledTimelineSpan(delaySecondsByMotionIndex); + const onTextLayout = useCallback( + (event: NativeSyntheticEvent) => { + const nextLayout = createOverlayTextLayout(text, readTextLayoutNativeEvent(event)); + + if (nextLayout.kind === 'static') { + setLayoutResult({ inputKey }); + return; + } + + if (nextLayout.kind === 'fallback') { + setLayoutResult({ fallbackReason: nextLayout.reason, inputKey }); + warnOverlayTextFallbackOnce(warnedFallback, nextLayout.reason); + return; + } + + const change = compareOverlayTextLayouts(retainedLayout, nextLayout); + + if (change === 'identical' && boundInputKey === inputKey) { + return; + } + + setLayoutResult({ inputKey, layout: nextLayout }); + }, + [boundInputKey, inputKey, retainedLayout, text], + ); + + if (text.trim().length === 0 || fallbackReason) { + return ( + + {children} + + ); + } + + return ( + + + {children} + + {retainedLayout ? ( + + {retainedLayout.motionLines.map((line) => { + const frameMotion = createTextMotionItemMotion( + recipe, + overlayStyleState.frame, + delaySecondsByMotionIndex[line.motionIndex] ?? 0, + ); + const revealContentMotion = createTextMotionItemMotion( + recipe, + overlayStyleState.revealContent, + delaySecondsByMotionIndex[line.motionIndex] ?? 0, + ); + + if (!externalProgress) { + return ( + + {text} + + ); + } + + return ( + + {text} + + ); + })} + + ) : null} + + ); + } + + function OverlayTextRenderer({ children, recipe, textProps, tokens }: OverlayTextRendererProps) { + const reducedMotionEnabled = useReducedMotion(); + const text = typeof children === 'string' ? children : ''; + const textStyle = textProps?.style; + const policy = recipe.accessibility ?? parentLabelPolicy(); + const accessibilityLabel = resolveAccessibleText(text, tokens); + const parentAccessibilityProps = createParentAccessibilityProps(policy, accessibilityLabel); + const sourceAccessibilityProps = createHiddenTokenAccessibilityProps(policy); + const containerProps = createContainerProps(textProps, parentAccessibilityProps); + const typographyProps = createTypographyProps(textProps); + const externalProgress = textProps?.progress; + const controls = textProps?.controls; + + if (externalProgress && controls) { + throw new Error( + '@react-native-motion-kit/text-motion cannot receive both progress and controls. Use progress for raw app-owned values, or controls for event-driven playback.', + ); + } + + if (reducedMotionEnabled) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); + } + + OverlayTextRenderer.displayName = 'OverlayTextRenderer'; + + return OverlayTextRenderer; +} + +/** Render one native paragraph with animated masks for each actual rendered line. */ +export function overlayText( + options: OverlayTextRendererOptions = {}, +): TextMotionRenderedLineRenderer< + TextMotionLineMaskCapability | 'style-transform', + OverlayTextRendererProps['recipe'] +> { + return createTextMotionRendererHandle({ + kind: 'overlayText', + capabilities: [LINE_MASK_CAPABILITY, 'style-transform'], + motionUnit: 'rendered-line', + Component: createOverlayTextRendererComponent(options), + }); +} diff --git a/packages/text-motion/src/renderers/overlayTextLayout.ts b/packages/text-motion/src/renderers/overlayTextLayout.ts new file mode 100644 index 0000000..a32379d --- /dev/null +++ b/packages/text-motion/src/renderers/overlayTextLayout.ts @@ -0,0 +1,312 @@ +export type OverlayTextLineGeometry = { + height: number; + index: number; + isStructuralBlank: boolean; + motionIndex?: number; + text: string; + width: number; + x: number; + y: number; +}; + +export type OverlayTextMotionLine = OverlayTextLineGeometry & { + motionIndex: number; +}; + +export type OverlayTextLayoutReady = { + geometrySignature: string; + kind: 'ready'; + lines: readonly OverlayTextLineGeometry[]; + motionCount: number; + motionLines: readonly OverlayTextMotionLine[]; + playbackSignature: string; + signature: string; + topologySignature: string; +}; + +export type OverlayTextLayoutStatic = { + kind: 'static'; + reason: 'empty-source'; +}; + +export type OverlayTextLayoutFallback = { + kind: 'fallback'; + reason: + | 'malformed-payload' + | 'missing-line-text' + | 'non-finite-geometry' + | 'non-positive-visible-height' + | 'unordered-bands' + | 'overlapping-bands' + | 'missing-visible-lines'; +}; + +export type OverlayTextLayoutResult = + | OverlayTextLayoutFallback + | OverlayTextLayoutReady + | OverlayTextLayoutStatic; + +export type OverlayTextLayoutChange = 'identical' | 'geometry' | 'topology'; + +type OverlayTextNativeLine = { + height: unknown; + text: unknown; + width: unknown; + x: unknown; + y: unknown; +}; + +type OverlayTextLayoutDraft = { + lines: readonly OverlayTextLineGeometry[]; + motionIndex: number; +}; + +const GEOMETRY_NOISE_STEP = 0.01; +const BAND_OVERLAP_TOLERANCE = 0.5; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isFallbackResult( + value: OverlayTextLayoutDraft | OverlayTextLayoutFallback, +): value is OverlayTextLayoutFallback { + return 'kind' in value; +} + +function readPayloadRecord(payload: unknown): Record | undefined { + if (!isRecord(payload)) { + return undefined; + } + + if ('lines' in payload) { + return payload; + } + + const nestedPayload = payload.nativeEvent; + + if (!isRecord(nestedPayload)) { + return payload; + } + + return 'lines' in nestedPayload ? nestedPayload : readPayloadRecord(nestedPayload); +} + +function normalizeFloat(value: number): number { + const normalized = Math.round(value / GEOMETRY_NOISE_STEP) * GEOMETRY_NOISE_STEP; + + return Object.is(normalized, -0) ? 0 : normalized; +} + +function parseNativeLine(value: unknown): OverlayTextNativeLine | undefined { + if (!isRecord(value)) { + return undefined; + } + + return { + height: value.height, + text: value.text, + width: value.width, + x: value.x, + y: value.y, + }; +} + +function parseNativeLines( + values: readonly unknown[], +): readonly OverlayTextNativeLine[] | undefined { + const lines = values.map(parseNativeLine); + + return lines.every((line): line is OverlayTextNativeLine => Boolean(line)) ? lines : undefined; +} + +function readFiniteNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? normalizeFloat(value) : undefined; +} + +function createGeometrySignature(lines: readonly OverlayTextLineGeometry[]): string { + return JSON.stringify(lines.map((line) => [line.index, line.x, line.y, line.width, line.height])); +} + +function createTopologySignature(lines: readonly OverlayTextLineGeometry[]): string { + return JSON.stringify(lines.map((line) => [line.index, line.text, line.isStructuralBlank])); +} + +function createReadyLayout( + sourceText: string, + lines: readonly OverlayTextLineGeometry[], +): OverlayTextLayoutReady { + const motionLines = lines.filter( + (line): line is OverlayTextMotionLine => typeof line.motionIndex === 'number', + ); + const geometrySignature = createGeometrySignature(lines); + const topologySignature = createTopologySignature(lines); + const playbackSignature = JSON.stringify([sourceText, topologySignature]); + + return { + geometrySignature, + kind: 'ready', + lines, + motionCount: motionLines.length, + motionLines, + playbackSignature, + signature: `${topologySignature}::${geometrySignature}`, + topologySignature, + }; +} + +function isMeaningfullyUnordered( + previous: OverlayTextLineGeometry | undefined, + current: OverlayTextLineGeometry, +): boolean { + return Boolean(previous && current.y + BAND_OVERLAP_TOLERANCE < previous.y); +} + +function isMeaningfullyOverlapping( + previous: OverlayTextLineGeometry | undefined, + current: OverlayTextLineGeometry, +): boolean { + if (!previous) { + return false; + } + + return current.y < previous.y + previous.height - BAND_OVERLAP_TOLERANCE; +} + +export function compareOverlayTextLayouts( + previous: OverlayTextLayoutReady | undefined, + next: OverlayTextLayoutReady, +): OverlayTextLayoutChange { + if (!previous) { + return 'topology'; + } + + if (previous.signature === next.signature) { + return 'identical'; + } + + if (previous.topologySignature === next.topologySignature) { + return 'geometry'; + } + + return 'topology'; +} + +export function createOverlayTextLayout(source: string, payload: unknown): OverlayTextLayoutResult { + if (source.trim().length === 0) { + return { + kind: 'static', + reason: 'empty-source', + }; + } + + const payloadRecord = readPayloadRecord(payload); + + if (!payloadRecord || !Array.isArray(payloadRecord.lines)) { + return { + kind: 'fallback', + reason: 'malformed-payload', + }; + } + + const parsedLines = parseNativeLines(payloadRecord.lines); + + if (!parsedLines) { + return { + kind: 'fallback', + reason: 'malformed-payload', + }; + } + + const initial: OverlayTextLayoutDraft = { + lines: [], + motionIndex: 0, + }; + const normalized = parsedLines.reduce( + (accumulator, line, index) => { + if (isFallbackResult(accumulator)) { + return accumulator; + } + + if (typeof line.text !== 'string') { + return { + kind: 'fallback', + reason: 'missing-line-text', + }; + } + + const x = readFiniteNumber(line.x); + const y = readFiniteNumber(line.y); + const width = readFiniteNumber(line.width); + const height = readFiniteNumber(line.height); + + if ( + typeof x !== 'number' || + typeof y !== 'number' || + typeof width !== 'number' || + typeof height !== 'number' + ) { + return { + kind: 'fallback', + reason: 'non-finite-geometry', + }; + } + + const isStructuralBlank = line.text.trim().length === 0; + + if (!isStructuralBlank && height <= 0) { + return { + kind: 'fallback', + reason: 'non-positive-visible-height', + }; + } + + const previous = accumulator.lines[accumulator.lines.length - 1]; + const current: OverlayTextLineGeometry = { + height, + index, + isStructuralBlank, + motionIndex: isStructuralBlank ? undefined : accumulator.motionIndex, + text: line.text, + width, + x, + y, + }; + + if (isMeaningfullyUnordered(previous, current)) { + return { + kind: 'fallback', + reason: 'unordered-bands', + }; + } + + if (isMeaningfullyOverlapping(previous, current)) { + return { + kind: 'fallback', + reason: 'overlapping-bands', + }; + } + + return { + lines: [...accumulator.lines, current], + motionIndex: isStructuralBlank ? accumulator.motionIndex : accumulator.motionIndex + 1, + }; + }, + initial, + ); + + if (isFallbackResult(normalized)) { + return normalized; + } + + const ready = createReadyLayout(source, normalized.lines); + + if (ready.motionCount > 0) { + return ready; + } + + return { + kind: 'fallback', + reason: 'missing-visible-lines', + }; +} diff --git a/packages/text-motion/src/renderers/rendererMotion.ts b/packages/text-motion/src/renderers/rendererMotion.ts new file mode 100644 index 0000000..d65d8a7 --- /dev/null +++ b/packages/text-motion/src/renderers/rendererMotion.ts @@ -0,0 +1,477 @@ +import { + ReduceMotion, + withDelay, + withSpring, + withTiming, + type SharedValue, +} from 'react-native-reanimated'; + +import type { + TextMotionAnyEffect, + TextMotionAnyEffectDescriptor, + TextMotionEffectDescriptorOptions, + TextMotionInternalRecipeConfig, + TextMotionMotionConfig, +} from '../types/recipe'; + +import { + flattenTextMotionEffectDescriptors, + readTextMotionTimelineDescriptor, +} from '../recipe/descriptors'; + +export const TEXT_MOTION_CONTROLLED_ITEM_TIMELINE_SPAN = 1; + +export type TextMotionStyleTransformState = { + opacity: number; + scale: number; + translateX: number; + translateY: number; +}; + +export type TextMotionStyleTransformStatePair = { + initial: TextMotionStyleTransformState; + pulseScale: number; + target: TextMotionStyleTransformState; +}; + +export type TextMotionOverlayStyleTransformStatePair = { + frame: TextMotionStyleTransformStatePair; + revealContent: TextMotionStyleTransformStatePair; +}; + +export type TextMotionItemMotion = { + delayMs: number; + initial: TextMotionStyleTransformState; + target: TextMotionStyleTransformState; + motion?: TextMotionMotionConfig; + pulseScale: number; + reducedMotion: 'final-state' | 'system'; +}; + +export type TextMotionControlledProgressPlan = { + itemDelaySeconds: number; + totalTimelineSpan: number; +}; + +type TextMotionControlledProgressInput = TextMotionControlledProgressPlan & { + progress: number; +}; + +type TextMotionNumberTransform = (value: number) => number; +type TextMotionStyleStateTransform = ( + state: TextMotionStyleTransformState, +) => TextMotionStyleTransformState; + +type EffectStyleStateChange = { + initial?: TextMotionStyleStateTransform; + pulseScale?: TextMotionNumberTransform; + target?: TextMotionStyleStateTransform; +}; + +type EffectStyleStateResolver = (effect: TextMotionAnyEffectDescriptor) => EffectStyleStateChange; +type TextMotionStyleNumberKey = keyof TextMotionStyleTransformState; + +const DEFAULT_STYLE_STATE: TextMotionStyleTransformState = { + opacity: 1, + scale: 1, + translateX: 0, + translateY: 0, +}; + +const REDUCE_MOTION_CONFIG_BY_POLICY = { + 'final-state': ReduceMotion.Never, + system: ReduceMotion.System, +} as const satisfies Record; + +const preserveStyleState: TextMotionStyleStateTransform = (state) => state; +const preserveNumber: TextMotionNumberTransform = (value) => value; + +const effectStyleStateResolvers: Record = { + fade(effect) { + return { + initial: setStyleNumber('opacity', numberOption(effect.options, 'from', 0)), + target: setStyleNumber('opacity', numberOption(effect.options, 'to', 1)), + }; + }, + lineReveal(effect) { + return { + initial: composeStyleTransforms( + setStyleNumber('opacity', numberOption(effect.options, 'fromOpacity', 0)), + mapStyleNumber( + 'translateY', + (translateY) => translateY + numberOption(effect.options, 'y', 16), + ), + ), + target: preserveStyleState, + }; + }, + pulse(effect) { + return { + pulseScale: (scale) => scale * numberOption(effect.options, 'scale', 1.04), + }; + }, + rise(effect) { + return { + initial: mapStyleNumber( + 'translateY', + (translateY) => translateY + numberOption(effect.options, 'y', 12), + ), + target: preserveStyleState, + }; + }, + scale(effect) { + return { + initial: mapStyleNumber( + 'scale', + (scale) => scale * numberOption(effect.options, 'from', 0.96), + ), + target: mapStyleNumber('scale', (scale) => scale * numberOption(effect.options, 'to', 1)), + }; + }, + shake(effect) { + return { + initial: mapStyleNumber( + 'translateX', + (translateX) => translateX + numberOption(effect.options, 'x', 4), + ), + target: preserveStyleState, + }; + }, + slide(effect) { + return { + initial: composeStyleTransforms( + mapStyleNumber( + 'translateX', + (translateX) => translateX + numberOption(effect.options, 'x', 0), + ), + mapStyleNumber( + 'translateY', + (translateY) => translateY + numberOption(effect.options, 'y', 12), + ), + ), + target: preserveStyleState, + }; + }, +}; + +const overlayFrameEffectStyleStateResolvers: Record = { + ...effectStyleStateResolvers, + lineReveal(effect) { + return { + initial: setStyleNumber('opacity', numberOption(effect.options, 'fromOpacity', 0)), + target: preserveStyleState, + }; + }, +}; + +const overlayRevealContentEffectStyleStateResolvers: Record = { + fade: preserveEffectStyleState, + lineReveal(effect) { + return { + initial: mapStyleNumber( + 'translateY', + (translateY) => translateY + numberOption(effect.options, 'y', 16), + ), + target: preserveStyleState, + }; + }, + pulse: preserveEffectStyleState, + rise: preserveEffectStyleState, + scale: preserveEffectStyleState, + shake: preserveEffectStyleState, + slide: preserveEffectStyleState, +}; + +function numberOption( + options: TextMotionEffectDescriptorOptions | undefined, + key: string, + fallback: number, +): number { + const value = options?.[key]; + + if (value === undefined) { + return fallback; + } + + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + throw new Error( + `@react-native-motion-kit/text-motion effect option "${key}" must be a finite number.`, + ); +} + +function validateDelaySeconds(delay: number): number { + if (Number.isFinite(delay) && delay >= 0) { + return delay; + } + + throw new Error( + '@react-native-motion-kit/text-motion timeline delay must be a finite number greater than or equal to 0.', + ); +} + +function createDelayMs(delaySeconds: number): number { + const delayMs = Math.round(delaySeconds * 1000); + + if (Number.isSafeInteger(delayMs)) { + return delayMs; + } + + throw new Error( + '@react-native-motion-kit/text-motion timeline delay must convert to a safe integer millisecond value.', + ); +} + +function createDefaultStyleStatePair(): TextMotionStyleTransformStatePair { + return { + initial: { ...DEFAULT_STYLE_STATE }, + pulseScale: 1, + target: { ...DEFAULT_STYLE_STATE }, + }; +} + +function mapStyleNumber( + key: TextMotionStyleNumberKey, + mapValue: (value: number) => number, +): TextMotionStyleStateTransform { + return (state) => { + const nextState: TextMotionStyleTransformState = { + ...state, + }; + + nextState[key] = mapValue(state[key]); + + return nextState; + }; +} + +function setStyleNumber( + key: TextMotionStyleNumberKey, + value: number, +): TextMotionStyleStateTransform { + return mapStyleNumber(key, () => value); +} + +function composeStyleTransforms( + ...transforms: readonly TextMotionStyleStateTransform[] +): TextMotionStyleStateTransform { + return (state) => transforms.reduce((current, transform) => transform(current), state); +} + +function preserveEffectStyleState(): EffectStyleStateChange { + return {}; +} + +function resolveEffectStyleStateChange( + effect: TextMotionAnyEffectDescriptor, + unsupportedEffectSubject: string, + resolvers: Record = effectStyleStateResolvers, +): EffectStyleStateChange { + const resolver = resolvers[effect.name]; + + if (resolver) { + return resolver(effect); + } + + throw new Error( + `${unsupportedEffectSubject} does not implement the "${effect.name}" effect. Use a built-in style-transform effect or a renderer that handles this effect.`, + ); +} + +function applyResolvedEffectStyleStateChange( + styleState: TextMotionStyleTransformStatePair, + effectStyleStateChange: EffectStyleStateChange, +): TextMotionStyleTransformStatePair { + const initial = effectStyleStateChange.initial ?? preserveStyleState; + const pulseScale = effectStyleStateChange.pulseScale ?? preserveNumber; + const target = effectStyleStateChange.target ?? preserveStyleState; + + return { + initial: initial(styleState.initial), + pulseScale: pulseScale(styleState.pulseScale), + target: target(styleState.target), + }; +} + +function applyDelay(animation: number, delayMs: number, reduceMotion: ReduceMotion): number { + return delayMs > 0 ? withDelay(delayMs, animation, reduceMotion) : animation; +} + +export function createTextMotionStyleTransformStatePair( + effects: readonly TextMotionAnyEffect[], + unsupportedEffectSubject = 'style-transform renderer', +): TextMotionStyleTransformStatePair { + return createResolvedTextMotionStyleTransformStatePair( + effects, + unsupportedEffectSubject, + effectStyleStateResolvers, + ); +} + +function createResolvedTextMotionStyleTransformStatePair( + effects: readonly TextMotionAnyEffect[], + unsupportedEffectSubject: string, + resolvers: Record, +): TextMotionStyleTransformStatePair { + return flattenTextMotionEffectDescriptors(effects).reduce( + (styleState, effect) => + applyResolvedEffectStyleStateChange( + styleState, + resolveEffectStyleStateChange(effect, unsupportedEffectSubject, resolvers), + ), + createDefaultStyleStatePair(), + ); +} + +export function createOverlayTextStyleTransformStatePair( + effects: readonly TextMotionAnyEffect[], +): TextMotionOverlayStyleTransformStatePair { + return { + frame: createResolvedTextMotionStyleTransformStatePair( + effects, + 'overlayText()', + overlayFrameEffectStyleStateResolvers, + ), + revealContent: createResolvedTextMotionStyleTransformStatePair( + effects, + 'overlayText()', + overlayRevealContentEffectStyleStateResolvers, + ), + }; +} + +export function createTextMotionDelaySecondsByItemIndex( + recipe: TextMotionInternalRecipeConfig, + count: number, +): readonly number[] { + const timeline = recipe.timeline ? readTextMotionTimelineDescriptor(recipe.timeline) : undefined; + + return Array.from({ length: count }, (_, index) => + validateDelaySeconds(timeline?.delayFor(index, count) ?? 0), + ); +} + +export function createTextMotionItemMotion( + recipe: TextMotionInternalRecipeConfig, + styleState: TextMotionStyleTransformStatePair, + delaySeconds: number, +): TextMotionItemMotion { + return { + delayMs: createDelayMs(delaySeconds), + initial: styleState.initial, + motion: recipe.motion, + pulseScale: styleState.pulseScale, + reducedMotion: recipe.accessibility?.reducedMotion ?? 'system', + target: styleState.target, + }; +} + +export function createTextMotionControlledProgressPlan( + itemIndex: number, + delaySecondsByItemIndex: readonly number[], + totalTimelineSpan: number, +): TextMotionControlledProgressPlan { + return { + itemDelaySeconds: delaySecondsByItemIndex[itemIndex] ?? 0, + totalTimelineSpan, + }; +} + +export function clampTextMotionProgress(progress: number): number { + 'worklet'; + + if (!Number.isFinite(progress)) { + return 0; + } + + return Math.min(1, Math.max(0, progress)); +} + +export function createTextMotionControlledTimelineSpan( + delaySecondsByItemIndex: readonly number[], +): number { + const maxDelaySeconds = delaySecondsByItemIndex.reduce( + (maxDelay, delaySeconds) => Math.max(maxDelay, delaySeconds), + 0, + ); + + return maxDelaySeconds + TEXT_MOTION_CONTROLLED_ITEM_TIMELINE_SPAN; +} + +export function mapTextMotionControlledProgressToItemProgress({ + progress, + itemDelaySeconds, + totalTimelineSpan, +}: TextMotionControlledProgressInput): number { + 'worklet'; + + const virtualTime = clampTextMotionProgress(progress) * totalTimelineSpan; + const itemProgress = (virtualTime - itemDelaySeconds) / TEXT_MOTION_CONTROLLED_ITEM_TIMELINE_SPAN; + + return clampTextMotionProgress(itemProgress); +} + +export function resolveTextMotionReduceMotionConfig( + reducedMotion: TextMotionItemMotion['reducedMotion'], +) { + return REDUCE_MOTION_CONFIG_BY_POLICY[reducedMotion]; +} + +export function shouldRenderTextMotionFinalState( + reducedMotionEnabled: boolean, + reducedMotion: TextMotionItemMotion['reducedMotion'], +): boolean { + return reducedMotionEnabled && reducedMotion !== 'system'; +} + +export function createTextMotionProgressAnimation({ + delayMs, + motion, + reducedMotion, +}: Pick): number { + const reduceMotion = resolveTextMotionReduceMotionConfig(reducedMotion); + + if (motion?.kind === 'spring') { + const animation = withSpring(1, { + ...motion.options, + reduceMotion, + }); + + return applyDelay(animation, delayMs, reduceMotion); + } + + const timingOptions = motion?.kind === 'timing' ? motion.options : undefined; + const animation = withTiming(1, { + duration: 300, + ...timingOptions, + reduceMotion, + }); + + return applyDelay(animation, delayMs, reduceMotion); +} + +export function readTextMotionProgressForAnimatedStyle({ + controlledProgressPlan, + progress, + renderFinalState, +}: { + controlledProgressPlan?: TextMotionControlledProgressPlan; + progress: SharedValue; + renderFinalState: boolean; +}): number { + 'worklet'; + + const rawProgress = renderFinalState ? 1 : progress.value; + + if (!controlledProgressPlan) { + return Number.isFinite(rawProgress) ? rawProgress : 0; + } + + return mapTextMotionControlledProgressToItemProgress({ + progress: rawProgress, + itemDelaySeconds: controlledProgressPlan.itemDelaySeconds, + totalTimelineSpan: controlledProgressPlan.totalTimelineSpan, + }); +} diff --git a/packages/text-motion/src/types/renderer.ts b/packages/text-motion/src/types/renderer.ts index d0c1ccc..1afbb88 100644 --- a/packages/text-motion/src/types/renderer.ts +++ b/packages/text-motion/src/types/renderer.ts @@ -5,6 +5,7 @@ import type { TextMotionToken } from './token'; declare const textMotionRendererCapabilityBrand: unique symbol; declare const textMotionRendererBrand: unique symbol; +declare const textMotionRenderedLineRendererBrand: unique symbol; /** Branded renderer capability name for custom renderer packages. */ export type TextMotionRendererCapabilityExtension = Name & { @@ -12,7 +13,13 @@ export type TextMotionRendererCapabilityExtension }; /** Capability supported by a renderer and required by effects. */ -export type TextMotionRendererCapability = 'native-text' | TextMotionRendererCapabilityExtension; +export type TextMotionRendererCapability = + | 'native-text' + | 'style-transform' + | TextMotionRendererCapabilityExtension; + +/** @internal Built-in capability used by overlayText line-mask effects. */ +export type TextMotionLineMaskCapability = TextMotionRendererCapabilityExtension<'line-mask'>; /** Create a branded capability name for a custom renderer. */ export function createTextMotionRendererCapability( @@ -33,16 +40,18 @@ export type TextMotionRendererProps = { export type TextMotionRendererDescriptor< Capabilities extends TextMotionRendererCapability = TextMotionRendererCapability, Recipe = TextMotionRecipeConfig, + MotionUnit extends 'source-token' | 'rendered-line' = 'source-token' | 'rendered-line', > = { kind: string; capabilities: readonly Capabilities[]; + motionUnit?: MotionUnit; Component: ComponentType>; }; /** Renderer handle used by `.layout(...)`. */ export type TextMotionRenderer< Capabilities extends TextMotionRendererCapability = TextMotionRendererCapability, - Recipe = TextMotionRecipeConfig, + Recipe = unknown, > = { readonly [textMotionRendererBrand]: { readonly capabilities: Capabilities; @@ -50,6 +59,22 @@ export type TextMotionRenderer< }; }; +/** @internal Renderer handle for source-token renderers accepted after `.split(...)`. */ +export type TextMotionSourceTokenRenderer< + Capabilities extends TextMotionRendererCapability = TextMotionRendererCapability, + Recipe = unknown, +> = TextMotionRenderer & { + readonly [textMotionRenderedLineRendererBrand]?: never; +}; + +/** @internal Renderer handle for renderers that own rendered-line layout. */ +export type TextMotionRenderedLineRenderer< + Capabilities extends TextMotionRendererCapability = TextMotionRendererCapability, + Recipe = unknown, +> = TextMotionRenderer & { + readonly [textMotionRenderedLineRendererBrand]: 'rendered-line'; +}; + /** Capabilities required by an effect but missing from a renderer. */ export type TextMotionMissingCapabilities< RendererCapabilities extends TextMotionRendererCapability, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8adaa8b..f627688 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,6 +78,9 @@ importers: '@types/react': specifier: ^19.2.0 version: 19.2.17 + babel-preset-expo: + specifier: ~56.0.15 + version: 56.0.15(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@56.0.12)(react-refresh@0.14.2) typescript: specifier: 'catalog:' version: 6.0.3