diff --git a/AGENTS.md b/AGENTS.md index 6a8fb5c..9c05297 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Overview -`react-native-ease` is a React Native library that provides declarative, native-powered animations via a single `EaseView` component. It uses Core Animation (iOS) and ObjectAnimator/SpringAnimation (Android) — no JS animation loop, no worklets, no C++ runtime. +`react-native-ease` is a React Native library that provides declarative, native-powered animations via `EaseView` and `EaseText` components. It uses Core Animation (iOS) and ObjectAnimator/SpringAnimation (Android) — no JS animation loop, no worklets, no C++ runtime. **Fabric (new architecture) only.** Does not support the old architecture. @@ -11,6 +11,8 @@ ``` src/ # TypeScript source (library) EaseView.tsx # React component — flattens props to native + EaseText.tsx # Text component — EaseView wrapper + Text with color interpolation + useColorTransition.ts # JS-side color interpolation hook (requestAnimationFrame) EaseViewNativeComponent.ts # Codegen spec — defines native props/events types.ts # Public TypeScript types index.tsx # Public exports @@ -26,8 +28,7 @@ android/src/main/java/com/ease/ EasePackage.kt # Package registration example/ # Demo app (separate workspace) - src/App.tsx # Main demo screen with animation examples - src/ComparisonScreen.tsx # Comparison with Reanimated + src/demos/ # Demo screens (one per feature) src/components/ # Shared demo components (Section, Button, TabBar) ``` @@ -43,6 +44,24 @@ transition={{ type: 'spring', damping: 10 }} → transitionType="spring", tran **Key design pattern:** All animation logic lives on the native side. The JS layer is purely a prop resolver — no animation state, no timers, no refs. +## EaseText Architecture + +`EaseText` is a **JS-only component** — no native code. It composes `EaseView` (for native transforms/opacity) with a standard `` (for text rendering). + +``` + + → ← native animations + ← JS color interpolation + ``` + +**Color:** Two modes: +- `style.color` — instant change, zero JS cost (same as ``) +- `interpolateColor` prop — smooth JS interpolation via `requestAnimationFrame`, follows the `color` key in `transition` (or falls back to `default`) + +**Why not native text color animation:** Fabric's text rendering pipeline (`RCTParagraphComponentView` on iOS) manages text via `NSAttributedString` in the shadow tree. The `attributedText` is readonly — color can't be mutated from outside the Fabric commit cycle. Android's `ReactTextView.setTextColor()` works, but iOS doesn't. JS interpolation keeps behavior consistent cross-platform. + +**TextAnimateProps:** `Omit` — transform/opacity props only; view-only properties (border, background, shadow, elevation) are excluded. Color is handled separately via `interpolateColor`. + ## Adding a New Animatable Property 1. Add to `AnimateProps` in `src/types.ts` @@ -117,3 +136,5 @@ Use conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, etc. - **Spring damping ratio on Android is derived** from `damping`, `stiffness`, and `mass` using: `dampingRatio = damping / (2 * sqrt(stiffness * mass))`. iOS passes these values directly to `CASpringAnimation`. - **Animation batching:** Both platforms track animation batches with a generation ID. When new animations start, any pending old-batch callbacks are fired as interrupted (`finished: false`). - **Loop only works with timing animations**, not springs. Loop requires `initialAnimate` to define the start value. +- **EaseText color has two modes:** `style.color` for instant changes (zero cost), `interpolateColor` prop for smooth JS interpolation via requestAnimationFrame. Transforms/opacity are always native via EaseView. +- **EaseText wraps EaseView + Text.** Layout behaves like a View containing a Text, not a bare Text. Use a wrapper View with `position: 'absolute'` for floating label patterns (see FloatingLabelDemo). diff --git a/README.md b/README.md index bf48d6e..6cd77a4 100644 --- a/README.md +++ b/README.md @@ -510,6 +510,49 @@ Default is `1280`, matching React Native's default perspective. /> ``` +### Animated Text (`EaseText`) + +`EaseText` provides native transform/opacity animations on text plus optional smooth color transitions. It composes `EaseView` (for native animations) with a standard `` (for text rendering). + +```tsx +import { EaseText } from 'react-native-ease'; + +// Smooth color + native transforms + + {label} + + +// Instant color via style (zero JS cost) + + Tap me + +``` + +**Two color modes:** + +- `style.color` — instant change, zero JS cost. Recommended when you don't need smooth color transitions. +- `interpolateColor` prop — smooth JS interpolation via `requestAnimationFrame`. Follows the `color` key in `transition` (or `default` as fallback). + +Transforms and opacity are always native (60fps via the internal `EaseView`). Standard `TextProps` like `numberOfLines`, `ellipsizeMode`, `selectable`, and `onPress` are passed through to the underlying ``. + +**Layout note:** `EaseText` renders a wrapping `View` around ``. For floating-label patterns, place it in an absolutely positioned wrapper. + +**Spring color caveat:** spring transitions on `interpolateColor` are approximated as a 500 ms `easeOut` (real spring physics on the JS thread isn't worth the cost). Prefer `timing` for color transitions when precise feel matters. + ## API Reference ### `` @@ -602,6 +645,29 @@ A per-property map that applies different transition configs to different proper | `backgroundColor` | backgroundColor | | `border` | borderWidth, borderColor | | `shadow` | shadowOpacity, shadowRadius, shadowColor, shadowOffset, elevation | +| `color` | text color (EaseText only, JS-interpolated) | + +### `` + +A `Text` that animates transforms/opacity natively (via the internal `EaseView`) and optionally interpolates `color` on the JS thread. Accepts all standard `TextProps`. + +| Prop | Type | Description | +| ------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `animate` | `TextAnimateProps` | Target values for animated transforms/opacity. Excludes view-only props (border, shadow, background). | +| `initialAnimate` | `TextAnimateProps` | Starting values for enter animations. | +| `transition` | `Transition` | Same as `EaseView` — supports an extra `color` key in `TransitionMap` for the JS color interpolation. | +| `interpolateColor` | `ColorValue` | Target color, smoothly interpolated on the JS thread via `requestAnimationFrame`. | +| `initialInterpolateColor` | `ColorValue` | Starting color for mount animations (only applied when `interpolateColor` is set). | +| `onTransitionEnd` | `(event) => void` | Called when transform/opacity animations complete (does not fire for color interpolation). | +| `transformOrigin` | `{ x?: number; y?: number }` | Pivot point as 0–1 fractions. Default: `{ x: 0.5, y: 0.5 }`. | +| `transformPerspective` | `number` | Camera distance for 3D rotations. Default: `1280`. | +| `useHardwareLayer` | `boolean` | Android only — same as `EaseView`. | +| `style` | `TextStyle` | Use `style.color` for instant color changes (zero JS cost). | +| ...rest | `TextProps` | All standard `Text` props (`numberOfLines`, `ellipsizeMode`, `selectable`, `onPress`, etc.). | + +### `TextAnimateProps` + +`Omit` — same transform and opacity props as `AnimateProps`. View-only properties are excluded; color is set via `interpolateColor` or `style.color`. ## Hardware Layers (Android) diff --git a/docs/docs/api-reference.mdx b/docs/docs/api-reference.mdx index 85e26bc..328b95c 100644 --- a/docs/docs/api-reference.mdx +++ b/docs/docs/api-reference.mdx @@ -91,6 +91,29 @@ A per-property map that applies different transition configs to different proper | `backgroundColor` | backgroundColor | | `border` | borderWidth, borderColor | | `shadow` | shadowOpacity, shadowRadius, shadowColor, shadowOffset, elevation | +| `color` | text color (EaseText only, JS-interpolated) | + +## `` + +A `Text` that animates transforms/opacity natively (via the internal `EaseView`) and optionally interpolates `color` on the JS thread. Accepts all standard `TextProps`. + +| Prop | Type | Description | +| ------------------------- | ---------------------------- | ----------- | +| `animate` | `TextAnimateProps` | Target values for animated transforms/opacity | +| `initialAnimate` | `TextAnimateProps` | Starting values for enter animations | +| `transition` | `Transition` | Single config or per-property map (supports `color` key for JS color interpolation) | +| `interpolateColor` | `ColorValue` | Target color, smoothly interpolated on the JS thread via `requestAnimationFrame` | +| `initialInterpolateColor` | `ColorValue` | Starting color for mount animations | +| `onTransitionEnd` | `(event) => void` | Fires when transform/opacity animations complete (does not fire for color interpolation) | +| `transformOrigin` | `{ x?: number; y?: number }` | Pivot point as 0–1 fractions | +| `transformPerspective` | `number` | Camera distance for 3D rotations. Default: `1280` | +| `useHardwareLayer` | `boolean` | Android only | +| `style` | `TextStyle` | Use `style.color` for instant color changes (zero JS cost) | +| `...rest` | `TextProps` | All standard `Text` props (`numberOfLines`, `ellipsizeMode`, `selectable`, `onPress`, etc.) | + +## `TextAnimateProps` + +`Omit` — same transform/opacity props as `AnimateProps`. View-only properties are excluded. ## Hardware layers (Android) diff --git a/docs/docs/usage.mdx b/docs/docs/usage.mdx index 9b538d3..343aaf7 100644 --- a/docs/docs/usage.mdx +++ b/docs/docs/usage.mdx @@ -281,3 +281,50 @@ If a property appears in both `style` and `animate`, the animated value takes pr Notification card ``` + +## Animated text (`EaseText`) + +`EaseText` provides native transform/opacity animations on text plus optional smooth color transitions. Transforms and opacity run natively (60fps); color is interpolated on the JS thread when `interpolateColor` is set. + +```tsx +import { EaseText } from 'react-native-ease'; + +// Smooth color + native transforms + + {label} + + +// Instant color via style (zero JS cost) + + Tap me + +``` + +Two color modes: + +- `style.color` — instant change, zero JS cost. Recommended when you don't need smooth color transitions. +- `interpolateColor` prop — smooth interpolation via `requestAnimationFrame`. Follows the `color` key in `transition` (or `default` as fallback). + +All standard `TextProps` (`numberOfLines`, `ellipsizeMode`, `selectable`, `onPress`, ...) are passed through to the underlying ``. + +:::note[Layout] +`EaseText` renders a wrapping `View` around `` (it composes `EaseView` + `Text`). For floating-label patterns, place it in an absolutely positioned wrapper. +::: + +:::note[Spring color] +Spring transitions on `interpolateColor` are approximated as a 500 ms `easeOut` (real spring physics on the JS thread isn't worth the cost). Prefer `timing` for color when feel matters. +::: diff --git a/example/src/demos/FloatingLabelDemo.tsx b/example/src/demos/FloatingLabelDemo.tsx new file mode 100644 index 0000000..f9dbc98 --- /dev/null +++ b/example/src/demos/FloatingLabelDemo.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react'; +import { View, TextInput, StyleSheet } from 'react-native'; +import { EaseText } from 'react-native-ease'; + +import { Section } from '../components/Section'; + +function FloatingInput({ label }: { label: string }) { + const [focused, setFocused] = useState(false); + const [value, setValue] = useState(''); + const compact = focused || value.length > 0; + + return ( + + + + {label} + + + setFocused(true)} + onBlur={() => setFocused(false)} + placeholderTextColor="transparent" + /> + + ); +} + +export function FloatingLabelDemo() { + return ( +
+ + + +
+ ); +} + +const styles = StyleSheet.create({ + inputContainer: { + position: 'relative', + marginBottom: 20, + borderBottomWidth: 1, + borderBottomColor: '#2a2a4a', + paddingTop: 22, + }, + labelWrapper: { + position: 'absolute', + left: 0, + top: 22, + }, + floatingLabel: { + fontSize: 15, + fontWeight: '400', + }, + textInput: { + fontSize: 15, + color: '#fff', + paddingVertical: 8, + paddingHorizontal: 0, + }, +}); diff --git a/example/src/demos/TextColorDemo.tsx b/example/src/demos/TextColorDemo.tsx new file mode 100644 index 0000000..538a27a --- /dev/null +++ b/example/src/demos/TextColorDemo.tsx @@ -0,0 +1,83 @@ +import { useState } from 'react'; +import { StyleSheet } from 'react-native'; +import { EaseText } from 'react-native-ease'; + +import { Section } from '../components/Section'; +import { Button } from '../components/Button'; + +export function TextColorDemo() { + const [focused, setFocused] = useState(false); + + return ( +
+ + Smooth color transition (300ms) + + + + Color + Scale + + + + Color + Opacity + TranslateX + + + + Instant color (style.color, zero JS cost) + + +
+ ); +} + +const styles = StyleSheet.create({ + label: { + fontSize: 16, + marginBottom: 16, + }, + heading: { + fontSize: 28, + fontWeight: '700', + marginBottom: 16, + }, + subtitle: { + fontSize: 14, + marginBottom: 16, + }, + instant: { + fontSize: 14, + marginBottom: 24, + }, + instantIdle: { + color: '#8892b0', + }, + instantActive: { + color: '#e94560', + }, +}); diff --git a/example/src/demos/TextEnterDemo.tsx b/example/src/demos/TextEnterDemo.tsx new file mode 100644 index 0000000..8ac0f74 --- /dev/null +++ b/example/src/demos/TextEnterDemo.tsx @@ -0,0 +1,75 @@ +import { useState } from 'react'; +import { View, StyleSheet } from 'react-native'; +import { EaseText } from 'react-native-ease'; + +import { Section } from '../components/Section'; +import { Button } from '../components/Button'; + +export function TextEnterDemo() { + const [key, setKey] = useState(0); + + return ( +
+ + + Welcome Back + + + + Your dashboard is ready + + + + 🚀 + + + +
+ ); +} + +const styles = StyleSheet.create({ + textContainer: { + alignItems: 'center', + marginBottom: 24, + paddingVertical: 20, + }, + title: { + fontSize: 28, + fontWeight: '700', + marginBottom: 8, + }, + subtitle: { + fontSize: 16, + marginBottom: 16, + }, + emoji: { + fontSize: 48, + }, +}); diff --git a/example/src/demos/TextPropsDemo.tsx b/example/src/demos/TextPropsDemo.tsx new file mode 100644 index 0000000..eef5ac7 --- /dev/null +++ b/example/src/demos/TextPropsDemo.tsx @@ -0,0 +1,89 @@ +import { useState } from 'react'; +import { Alert, StyleSheet } from 'react-native'; +import { EaseText } from 'react-native-ease'; + +import { Section } from '../components/Section'; +import { Button } from '../components/Button'; + +export function TextPropsDemo() { + const [active, setActive] = useState(false); + + return ( +
+ + This is a very long text that will be truncated to a single line with + ellipsis because numberOfLines is set to 1 + + + Alert.alert('Long-press', 'onLongPress fired')} + style={styles.selectable} + > + Long-press me — onLongPress fires an Alert + + + Alert.alert('Pressed!', 'onPress works on EaseText')} + > + Tap me — onPress + instant color (style) + + + + Two lines max with spring scale. Lorem ipsum dolor sit amet, consectetur + adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. + + +
+ ); +} + +const styles = StyleSheet.create({ + truncated: { + fontSize: 14, + marginBottom: 16, + }, + selectable: { + fontSize: 14, + marginBottom: 16, + }, + pressable: { + fontSize: 14, + marginBottom: 16, + textDecorationLine: 'underline', + }, + pressableIdle: { + color: '#8892b0', + }, + pressableActive: { + color: '#e94560', + }, + multiline: { + fontSize: 14, + lineHeight: 20, + marginBottom: 24, + }, +}); diff --git a/example/src/demos/index.ts b/example/src/demos/index.ts index ab14eb5..13bcec5 100644 --- a/example/src/demos/index.ts +++ b/example/src/demos/index.ts @@ -27,6 +27,10 @@ import { TransformOriginDemo } from './TransformOriginDemo'; import { PerPropertyDemo } from './PerPropertyDemo'; import { ShadowDemo } from './ShadowDemo'; import { SpinDemo } from './SpinDemo'; +import { TextColorDemo } from './TextColorDemo'; +import { FloatingLabelDemo } from './FloatingLabelDemo'; +import { TextEnterDemo } from './TextEnterDemo'; +import { TextPropsDemo } from './TextPropsDemo'; import { UniwindDemo } from './uniwind/UniwindDemo'; interface DemoEntry { @@ -118,6 +122,26 @@ export const demos: Record = { title: 'Kitchen Sink', section: 'Advanced', }, + 'text-color': { + component: TextColorDemo, + title: 'Text Color', + section: 'Text', + }, + 'floating-label': { + component: FloatingLabelDemo, + title: 'Floating Label', + section: 'Text', + }, + 'text-enter': { + component: TextEnterDemo, + title: 'Text Enter', + section: 'Text', + }, + 'text-props': { + component: TextPropsDemo, + title: 'Text Props', + section: 'Text', + }, ...(Platform.OS !== 'web' ? { benchmark: { @@ -139,6 +163,7 @@ const sectionOrder = [ 'Transform', 'Timing', 'Style', + 'Text', 'Loop', 'Advanced', ]; diff --git a/skills/react-native-ease-refactor/SKILL.md b/skills/react-native-ease-refactor/SKILL.md index 51ff2e2..a31a7f4 100644 --- a/skills/react-native-ease-refactor/SKILL.md +++ b/skills/react-native-ease-refactor/SKILL.md @@ -6,7 +6,7 @@ user-invocable: true # react-native-ease refactor -You are a migration assistant that converts `react-native-reanimated` and React Native's built-in `Animated` API code to `react-native-ease` `EaseView` components. +You are a migration assistant that converts `react-native-reanimated` and React Native's built-in `Animated` API code to `react-native-ease` `EaseView` and `EaseText` components. Follow these 6 phases exactly. Do not skip phases or reorder them. @@ -38,7 +38,7 @@ Scan the user's project for animation code: - Pattern: `from ['"]react-native['"]` that also use `Animated` - Pattern: `Animated\.View|Animated\.Text|Animated\.Image|Animated\.Value|Animated\.timing|Animated\.spring` -3. Use Grep to find files already using `react-native-ease` (to avoid re-migrating): +4. Use Grep to find files already using `react-native-ease` (to avoid re-migrating): - Pattern: `from ['"]react-native-ease['"]` @@ -69,10 +69,14 @@ Apply these checks in order. The first match determines the result: 5c. **Uses `withDelay` wrapping `withSequence` or nested `withDelay`?** → NOT migratable — "Complex delay/sequencing not supported" 6. **Uses complex `interpolate()`?** (more than 2 input/output values) → NOT migratable — "Complex interpolation" 7. **Uses `layout={...}` prop?** → NOT migratable — "Layout animation" -8. **Animates unsupported properties?** (anything besides: opacity, translateX, translateY, scale, scaleX, scaleY, rotate, rotateX, rotateY, borderRadius, backgroundColor, borderWidth, borderColor, shadowOpacity, shadowRadius, shadowColor, shadowOffset, elevation) → NOT migratable — "Animates unsupported property: ``" -9. **Uses different transition configs per property?** (e.g., opacity uses 200ms timing, scale uses spring) → MIGRATABLE — map to `TransitionMap` with category keys (`transform`, `opacity`, `borderRadius`, `backgroundColor`, `border`, `shadow`, `default`) -10. **Not driven by state?** (animation triggered by gesture/scroll value, not React state) → NOT migratable — "Not state-driven" -11. **Otherwise** → MIGRATABLE +8. **Is a text component** (`Animated.Text`, or `` with `useAnimatedStyle`)? + - 8a. Animates `fontSize`, `fontWeight`, `letterSpacing`, or `lineHeight`? → NOT migratable — "Text property `` not animatable. Use `scale` + `transformOrigin` as approximation for fontSize." + - 8b. Animates `color` (with or without transforms/opacity)? → MIGRATABLE to **`EaseText`** + - 8c. Animates only transforms/opacity (no color)? → MIGRATABLE to **`EaseText`** (benefits from text prop passthrough even without color) +9. **Animates unsupported properties?** (anything besides: opacity, translateX, translateY, scale, scaleX, scaleY, rotate, rotateX, rotateY, borderRadius, backgroundColor, borderWidth, borderColor, shadowOpacity, shadowRadius, shadowColor, shadowOffset, elevation) → NOT migratable — "Animates unsupported property: ``" +10. **Uses different transition configs per property?** (e.g., opacity uses 200ms timing, scale uses spring) → MIGRATABLE to **`EaseView`** — map to `TransitionMap` with category keys (`transform`, `opacity`, `borderRadius`, `backgroundColor`, `border`, `shadow`, `color`, `default`) +11. **Not driven by state?** (animation triggered by gesture/scroll value, not React state) → NOT migratable — "Not state-driven" +12. **Otherwise** → MIGRATABLE to **`EaseView`** ### Migratable Pattern Mapping @@ -101,6 +105,20 @@ Use this table to convert Reanimated/Animated patterns to EaseView: | `entering={FadeIn.delay(ms)}` / any entering preset with `.delay()` | `initialAnimate` + `animate` + `transition={{ ..., delay: ms }}` | | Different `withTiming`/`withSpring` per property in `useAnimatedStyle` | `transition={{ opacity: { type: 'timing', ... }, transform: { type: 'spring', ... } }}` (per-property map) | + +### Text-Specific Patterns (→ EaseText) + +Use `EaseText` instead of `EaseView` when the source is an `Animated.Text` or a `` with `useAnimatedStyle`. + +| Source Pattern | EaseText Equivalent | +|---|---| +| `Animated.Text` + `Animated.timing` on `color` | `` | +| `useSharedValue` + `useAnimatedStyle` on `` animating `color` + `translateY` + `scale` | `` | +| Text with `color` that doesn't need smooth transition | `` — instant, zero JS cost | +| `useAnimatedStyle` changing `fontSize` | NOT migratable — use `` as visual approximation | + +**Key difference:** `interpolateColor` is JS-interpolated (requestAnimationFrame), not native. Use `style.color` for instant changes with zero overhead. Transforms and opacity remain native via the internal EaseView wrapper. All standard `TextProps` (`numberOfLines`, `ellipsizeMode`, `selectable`, `onPress`, etc.) pass through to the inner ``. + ### Default Value Mapping **CRITICAL: Reanimated and EaseView have different defaults. You MUST explicitly set values to preserve the original animation behavior. Do not rely on EaseView defaults matching Reanimated defaults.** @@ -274,18 +292,23 @@ For each confirmed component, apply the migration: ### Migration Steps (per component) -1. **Add EaseView import** if not already present: +1. **Add import** if not already present: ```typescript + // For view animations: import { EaseView } from 'react-native-ease'; + // For text animations (color, transforms on text): + import { EaseText } from 'react-native-ease'; ``` 1b. **If `usesNativeWind` is true**, check if `import 'react-native-ease/nativewind'` already exists in the project (search all files). If not, add it to the app's root entry point (e.g., `_layout.tsx`, `App.tsx`, or `index.tsx` — whichever is the earliest entry). This only needs to be done once across all migrations, not per component. -2. **Replace the animated view:** +2. **Replace the animated component:** - `Animated.View` → `EaseView` + - `Animated.Text` (or `` with animated color/transforms) → `EaseText` - `` → `` + - `` → `` 3. **Convert animation hooks to props:** @@ -430,16 +453,49 @@ transition={{ type: 'none' }} - `animate` — target values for animated properties - `initialAnimate` — starting values (animates to `animate` on mount) -- `transition` — animation config: a single `SingleTransition` (timing/spring/none) OR a `TransitionMap` with category keys (`default`, `transform`, `opacity`, `borderRadius`, `backgroundColor`, `border`, `shadow`) +- `transition` — animation config: a single `SingleTransition` (timing/spring/none) OR a `TransitionMap` with category keys (`default`, `transform`, `opacity`, `borderRadius`, `backgroundColor`, `border`, `shadow`, `color`) - `onTransitionEnd` — callback with `{ finished: boolean }` - `transformOrigin` — pivot point as `{ x: 0-1, y: 0-1 }`, default center - `useHardwareLayer` — Android GPU optimization (boolean, default false) - `className` — NativeWind / Tailwind CSS class string (requires NativeWind in the project) +### EaseText API Reference + +`EaseText` is a JS-only component that composes `EaseView` (native transforms/opacity) with `` (text rendering + JS color interpolation). + +**Additional Animatable Properties (text only):** + +| Property | Type | Default | Notes | +| -------- | ------------ | ----------- | -------------------------------------------------- | +| `color` | `ColorValue` | from style | JS-interpolated via requestAnimationFrame | + +**NOT Animatable on text:** `fontSize`, `fontWeight`, `fontFamily`, `lineHeight`, `textAlign`, `letterSpacing`, `backgroundColor`. Use `scale` + `transformOrigin` as approximation for `fontSize`. + +**Props:** Same as EaseView (`animate`, `initialAnimate`, `transition`, `onTransitionEnd`, `transformOrigin`, `useHardwareLayer`) plus all standard `TextProps` (`numberOfLines`, `ellipsizeMode`, `selectable`, `onPress`, `style`, etc.). + +**Per-property transition for color:** +```typescript +transition={{ + color: { type: 'timing', duration: 150 }, + transform: { type: 'spring', damping: 12, stiffness: 250 }, +}} +``` + +**TextAnimateProps:** `Omit` — same transform/opacity props as AnimateProps, view-only properties excluded. Color is not in `animate` — it's a separate prop. + +**Color:** +- `style.color` — instant, zero JS cost (recommended for most cases) +- `interpolateColor` prop — smooth JS interpolation via requestAnimationFrame, follows `color` key in transition or falls back to `default` +- `initialInterpolateColor` — starting color for mount animations (used with `interpolateColor`) + +**Performance:** Transforms/opacity are native (60fps, off JS thread). `interpolateColor` runs on JS thread — smooth under normal conditions, may stutter under heavy JS load. For typical 150–300ms transitions, imperceptible. Use `style.color` to avoid any JS cost. + ### Important Constraints - **Loop requires timing** (not spring) and `initialAnimate` must define the start value -- **Per-property transitions supported** — pass a `TransitionMap` with category keys (`default`, `transform`, `opacity`, `borderRadius`, `backgroundColor`, `border`, `shadow`) to use different configs per property group +- **Per-property transitions supported** — pass a `TransitionMap` with category keys (`default`, `transform`, `opacity`, `borderRadius`, `backgroundColor`, `border`, `shadow`, `color`) to use different configs per property group - **No animation sequencing** — no equivalent to `withSequence`. Simple `withDelay` IS supported via the `delay` transition prop -- **No gesture/scroll-driven animations** — EaseView is state-driven only +- **No gesture/scroll-driven animations** — EaseView/EaseText are state-driven only - **Style/animate conflict** — if a property appears in both `style` and `animate`, the animated value wins +- **EaseText wraps EaseView + Text** — layout behaves like a View containing a Text, not a bare Text. Use a wrapper View with `position: 'absolute'` for floating label patterns +- **EaseText color is JS-interpolated** — not native. Spring transitions on color are approximated as 500ms easeOut diff --git a/src/EaseText.tsx b/src/EaseText.tsx new file mode 100644 index 0000000..af604ca --- /dev/null +++ b/src/EaseText.tsx @@ -0,0 +1,87 @@ +import { Text, type ColorValue, type TextProps } from 'react-native'; +import { EaseView } from './EaseView'; +import { useColorTransition } from './useColorTransition'; +import type { + AnimateProps, + TextAnimateProps, + Transition, + TransitionEndEvent, + TransformOrigin, + TransformPerspective, +} from './types'; + +export type EaseTextProps = TextProps & { + /** Target values for animated properties (transforms, opacity). Animated natively via EaseView. */ + animate?: TextAnimateProps; + /** Starting values for enter animations. Animates to `animate` on mount. */ + initialAnimate?: TextAnimateProps; + /** Animation configuration (timing or spring). */ + transition?: Transition; + /** Called when all animations complete. Reports whether they finished naturally or were interrupted. */ + onTransitionEnd?: (event: TransitionEndEvent) => void; + /** + * Enable Android hardware layer during animations. + * @default false + */ + useHardwareLayer?: boolean; + /** Pivot point for scale and rotation as 0–1 fractions. @default { x: 0.5, y: 0.5 } (center) */ + transformOrigin?: TransformOrigin; + /** + * Distance of the camera from the z=0 plane for 3D transforms (rotateX, rotateY). + * @default 1280 + */ + transformPerspective?: TransformPerspective; + /** + * Smoothly interpolates the text color using JS (requestAnimationFrame). + * Follows the `color` key in `transition`, or falls back to `default`. + * For instant color changes with zero JS cost, use `style.color` instead. + */ + interpolateColor?: ColorValue; + /** + * Initial color for mount animation. Interpolates from this color to `interpolateColor` on mount. + * Only used when `interpolateColor` is also set. + */ + initialInterpolateColor?: ColorValue; +}; + +export function EaseText({ + animate, + initialAnimate, + transition, + onTransitionEnd, + useHardwareLayer, + transformOrigin, + transformPerspective, + interpolateColor, + initialInterpolateColor, + style, + children, + ...textProps +}: EaseTextProps) { + // Interpolate color with requestAnimationFrame, respecting transition config + const interpolatedColor = useColorTransition( + interpolateColor, + transition, + initialInterpolateColor, + ); + + // Merge interpolated color into text style (overrides style.color if set) + const textStyle = + interpolatedColor != null ? [style, { color: interpolatedColor }] : style; + + return ( + + + {children} + + + ); +} diff --git a/src/__tests__/EaseText.test.tsx b/src/__tests__/EaseText.test.tsx new file mode 100644 index 0000000..c25e15e --- /dev/null +++ b/src/__tests__/EaseText.test.tsx @@ -0,0 +1,97 @@ +import { render, screen } from '@testing-library/react-native'; +import { EaseText } from '../EaseText'; + +function getTextColor(text: ReturnType) { + const flatStyle = Array.isArray(text.props.style) + ? Object.assign({}, ...text.props.style.filter(Boolean)) + : text.props.style; + return flatStyle?.color; +} + +describe('EaseText', () => { + describe('interpolateColor', () => { + it('applies interpolateColor to Text style', () => { + render(Hello); + const text = screen.getByText('Hello'); + expect(getTextColor(text)).toBe('#ff0000'); + }); + + it('merges interpolateColor with existing style', () => { + render( + + Hello + , + ); + const text = screen.getByText('Hello'); + const flatStyle = Array.isArray(text.props.style) + ? Object.assign({}, ...text.props.style.filter(Boolean)) + : text.props.style; + expect(getTextColor(text)).toBe('#ff0000'); + expect(flatStyle.fontSize).toBe(16); + expect(flatStyle.fontWeight).toBe('600'); + }); + + it('does not apply the target color immediately when color is omitted from a transition map', () => { + render( + + Hello + , + ); + + const text = screen.getByText('Hello'); + expect(getTextColor(text)).toBe('#000000'); + }); + + it('does not override style when interpolateColor is not set', () => { + render( + Hello, + ); + const text = screen.getByText('Hello'); + expect(text.props.style).toEqual({ fontSize: 16, color: '#000' }); + }); + }); + + describe('text props passthrough', () => { + it('passes numberOfLines and ellipsizeMode to Text', () => { + render( + + Hello + , + ); + const text = screen.getByText('Hello'); + expect(text.props.numberOfLines).toBe(1); + expect(text.props.ellipsizeMode).toBe('tail'); + }); + + it('passes style to Text', () => { + render( + Hello, + ); + const text = screen.getByText('Hello'); + expect(text.props.style).toEqual({ fontSize: 16, fontWeight: '600' }); + }); + + it('renders children as text', () => { + render(Hello World); + expect(screen.getByText('Hello World')).toBeTruthy(); + }); + }); + + describe('EaseView wrapper', () => { + it('renders text inside a view hierarchy', () => { + render( + Hello, + ); + const text = screen.getByText('Hello'); + expect(text).toBeTruthy(); + expect(text.parent).toBeTruthy(); + }); + }); +}); diff --git a/src/index.tsx b/src/index.tsx index 612c5da..756d467 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,7 +1,10 @@ export { EaseView } from './EaseView'; export type { EaseViewProps } from './EaseView'; +export { EaseText } from './EaseText'; +export type { EaseTextProps } from './EaseText'; export type { AnimateProps, + TextAnimateProps, CubicBezier, Transition, SingleTransition, diff --git a/src/types.ts b/src/types.ts index b6dc605..366834a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -64,6 +64,8 @@ export type TransitionMap = { border?: SingleTransition; /** Config for shadow properties (shadowOpacity, shadowRadius, shadowColor, shadowOffset) and elevation. */ shadow?: SingleTransition; + /** Config for text color (EaseText only). */ + color?: SingleTransition; }; /** Animation transition configuration — either a single config or a per-property map. */ @@ -133,3 +135,17 @@ export type AnimateProps = { /** Android elevation for material shadow. @default 0 */ elevation?: number; }; + +/** Animatable text properties. Excludes view-only props (border, background, shadow, elevation). */ +export type TextAnimateProps = Omit< + AnimateProps, + | 'borderRadius' + | 'backgroundColor' + | 'borderWidth' + | 'borderColor' + | 'shadowOpacity' + | 'shadowRadius' + | 'shadowColor' + | 'shadowOffset' + | 'elevation' +>; diff --git a/src/useColorTransition.ts b/src/useColorTransition.ts new file mode 100644 index 0000000..00d62a5 --- /dev/null +++ b/src/useColorTransition.ts @@ -0,0 +1,202 @@ +import { useEffect, useRef, useState } from 'react'; +import { processColor } from 'react-native'; +import type { SingleTransition, Transition } from './types'; + +type RGBA = [number, number, number, number]; + +/** Parse a React Native color to RGBA components (0–255, alpha 0–1). */ +function parseColor(color: unknown): RGBA | null { + const processed = processColor(color as string); + if (processed == null || typeof processed !== 'number') return null; + /* eslint-disable no-bitwise */ + const a = ((processed >>> 24) & 0xff) / 255; + const r = (processed >> 16) & 0xff; + const g = (processed >> 8) & 0xff; + const b = processed & 0xff; + /* eslint-enable no-bitwise */ + return [r, g, b, a]; +} + +/** Convert RGBA to hex string. */ +function rgbaToHex(r: number, g: number, b: number, a: number): string { + const ri = Math.round(r); + const gi = Math.round(g); + const bi = Math.round(b); + const hex = (v: number) => v.toString(16).padStart(2, '0'); + if (a < 1) { + return `#${hex(ri)}${hex(gi)}${hex(bi)}${hex(Math.round(a * 255))}`; + } + return `#${hex(ri)}${hex(gi)}${hex(bi)}`; +} + +/** Built-in easing functions. */ +const easings: Record number> = { + linear: (t) => t, + easeIn: (t) => t * t * t, + easeOut: (t) => 1 - (1 - t) ** 3, + easeInOut: (t) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2), +}; + +/** Resolve the color transition config. Follows the same logic as EaseView: + * use `color`, else `default`, else implicit defaults (300ms easeInOut), including + * when `transition` is omitted or a category map has no color-specific entry. + * Instant only when the resolved single transition has `type: 'none'`. */ +function resolveColorConfig(transition?: Transition): { + duration: number; + easing: (t: number) => number; + delay: number; +} { + const defaults = { duration: 300, easing: easings.easeInOut!, delay: 0 }; + + if (transition == null) return defaults; + + let config: SingleTransition | undefined; + if ('type' in transition) { + config = transition; + } else { + config = transition.color ?? transition.default; + } + + if (!config) { + return defaults; + } + + if (config.type === 'none') { + return { duration: 0, easing: easings.linear!, delay: 0 }; + } + + if (config.type === 'spring') { + // Approximate spring as 500ms easeOut — real spring physics for a color + // interpolation on the JS thread isn't worth the complexity. + return { + duration: 500, + easing: easings.easeOut!, + delay: config.delay ?? 0, + }; + } + + return { + duration: config.duration ?? 300, + easing: + typeof config.easing === 'string' + ? easings[config.easing] ?? easings.easeInOut! + : easings.easeInOut!, + delay: config.delay ?? 0, + }; +} + +/** + * Interpolates a color value over time using requestAnimationFrame. + * Respects the `color` or `default` key from the transition config. + * + * On first render, `initialColor` is displayed immediately and will animate + * to `targetColor` if it differs. Subsequent changes to `targetColor` animate + * from the current displayed color. `transition` updates take effect on the + * next color change. + */ +export function useColorTransition( + targetColor: unknown, + transition?: Transition, + initialColor?: unknown, +): string | undefined { + const { duration, easing, delay } = resolveColorConfig(transition); + + const initialColorRef = useRef(initialColor); + const currentRGBA = useRef(null); + const animRef = useRef<{ rafId: number; id: number } | null>(null); + const batchId = useRef(0); + const isFirstRender = useRef(true); + + const [displayColor, setDisplayColor] = useState(() => { + const startColor = initialColorRef.current ?? targetColor; + if (startColor == null) return undefined; + const parsed = parseColor(startColor); + if (!parsed) return undefined; + currentRGBA.current = parsed; + return rgbaToHex(...parsed); + }); + + useEffect(() => { + if (targetColor == null) { + currentRGBA.current = null; + setDisplayColor(undefined); + return; + } + + const toRGBA = parseColor(targetColor); + if (!toRGBA) return; + + if (isFirstRender.current) { + isFirstRender.current = false; + const fromRGBA = currentRGBA.current; + + if ( + !fromRGBA || + (fromRGBA[0] === toRGBA[0] && + fromRGBA[1] === toRGBA[1] && + fromRGBA[2] === toRGBA[2] && + fromRGBA[3] === toRGBA[3]) + ) { + currentRGBA.current = toRGBA; + setDisplayColor(rgbaToHex(...toRGBA)); + return; + } + } + + const fromRGBA = currentRGBA.current ?? toRGBA; + + if (animRef.current) { + cancelAnimationFrame(animRef.current.rafId); + animRef.current = null; + } + + if (duration === 0) { + currentRGBA.current = toRGBA; + setDisplayColor(rgbaToHex(...toRGBA)); + return; + } + + batchId.current++; + const thisBatch = batchId.current; + const startTime = performance.now() + delay; + + const tick = (now: number) => { + if (batchId.current !== thisBatch) return; + + const elapsed = now - startTime; + if (elapsed < 0) { + animRef.current = { rafId: requestAnimationFrame(tick), id: thisBatch }; + return; + } + + const progress = Math.min(elapsed / duration, 1); + const t = easing(progress); + + const r = fromRGBA[0] + (toRGBA[0] - fromRGBA[0]) * t; + const g = fromRGBA[1] + (toRGBA[1] - fromRGBA[1]) * t; + const b = fromRGBA[2] + (toRGBA[2] - fromRGBA[2]) * t; + const a = fromRGBA[3] + (toRGBA[3] - fromRGBA[3]) * t; + + const current: RGBA = [r, g, b, a]; + currentRGBA.current = current; + setDisplayColor(rgbaToHex(...current)); + + if (progress < 1) { + animRef.current = { rafId: requestAnimationFrame(tick), id: thisBatch }; + } else { + animRef.current = null; + } + }; + + animRef.current = { rafId: requestAnimationFrame(tick), id: thisBatch }; + + return () => { + if (animRef.current?.id === thisBatch) { + cancelAnimationFrame(animRef.current.rafId); + animRef.current = null; + } + }; + }, [targetColor, duration, easing, delay]); + + return displayColor; +}