Skip to content

Commit cb430ba

Browse files
committed
feat: add EaseText component for animated text with color interpolation
EaseText wraps EaseView + Text to provide native transform/opacity animations on text with optional JS-side color interpolation. - `animate` for transforms/opacity (native 60fps via EaseView) - `interpolateColor` for smooth color transitions (JS rAF, opt-in) - `style.color` for instant color changes (zero JS cost) - All standard TextProps passthrough (numberOfLines, onPress, etc.) - TextAnimateProps derived from AnimateProps (minus borderRadius/backgroundColor) - useColorTransition hook with easing, delay, and spring approximation - 4 example demos: Text Color, Floating Label, Text Enter, Text Props - Updated AGENTS.md and migration skill with EaseText documentation
1 parent c3231e8 commit cb430ba

12 files changed

Lines changed: 791 additions & 15 deletions

File tree

AGENTS.md

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Overview
44

5-
`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.
5+
`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.
66

77
**Fabric (new architecture) only.** Does not support the old architecture.
88

@@ -11,6 +11,8 @@
1111
```
1212
src/ # TypeScript source (library)
1313
EaseView.tsx # React component — flattens props to native
14+
EaseText.tsx # Text component — EaseView wrapper + Text with color interpolation
15+
useColorTransition.ts # JS-side color interpolation hook (requestAnimationFrame)
1416
EaseViewNativeComponent.ts # Codegen spec — defines native props/events
1517
types.ts # Public TypeScript types
1618
index.tsx # Public exports
@@ -26,8 +28,7 @@ android/src/main/java/com/ease/
2628
EasePackage.kt # Package registration
2729
2830
example/ # Demo app (separate workspace)
29-
src/App.tsx # Main demo screen with animation examples
30-
src/ComparisonScreen.tsx # Comparison with Reanimated
31+
src/demos/ # Demo screens (one per feature)
3132
src/components/ # Shared demo components (Section, Button, TabBar)
3233
```
3334

@@ -43,6 +44,24 @@ transition={{ type: 'spring', damping: 10 }} → transitionType="spring", tran
4344

4445
**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.
4546

47+
## EaseText Architecture
48+
49+
`EaseText` is a **JS-only component** — no native code. It composes `EaseView` (for native transforms/opacity) with a standard `<Text>` (for text rendering).
50+
51+
```
52+
<EaseText interpolateColor="#000" animate={{ translateY, scale }} />
53+
→ <EaseView animate={{ translateY, scale }}> ← native animations
54+
<Text style={{ color: interpolated }}> ← JS color interpolation
55+
```
56+
57+
**Color:** Two modes:
58+
- `style.color` — instant change, zero JS cost (same as `<Text>`)
59+
- `interpolateColor` prop — smooth JS interpolation via `requestAnimationFrame`, follows the `color` key in `transition` (or falls back to `default`)
60+
61+
**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.
62+
63+
**TextAnimateProps:** `Omit<AnimateProps, 'borderRadius' | 'backgroundColor'>` — same transform/opacity props as EaseView. Color is handled separately via `interpolateColor`.
64+
4665
## Adding a New Animatable Property
4766

4867
1. Add to `AnimateProps` in `src/types.ts`
@@ -111,3 +130,5 @@ Use conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, etc.
111130
- **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`.
112131
- **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`).
113132
- **Loop only works with timing animations**, not springs. Loop requires `initialAnimate` to define the start value.
133+
- **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.
134+
- **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).
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { useState } from 'react';
2+
import { View, TextInput, StyleSheet } from 'react-native';
3+
import { EaseText } from 'react-native-ease';
4+
5+
import { Section } from '../components/Section';
6+
7+
function FloatingInput({ label }: { label: string }) {
8+
const [focused, setFocused] = useState(false);
9+
const [value, setValue] = useState('');
10+
const compact = focused || value.length > 0;
11+
12+
return (
13+
<View style={styles.inputContainer}>
14+
<View style={styles.labelWrapper}>
15+
<EaseText
16+
interpolateColor={compact ? '#e94560' : '#8892b0'}
17+
animate={{
18+
translateY: compact ? -24 : 0,
19+
scale: compact ? 0.75 : 1,
20+
}}
21+
transition={{
22+
color: { type: 'timing', duration: 150 },
23+
transform: { type: 'spring', damping: 12, stiffness: 250 },
24+
}}
25+
transformOrigin={{ x: 0, y: 0.5 }}
26+
style={styles.floatingLabel}
27+
>
28+
{label}
29+
</EaseText>
30+
</View>
31+
<TextInput
32+
style={styles.textInput}
33+
value={value}
34+
onChangeText={setValue}
35+
onFocus={() => setFocused(true)}
36+
onBlur={() => setFocused(false)}
37+
placeholderTextColor="transparent"
38+
/>
39+
</View>
40+
);
41+
}
42+
43+
export function FloatingLabelDemo() {
44+
return (
45+
<Section title="Floating Label Input">
46+
<FloatingInput label="Email" />
47+
<FloatingInput label="Password" />
48+
<FloatingInput label="Full Name" />
49+
</Section>
50+
);
51+
}
52+
53+
const styles = StyleSheet.create({
54+
inputContainer: {
55+
position: 'relative',
56+
marginBottom: 20,
57+
borderBottomWidth: 1,
58+
borderBottomColor: '#2a2a4a',
59+
paddingTop: 22,
60+
},
61+
labelWrapper: {
62+
position: 'absolute',
63+
left: 0,
64+
top: 22,
65+
},
66+
floatingLabel: {
67+
fontSize: 15,
68+
fontWeight: '400',
69+
},
70+
textInput: {
71+
fontSize: 15,
72+
color: '#fff',
73+
paddingVertical: 8,
74+
paddingHorizontal: 0,
75+
},
76+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { useState } from 'react';
2+
import { StyleSheet } from 'react-native';
3+
import { EaseText } from 'react-native-ease';
4+
5+
import { Section } from '../components/Section';
6+
import { Button } from '../components/Button';
7+
8+
export function TextColorDemo() {
9+
const [focused, setFocused] = useState(false);
10+
11+
return (
12+
<Section title="Text Color">
13+
<EaseText
14+
interpolateColor={focused ? '#e94560' : '#8892b0'}
15+
transition={{ type: 'timing', duration: 300 }}
16+
style={styles.label}
17+
>
18+
Smooth color transition (300ms)
19+
</EaseText>
20+
21+
<EaseText
22+
interpolateColor={focused ? '#e94560' : '#8892b0'}
23+
animate={{ scale: focused ? 1.05 : 1 }}
24+
transition={{ type: 'spring', damping: 15, stiffness: 120 }}
25+
style={styles.heading}
26+
>
27+
Color + Scale
28+
</EaseText>
29+
30+
<EaseText
31+
interpolateColor={focused ? '#e94560' : '#8892b0'}
32+
animate={{
33+
opacity: focused ? 1 : 0.5,
34+
translateX: focused ? 10 : 0,
35+
}}
36+
transition={{ type: 'timing', duration: 400 }}
37+
style={styles.subtitle}
38+
>
39+
Color + Opacity + TranslateX
40+
</EaseText>
41+
42+
<EaseText
43+
style={[styles.instant, { color: focused ? '#e94560' : '#8892b0' }]}
44+
>
45+
Instant color (style.color, zero JS cost)
46+
</EaseText>
47+
48+
<Button
49+
label={focused ? 'Blur' : 'Focus'}
50+
onPress={() => setFocused((v) => !v)}
51+
/>
52+
</Section>
53+
);
54+
}
55+
56+
const styles = StyleSheet.create({
57+
label: {
58+
fontSize: 16,
59+
marginBottom: 16,
60+
},
61+
heading: {
62+
fontSize: 28,
63+
fontWeight: '700',
64+
marginBottom: 16,
65+
},
66+
subtitle: {
67+
fontSize: 14,
68+
marginBottom: 16,
69+
},
70+
instant: {
71+
fontSize: 14,
72+
marginBottom: 24,
73+
},
74+
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { useState } from 'react';
2+
import { View, StyleSheet } from 'react-native';
3+
import { EaseText } from 'react-native-ease';
4+
5+
import { Section } from '../components/Section';
6+
import { Button } from '../components/Button';
7+
8+
export function TextEnterDemo() {
9+
const [key, setKey] = useState(0);
10+
11+
return (
12+
<Section title="Text Enter Animation">
13+
<View key={key} style={styles.textContainer}>
14+
<EaseText
15+
initialInterpolateColor="#1a1a2e"
16+
interpolateColor="#e94560"
17+
initialAnimate={{ opacity: 0, translateY: 20 }}
18+
animate={{ opacity: 1, translateY: 0 }}
19+
transition={{ type: 'spring', damping: 14, stiffness: 100 }}
20+
style={styles.title}
21+
>
22+
Welcome Back
23+
</EaseText>
24+
25+
<EaseText
26+
initialInterpolateColor="#1a1a2e"
27+
interpolateColor="#8892b0"
28+
initialAnimate={{ opacity: 0, translateY: 12 }}
29+
animate={{ opacity: 1, translateY: 0 }}
30+
transition={{ type: 'timing', duration: 400, delay: 150 }}
31+
style={styles.subtitle}
32+
>
33+
Your dashboard is ready
34+
</EaseText>
35+
36+
<EaseText
37+
initialAnimate={{ opacity: 0, scale: 0.8 }}
38+
animate={{ opacity: 1, scale: 1 }}
39+
transition={{
40+
type: 'spring',
41+
damping: 10,
42+
stiffness: 150,
43+
delay: 300,
44+
}}
45+
transformOrigin={{ x: 0.5, y: 0.5 }}
46+
style={styles.emoji}
47+
>
48+
🚀
49+
</EaseText>
50+
</View>
51+
52+
<Button label="Replay" onPress={() => setKey((k) => k + 1)} />
53+
</Section>
54+
);
55+
}
56+
57+
const styles = StyleSheet.create({
58+
textContainer: {
59+
alignItems: 'center',
60+
marginBottom: 24,
61+
paddingVertical: 20,
62+
},
63+
title: {
64+
fontSize: 28,
65+
fontWeight: '700',
66+
marginBottom: 8,
67+
},
68+
subtitle: {
69+
fontSize: 16,
70+
marginBottom: 16,
71+
},
72+
emoji: {
73+
fontSize: 48,
74+
},
75+
});
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { useState } from 'react';
2+
import { Alert, StyleSheet } from 'react-native';
3+
import { EaseText } from 'react-native-ease';
4+
5+
import { Section } from '../components/Section';
6+
import { Button } from '../components/Button';
7+
8+
export function TextPropsDemo() {
9+
const [active, setActive] = useState(false);
10+
11+
return (
12+
<Section title="Text Props">
13+
<EaseText
14+
interpolateColor={active ? '#e94560' : '#8892b0'}
15+
transition={{ type: 'timing', duration: 300 }}
16+
numberOfLines={1}
17+
ellipsizeMode="tail"
18+
style={styles.truncated}
19+
>
20+
This is a very long text that will be truncated to a single line with
21+
ellipsis because numberOfLines is set to 1
22+
</EaseText>
23+
24+
<EaseText
25+
interpolateColor={active ? '#e94560' : '#ccd6f6'}
26+
animate={{ opacity: active ? 1 : 0.7 }}
27+
transition={{ type: 'timing', duration: 300 }}
28+
selectable
29+
style={styles.selectable}
30+
>
31+
This text is selectable — long press to copy
32+
</EaseText>
33+
34+
<EaseText
35+
style={[styles.pressable, { color: active ? '#e94560' : '#8892b0' }]}
36+
onPress={() => Alert.alert('Pressed!', 'onPress works on EaseText')}
37+
>
38+
Tap me — onPress + instant color (style)
39+
</EaseText>
40+
41+
<EaseText
42+
interpolateColor={active ? '#e94560' : '#8892b0'}
43+
animate={{ scale: active ? 1 : 0.95 }}
44+
transition={{ type: 'spring', damping: 15, stiffness: 150 }}
45+
numberOfLines={2}
46+
style={styles.multiline}
47+
>
48+
Two lines max with spring scale. Lorem ipsum dolor sit amet, consectetur
49+
adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore
50+
magna aliqua.
51+
</EaseText>
52+
53+
<Button
54+
label={active ? 'Reset' : 'Activate'}
55+
onPress={() => setActive((v) => !v)}
56+
/>
57+
</Section>
58+
);
59+
}
60+
61+
const styles = StyleSheet.create({
62+
truncated: {
63+
fontSize: 14,
64+
marginBottom: 16,
65+
},
66+
selectable: {
67+
fontSize: 14,
68+
marginBottom: 16,
69+
},
70+
pressable: {
71+
fontSize: 14,
72+
marginBottom: 16,
73+
textDecorationLine: 'underline',
74+
},
75+
multiline: {
76+
fontSize: 14,
77+
lineHeight: 20,
78+
marginBottom: 24,
79+
},
80+
});

0 commit comments

Comments
 (0)