react-native-ease 0.1.0-alpha.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "react-native-ease-plugins",
3
+ "owner": {
4
+ "name": "AppAndFlow",
5
+ "email": "devops@appandflow.com"
6
+ },
7
+ "metadata": {
8
+ "description": "Claude Code skills for react-native-ease — migrate Reanimated/Animated code to react-native-ease"
9
+ },
10
+ "plugins": [
11
+ {
12
+ "name": "react-native-ease",
13
+ "source": "./",
14
+ "description": "Scan for Animated/Reanimated code and migrate to react-native-ease",
15
+ "version": "0.2.0",
16
+ "author": {
17
+ "name": "AppAndFlow"
18
+ }
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "react-native-ease",
3
+ "description": "Declarative native animations for React Native — migration tools",
4
+ "version": "0.2.0"
5
+ }
package/README.md CHANGED
@@ -1,32 +1,17 @@
1
- # 🍃 react-native-ease
1
+ <img width="100%" height="auto" alt="react-native-ease by App & Flow" src="https://github.com/user-attachments/assets/8006ed51-d373-4c97-9e80-9937eb9a569e" />
2
2
 
3
3
  Lightweight declarative animations powered by platform APIs. Uses Core Animation on iOS and Animator on Android — zero JS overhead.
4
4
 
5
- ## Goals
5
+ ## About
6
+ App & Flow is a Montreal-based React Native engineering and consulting studio. We partner with the world’s top companies and are recommended by [Expo](https://expo.dev/consultants). Need a hand? Let’s build together. team@appandflow.com
6
7
 
7
- - **Fast** — Animations run entirely on native platform APIs (CAAnimation, ObjectAnimator/SpringAnimation). No JS animation loop, no worklets, no shared values.
8
- - **Simple** — CSS-transition-like API. Set target values, get smooth animations. One component, a few props.
9
- - **Lightweight** — Minimal native code, no C++ runtime, no custom animation engine. Just a thin declarative wrapper around what the OS already provides.
10
- - **Interruptible** — Changing values mid-animation smoothly redirects to the new target. No jumps.
8
+ ## Demo
11
9
 
12
- ## Non-Goals
10
+ ![ease-demo](https://github.com/user-attachments/assets/09658b07-803e-4b7e-a23c-831a6c63df84)
13
11
 
14
- - **Complex gesture-driven animations** — If you need pan/pinch-driven animations, animation worklets, or shared values across components, use [react-native-reanimated](https://github.com/software-mansion/react-native-reanimated).
15
- - **Layout animations** — Animating width/height/layout changes is not supported.
16
- - **Shared element transitions** — Use Reanimated or React Navigation's shared element transitions.
17
- - **Old architecture** — Fabric (new architecture) only.
18
-
19
- ## When to use this vs Reanimated
20
-
21
- | Use react-native-ease | Use Reanimated |
22
- |---|---|
23
- | Fade in a view | Gesture-driven animations |
24
- | Slide/translate on state change | Complex interpolations |
25
- | Scale/rotate on press | Shared values across components |
26
- | Simple enter animations | Layout animations |
27
- | You want zero config | You need animation worklets |
12
+ ## Getting started
28
13
 
29
- ## Installation
14
+ ### Installation
30
15
 
31
16
  ```bash
32
17
  npm install react-native-ease
@@ -34,7 +19,25 @@ npm install react-native-ease
34
19
  yarn add react-native-ease
35
20
  ```
36
21
 
37
- ## Quick Start
22
+ ### Migration Skill
23
+
24
+ If you're already using `react-native-reanimated` or React Native's `Animated` API, this project includes an [Agent Skill](https://agentskills.io) that scans your codebase for animations that can be replaced with `react-native-ease` and migrates them automatically.
25
+
26
+ ```bash
27
+ npx skills add appandflow/react-native-ease
28
+ ```
29
+
30
+ Then invoke the skill in your agent (e.g., `/react-native-ease-refactor` in Claude Code).
31
+
32
+ The skill will:
33
+
34
+ 1. Scan your project for Reanimated/Animated code
35
+ 2. Classify which animations can be migrated (and which can't, with reasons)
36
+ 3. Show a migration report with before/after details
37
+ 4. Let you select which components to migrate
38
+ 5. Apply the changes, preserving all non-animation logic
39
+
40
+ ### Example
38
41
 
39
42
  ```tsx
40
43
  import { EaseView } from 'react-native-ease';
@@ -54,6 +57,32 @@ function FadeCard({ visible, children }) {
54
57
 
55
58
  `EaseView` works like a regular `View` — it accepts children, styles, and all standard view props. When values in `animate` change, it smoothly transitions to the new values using native platform animations.
56
59
 
60
+ ## Why
61
+
62
+ ### Goals
63
+
64
+ - **Fast** — Animations run entirely on native platform APIs (CAAnimation, ObjectAnimator/SpringAnimation). No JS animation loop, no worklets, no shared values.
65
+ - **Simple** — CSS-transition-like API. Set target values, get smooth animations. One component, a few props.
66
+ - **Lightweight** — Minimal native code, no C++ runtime, no custom animation engine. Just a thin declarative wrapper around what the OS already provides.
67
+ - **Interruptible** — Changing values mid-animation smoothly redirects to the new target. No jumps.
68
+
69
+ ### Non-Goals
70
+
71
+ - **Complex gesture-driven animations** — If you need pan/pinch-driven animations, animation worklets, or shared values across components, use [react-native-reanimated](https://github.com/software-mansion/react-native-reanimated).
72
+ - **Layout animations** — Animating width/height/layout changes is not supported.
73
+ - **Shared element transitions** — Use Reanimated or React Navigation's shared element transitions.
74
+ - **Old architecture** — Fabric (new architecture) only.
75
+
76
+ ### When to use this vs Reanimated
77
+
78
+ | Use case | Ease | Reanimated |
79
+ | -------------------------------------- | ---- | ---------- |
80
+ | Fade/slide/scale on state change | ✅ | |
81
+ | Enter/exit animations | ✅ | |
82
+ | Gesture-driven animations (pan, pinch) | | ✅ |
83
+ | Layout animations (width, height) | | ✅ |
84
+ | Complex interpolations & chaining | | ✅ |
85
+
57
86
  ## Guide
58
87
 
59
88
  ### Timing Animations
@@ -67,11 +96,11 @@ Timing animations transition from one value to another over a fixed duration wit
67
96
  />
68
97
  ```
69
98
 
70
- | Parameter | Type | Default | Description |
71
- |---|---|---|---|
72
- | `duration` | `number` | `300` | Duration in milliseconds |
73
- | `easing` | `EasingType` | `'easeInOut'` | Easing curve (preset name or `[x1, y1, x2, y2]` cubic bezier) |
74
- | `loop` | `string` | — | `'repeat'` restarts from the beginning, `'reverse'` alternates direction |
99
+ | Parameter | Type | Default | Description |
100
+ | ---------- | ------------ | ------------- | ------------------------------------------------------------------------ |
101
+ | `duration` | `number` | `300` | Duration in milliseconds |
102
+ | `easing` | `EasingType` | `'easeInOut'` | Easing curve (preset name or `[x1, y1, x2, y2]` cubic bezier) |
103
+ | `loop` | `string` | — | `'repeat'` restarts from the beginning, `'reverse'` alternates direction |
75
104
 
76
105
  Available easing curves:
77
106
 
@@ -112,11 +141,11 @@ Spring animations use a physics-based model for natural-feeling motion. Great fo
112
141
  />
113
142
  ```
114
143
 
115
- | Parameter | Type | Default | Description |
116
- |---|---|---|---|
117
- | `damping` | `number` | `15` | Friction — higher values reduce oscillation |
118
- | `stiffness` | `number` | `120` | Spring constant — higher values mean faster animation |
119
- | `mass` | `number` | `1` | Mass of the object — higher values mean slower, more momentum |
144
+ | Parameter | Type | Default | Description |
145
+ | ----------- | -------- | ------- | ------------------------------------------------------------- |
146
+ | `damping` | `number` | `15` | Friction — higher values reduce oscillation |
147
+ | `stiffness` | `number` | `120` | Spring constant — higher values mean faster animation |
148
+ | `mass` | `number` | `1` | Mass of the object — higher values mean slower, more momentum |
120
149
 
121
150
  Spring presets for common feels:
122
151
 
@@ -164,6 +193,26 @@ Use `{ type: 'none' }` to apply values immediately without animation. Useful for
164
193
 
165
194
  When `borderRadius` is in `animate`, any `borderRadius` in `style` is automatically stripped to avoid conflicts.
166
195
 
196
+ ### Background Color
197
+
198
+ `backgroundColor` can be animated using any React Native color value. Colors are converted to native ARGB integers via `processColor()`.
199
+
200
+ ```tsx
201
+ <EaseView
202
+ animate={{ backgroundColor: isActive ? '#3B82F6' : '#E5E7EB' }}
203
+ transition={{ type: 'timing', duration: 300 }}
204
+ style={styles.card}
205
+ >
206
+ <Text>Tap to change color</Text>
207
+ </EaseView>
208
+ ```
209
+
210
+ On Android, background color uses `ValueAnimator.ofArgb()` (timing only — spring is not supported for colors). On iOS, it uses `CAAnimation` on the `backgroundColor` layer key path and supports both timing and spring transitions.
211
+
212
+ > **Note:** On Android, background color animation uses `ValueAnimator.ofArgb()` which only supports timing transitions. Spring transitions for `backgroundColor` are not supported on Android and will fall back to timing with the default duration. On iOS, both timing and spring transitions work for background color.
213
+
214
+ When `backgroundColor` is in `animate`, any `backgroundColor` in `style` is automatically stripped to avoid conflicts.
215
+
167
216
  ### Animatable Properties
168
217
 
169
218
  All properties are set in the `animate` prop as flat values (no transform array).
@@ -171,16 +220,17 @@ All properties are set in the `animate` prop as flat values (no transform array)
171
220
  ```tsx
172
221
  <EaseView
173
222
  animate={{
174
- opacity: 1, // 0 to 1
175
- translateX: 0, // pixels
176
- translateY: 0, // pixels
177
- scale: 1, // 1 = normal size (shorthand for scaleX + scaleY)
178
- scaleX: 1, // horizontal scale
179
- scaleY: 1, // vertical scale
180
- rotate: 0, // Z-axis rotation in degrees
181
- rotateX: 0, // X-axis rotation in degrees (3D)
182
- rotateY: 0, // Y-axis rotation in degrees (3D)
183
- borderRadius: 0, // pixels (hardware-accelerated, clips children)
223
+ opacity: 1, // 0 to 1
224
+ translateX: 0, // pixels
225
+ translateY: 0, // pixels
226
+ scale: 1, // 1 = normal size (shorthand for scaleX + scaleY)
227
+ scaleX: 1, // horizontal scale
228
+ scaleY: 1, // vertical scale
229
+ rotate: 0, // Z-axis rotation in degrees
230
+ rotateX: 0, // X-axis rotation in degrees (3D)
231
+ rotateY: 0, // Y-axis rotation in degrees (3D)
232
+ borderRadius: 0, // pixels (hardware-accelerated, clips children)
233
+ backgroundColor: 'transparent', // any RN color value
184
234
  }}
185
235
  />
186
236
  ```
@@ -261,11 +311,11 @@ By default, scale and rotation animate from the view's center. Use `transformOri
261
311
  />
262
312
  ```
263
313
 
264
- | Value | Position |
265
- |---|---|
266
- | `{ x: 0, y: 0 }` | Top-left |
314
+ | Value | Position |
315
+ | -------------------- | ---------------- |
316
+ | `{ x: 0, y: 0 }` | Top-left |
267
317
  | `{ x: 0.5, y: 0.5 }` | Center (default) |
268
- | `{ x: 1, y: 1 }` | Bottom-right |
318
+ | `{ x: 1, y: 1 }` | Bottom-right |
269
319
 
270
320
  ### Style Handling
271
321
 
@@ -299,32 +349,33 @@ By default, scale and rotation animate from the view's center. Use `transformOri
299
349
 
300
350
  A `View` that animates property changes using native platform APIs.
301
351
 
302
- | Prop | Type | Description |
303
- |---|---|---|
304
- | `animate` | `AnimateProps` | Target values for animated properties |
305
- | `initialAnimate` | `AnimateProps` | Starting values for enter animations (animates to `animate` on mount) |
306
- | `transition` | `Transition` | Animation configuration (timing, spring, or none) |
307
- | `onTransitionEnd` | `(event) => void` | Called when all animations complete with `{ finished: boolean }` |
308
- | `transformOrigin` | `{ x?: number; y?: number }` | Pivot point for scale/rotation as 0–1 fractions. Default: `{ x: 0.5, y: 0.5 }` (center) |
309
- | `useHardwareLayer` | `boolean` | Android only — rasterize to GPU texture during animations. See [Hardware Layers](#hardware-layers-android). Default: `false` |
310
- | `style` | `ViewStyle` | Non-animated styles (layout, colors, borders, etc.) |
311
- | `children` | `ReactNode` | Child elements |
312
- | ...rest | `ViewProps` | All other standard View props |
352
+ | Prop | Type | Description |
353
+ | ------------------ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
354
+ | `animate` | `AnimateProps` | Target values for animated properties |
355
+ | `initialAnimate` | `AnimateProps` | Starting values for enter animations (animates to `animate` on mount) |
356
+ | `transition` | `Transition` | Animation configuration (timing, spring, or none) |
357
+ | `onTransitionEnd` | `(event) => void` | Called when all animations complete with `{ finished: boolean }` |
358
+ | `transformOrigin` | `{ x?: number; y?: number }` | Pivot point for scale/rotation as 0–1 fractions. Default: `{ x: 0.5, y: 0.5 }` (center) |
359
+ | `useHardwareLayer` | `boolean` | Android only — rasterize to GPU texture during animations. See [Hardware Layers](#hardware-layers-android). Default: `false` |
360
+ | `style` | `ViewStyle` | Non-animated styles (layout, colors, borders, etc.) |
361
+ | `children` | `ReactNode` | Child elements |
362
+ | ...rest | `ViewProps` | All other standard View props |
313
363
 
314
364
  ### `AnimateProps`
315
365
 
316
- | Property | Type | Default | Description |
317
- |---|---|---|---|
318
- | `opacity` | `number` | `1` | View opacity (0–1) |
319
- | `translateX` | `number` | `0` | Horizontal translation in pixels |
320
- | `translateY` | `number` | `0` | Vertical translation in pixels |
321
- | `scale` | `number` | `1` | Uniform scale factor (shorthand for `scaleX` + `scaleY`) |
322
- | `scaleX` | `number` | `1` | Horizontal scale factor (overrides `scale` for X axis) |
323
- | `scaleY` | `number` | `1` | Vertical scale factor (overrides `scale` for Y axis) |
324
- | `rotate` | `number` | `0` | Z-axis rotation in degrees |
325
- | `rotateX` | `number` | `0` | X-axis rotation in degrees (3D) |
326
- | `rotateY` | `number` | `0` | Y-axis rotation in degrees (3D) |
327
- | `borderRadius` | `number` | `0` | Border radius in pixels (hardware-accelerated, clips children) |
366
+ | Property | Type | Default | Description |
367
+ | ----------------- | ------------ | --------------- | ------------------------------------------------------------------------------------ |
368
+ | `opacity` | `number` | `1` | View opacity (0–1) |
369
+ | `translateX` | `number` | `0` | Horizontal translation in pixels |
370
+ | `translateY` | `number` | `0` | Vertical translation in pixels |
371
+ | `scale` | `number` | `1` | Uniform scale factor (shorthand for `scaleX` + `scaleY`) |
372
+ | `scaleX` | `number` | `1` | Horizontal scale factor (overrides `scale` for X axis) |
373
+ | `scaleY` | `number` | `1` | Vertical scale factor (overrides `scale` for Y axis) |
374
+ | `rotate` | `number` | `0` | Z-axis rotation in degrees |
375
+ | `rotateX` | `number` | `0` | X-axis rotation in degrees (3D) |
376
+ | `rotateY` | `number` | `0` | Y-axis rotation in degrees (3D) |
377
+ | `borderRadius` | `number` | `0` | Border radius in pixels (hardware-accelerated, clips children) |
378
+ | `backgroundColor` | `ColorValue` | `'transparent'` | Background color (any RN color value). Timing-only on Android, spring+timing on iOS. |
328
379
 
329
380
  Properties not specified in `animate` default to their identity values.
330
381
 
@@ -365,10 +416,7 @@ Applies values instantly with no animation. `onTransitionEnd` fires immediately
365
416
  Setting `useHardwareLayer` rasterizes the view into a GPU texture for the duration of the animation. This means animated property changes (opacity, scale, rotation) are composited on the RenderThread without redrawing the view hierarchy — useful for complex views with many children.
366
417
 
367
418
  ```tsx
368
- <EaseView
369
- animate={{ opacity: isVisible ? 1 : 0 }}
370
- useHardwareLayer
371
- />
419
+ <EaseView animate={{ opacity: isVisible ? 1 : 0 }} useHardwareLayer />
372
420
  ```
373
421
 
374
422
  **Trade-offs:**
@@ -3,7 +3,9 @@ package com.ease
3
3
  import android.animation.Animator
4
4
  import android.animation.AnimatorListenerAdapter
5
5
  import android.animation.ObjectAnimator
6
+ import android.animation.ValueAnimator
6
7
  import android.content.Context
8
+ import android.graphics.Color
7
9
  import android.graphics.Outline
8
10
  import android.view.View
9
11
  import android.view.ViewOutlineProvider
@@ -26,6 +28,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
26
28
  private var prevRotateX: Float? = null
27
29
  private var prevRotateY: Float? = null
28
30
  private var prevBorderRadius: Float? = null
31
+ private var prevBackgroundColor: Int? = null
32
+ private var currentBackgroundColor: Int = Color.TRANSPARENT
29
33
 
30
34
  // --- First mount tracking ---
31
35
  private var isFirstMount: Boolean = true
@@ -38,6 +42,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
38
42
  var transitionStiffness: Float = 120.0f
39
43
  var transitionMass: Float = 1.0f
40
44
  var transitionLoop: String = "none"
45
+ var transitionDelay: Long = 0L
41
46
 
42
47
  // --- Transform origin (0–1 fractions) ---
43
48
  var transformOriginX: Float = 0.5f
@@ -52,11 +57,13 @@ class EaseView(context: Context) : ReactViewGroup(context) {
52
57
  }
53
58
 
54
59
  // --- Border radius (hardware-accelerated via outline clipping) ---
55
- // Animated via ObjectAnimator("borderRadius") — setter invalidates outline each frame.
60
+ // Animated via ObjectAnimator("animateBorderRadius") — setter invalidates outline each frame.
56
61
  private var _borderRadius: Float = 0f
57
62
 
58
- fun getBorderRadius(): Float = _borderRadius
59
- fun setBorderRadius(value: Float) {
63
+ @Suppress("unused") // Used by ObjectAnimator via reflection
64
+ fun getAnimateBorderRadius(): Float = _borderRadius
65
+ @Suppress("unused") // Used by ObjectAnimator via reflection
66
+ fun setAnimateBorderRadius(value: Float) {
60
67
  if (_borderRadius != value) {
61
68
  _borderRadius = value
62
69
  if (value > 0f) {
@@ -89,6 +96,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
89
96
  var initialAnimateRotateX: Float = 0.0f
90
97
  var initialAnimateRotateY: Float = 0.0f
91
98
  var initialAnimateBorderRadius: Float = 0.0f
99
+ var initialAnimateBackgroundColor: Int = Color.TRANSPARENT
92
100
 
93
101
  // --- Pending animate values (buffered per-view, applied in onAfterUpdateTransaction) ---
94
102
  var pendingOpacity: Float = 1.0f
@@ -100,10 +108,12 @@ class EaseView(context: Context) : ReactViewGroup(context) {
100
108
  var pendingRotateX: Float = 0.0f
101
109
  var pendingRotateY: Float = 0.0f
102
110
  var pendingBorderRadius: Float = 0.0f
111
+ var pendingBackgroundColor: Int = Color.TRANSPARENT
103
112
 
104
113
  // --- Running animations ---
105
- private val runningAnimators = mutableMapOf<String, ObjectAnimator>()
114
+ private val runningAnimators = mutableMapOf<String, Animator>()
106
115
  private val runningSpringAnimations = mutableMapOf<DynamicAnimation.ViewProperty, SpringAnimation>()
116
+ private val pendingDelayedRunnables = mutableListOf<Runnable>()
107
117
 
108
118
  // --- Animated properties bitmask (set by ViewManager) ---
109
119
  var animatedProperties: Int = 0
@@ -120,6 +130,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
120
130
  const val MASK_ROTATE_X = 1 shl 6
121
131
  const val MASK_ROTATE_Y = 1 shl 7
122
132
  const val MASK_BORDER_RADIUS = 1 shl 8
133
+ const val MASK_BACKGROUND_COLOR = 1 shl 9
123
134
  }
124
135
 
125
136
  init {
@@ -169,7 +180,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
169
180
  }
170
181
 
171
182
  fun applyPendingAnimateValues() {
172
- applyAnimateValues(pendingOpacity, pendingTranslateX, pendingTranslateY, pendingScaleX, pendingScaleY, pendingRotate, pendingRotateX, pendingRotateY, pendingBorderRadius)
183
+ applyAnimateValues(pendingOpacity, pendingTranslateX, pendingTranslateY, pendingScaleX, pendingScaleY, pendingRotate, pendingRotateX, pendingRotateY, pendingBorderRadius, pendingBackgroundColor)
173
184
  }
174
185
 
175
186
  private fun applyAnimateValues(
@@ -181,7 +192,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
181
192
  rotate: Float,
182
193
  rotateX: Float,
183
194
  rotateY: Float,
184
- borderRadius: Float
195
+ borderRadius: Float,
196
+ backgroundColor: Int
185
197
  ) {
186
198
  if (pendingBatchAnimationCount > 0) {
187
199
  onTransitionEnd?.invoke(false)
@@ -206,7 +218,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
206
218
  (mask and MASK_ROTATE != 0 && initialAnimateRotate != rotate) ||
207
219
  (mask and MASK_ROTATE_X != 0 && initialAnimateRotateX != rotateX) ||
208
220
  (mask and MASK_ROTATE_Y != 0 && initialAnimateRotateY != rotateY) ||
209
- (mask and MASK_BORDER_RADIUS != 0 && initialAnimateBorderRadius != borderRadius)
221
+ (mask and MASK_BORDER_RADIUS != 0 && initialAnimateBorderRadius != borderRadius) ||
222
+ (mask and MASK_BACKGROUND_COLOR != 0 && initialAnimateBackgroundColor != backgroundColor)
210
223
 
211
224
  if (hasInitialAnimation) {
212
225
  // Set initial values for animated properties
@@ -218,7 +231,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
218
231
  if (mask and MASK_ROTATE != 0) this.rotation = initialAnimateRotate
219
232
  if (mask and MASK_ROTATE_X != 0) this.rotationX = initialAnimateRotateX
220
233
  if (mask and MASK_ROTATE_Y != 0) this.rotationY = initialAnimateRotateY
221
- if (mask and MASK_BORDER_RADIUS != 0) setBorderRadius(initialAnimateBorderRadius)
234
+ if (mask and MASK_BORDER_RADIUS != 0) setAnimateBorderRadius(initialAnimateBorderRadius)
235
+ if (mask and MASK_BACKGROUND_COLOR != 0) applyBackgroundColor(initialAnimateBackgroundColor)
222
236
 
223
237
  // Animate properties that differ from initial to target
224
238
  if (mask and MASK_OPACITY != 0 && initialAnimateOpacity != opacity) {
@@ -246,7 +260,10 @@ class EaseView(context: Context) : ReactViewGroup(context) {
246
260
  animateProperty("rotationY", DynamicAnimation.ROTATION_Y, initialAnimateRotateY, rotateY, loop = true)
247
261
  }
248
262
  if (mask and MASK_BORDER_RADIUS != 0 && initialAnimateBorderRadius != borderRadius) {
249
- animateProperty("borderRadius", null, initialAnimateBorderRadius, borderRadius, loop = true)
263
+ animateProperty("animateBorderRadius", null, initialAnimateBorderRadius, borderRadius, loop = true)
264
+ }
265
+ if (mask and MASK_BACKGROUND_COLOR != 0 && initialAnimateBackgroundColor != backgroundColor) {
266
+ animateBackgroundColor(initialAnimateBackgroundColor, backgroundColor, loop = true)
250
267
  }
251
268
  } else {
252
269
  // No initial animation — set target values directly (skip non-animated)
@@ -258,7 +275,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
258
275
  if (mask and MASK_ROTATE != 0) this.rotation = rotate
259
276
  if (mask and MASK_ROTATE_X != 0) this.rotationX = rotateX
260
277
  if (mask and MASK_ROTATE_Y != 0) this.rotationY = rotateY
261
- if (mask and MASK_BORDER_RADIUS != 0) setBorderRadius(borderRadius)
278
+ if (mask and MASK_BORDER_RADIUS != 0) setAnimateBorderRadius(borderRadius)
279
+ if (mask and MASK_BACKGROUND_COLOR != 0) applyBackgroundColor(backgroundColor)
262
280
  }
263
281
  } else if (transitionType == "none") {
264
282
  // No transition — set values immediately, cancel running animations
@@ -271,7 +289,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
271
289
  if (mask and MASK_ROTATE != 0) this.rotation = rotate
272
290
  if (mask and MASK_ROTATE_X != 0) this.rotationX = rotateX
273
291
  if (mask and MASK_ROTATE_Y != 0) this.rotationY = rotateY
274
- if (mask and MASK_BORDER_RADIUS != 0) setBorderRadius(borderRadius)
292
+ if (mask and MASK_BORDER_RADIUS != 0) setAnimateBorderRadius(borderRadius)
293
+ if (mask and MASK_BACKGROUND_COLOR != 0) applyBackgroundColor(backgroundColor)
275
294
  onTransitionEnd?.invoke(true)
276
295
  } else {
277
296
  // Subsequent updates: animate changed properties (skip non-animated)
@@ -316,8 +335,12 @@ class EaseView(context: Context) : ReactViewGroup(context) {
316
335
  }
317
336
 
318
337
  if (prevBorderRadius != null && mask and MASK_BORDER_RADIUS != 0 && prevBorderRadius != borderRadius) {
319
- val from = getCurrentValue("borderRadius")
320
- animateProperty("borderRadius", null, from, borderRadius)
338
+ val from = getCurrentValue("animateBorderRadius")
339
+ animateProperty("animateBorderRadius", null, from, borderRadius)
340
+ }
341
+
342
+ if (prevBackgroundColor != null && mask and MASK_BACKGROUND_COLOR != 0 && prevBackgroundColor != backgroundColor) {
343
+ animateBackgroundColor(getCurrentBackgroundColor(), backgroundColor)
321
344
  }
322
345
  }
323
346
 
@@ -330,6 +353,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
330
353
  prevRotateX = rotateX
331
354
  prevRotateY = rotateY
332
355
  prevBorderRadius = borderRadius
356
+ prevBackgroundColor = backgroundColor
333
357
  }
334
358
 
335
359
  private fun getCurrentValue(propertyName: String): Float = when (propertyName) {
@@ -341,10 +365,64 @@ class EaseView(context: Context) : ReactViewGroup(context) {
341
365
  "rotation" -> this.rotation
342
366
  "rotationX" -> this.rotationX
343
367
  "rotationY" -> this.rotationY
344
- "borderRadius" -> getBorderRadius()
368
+ "animateBorderRadius" -> getAnimateBorderRadius()
345
369
  else -> 0f
346
370
  }
347
371
 
372
+ private fun getCurrentBackgroundColor(): Int {
373
+ return currentBackgroundColor
374
+ }
375
+
376
+ private fun applyBackgroundColor(color: Int) {
377
+ currentBackgroundColor = color
378
+ setBackgroundColor(color)
379
+ }
380
+
381
+ private fun animateBackgroundColor(fromColor: Int, toColor: Int, loop: Boolean = false) {
382
+ runningAnimators["backgroundColor"]?.cancel()
383
+
384
+ val batchId = animationBatchId
385
+ pendingBatchAnimationCount++
386
+
387
+ val animator = ValueAnimator.ofArgb(fromColor, toColor).apply {
388
+ duration = transitionDuration.toLong()
389
+ startDelay = transitionDelay
390
+ interpolator = PathInterpolator(
391
+ transitionEasingBezier[0], transitionEasingBezier[1],
392
+ transitionEasingBezier[2], transitionEasingBezier[3]
393
+ )
394
+ if (loop && transitionLoop != "none") {
395
+ repeatCount = ValueAnimator.INFINITE
396
+ repeatMode = if (transitionLoop == "reverse") ValueAnimator.REVERSE else ValueAnimator.RESTART
397
+ }
398
+ addUpdateListener { animation ->
399
+ val color = animation.animatedValue as Int
400
+ this@EaseView.currentBackgroundColor = color
401
+ this@EaseView.setBackgroundColor(color)
402
+ }
403
+ addListener(object : AnimatorListenerAdapter() {
404
+ private var cancelled = false
405
+ override fun onAnimationStart(animation: Animator) {
406
+ this@EaseView.onEaseAnimationStart()
407
+ }
408
+ override fun onAnimationCancel(animation: Animator) { cancelled = true }
409
+ override fun onAnimationEnd(animation: Animator) {
410
+ this@EaseView.onEaseAnimationEnd()
411
+ if (batchId == animationBatchId) {
412
+ if (cancelled) anyInterrupted = true
413
+ pendingBatchAnimationCount--
414
+ if (pendingBatchAnimationCount <= 0) {
415
+ onTransitionEnd?.invoke(!anyInterrupted)
416
+ }
417
+ }
418
+ }
419
+ })
420
+ }
421
+
422
+ runningAnimators["backgroundColor"] = animator
423
+ animator.start()
424
+ }
425
+
348
426
  private fun animateProperty(
349
427
  propertyName: String,
350
428
  viewProperty: DynamicAnimation.ViewProperty?,
@@ -368,6 +446,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
368
446
 
369
447
  val animator = ObjectAnimator.ofFloat(this, propertyName, fromValue, toValue).apply {
370
448
  duration = transitionDuration.toLong()
449
+ startDelay = transitionDelay
371
450
  interpolator = PathInterpolator(
372
451
  transitionEasingBezier[0], transitionEasingBezier[1],
373
452
  transitionEasingBezier[2], transitionEasingBezier[3]
@@ -445,10 +524,20 @@ class EaseView(context: Context) : ReactViewGroup(context) {
445
524
 
446
525
  onEaseAnimationStart()
447
526
  runningSpringAnimations[viewProperty] = spring
448
- spring.start()
527
+ if (transitionDelay > 0) {
528
+ val runnable = Runnable { spring.start() }
529
+ pendingDelayedRunnables.add(runnable)
530
+ postDelayed(runnable, transitionDelay)
531
+ } else {
532
+ spring.start()
533
+ }
449
534
  }
450
535
 
451
536
  private fun cancelAllAnimations() {
537
+ for (runnable in pendingDelayedRunnables) {
538
+ removeCallbacks(runnable)
539
+ }
540
+ pendingDelayedRunnables.clear()
452
541
  for (animator in runningAnimators.values) {
453
542
  animator.cancel()
454
543
  }
@@ -498,6 +587,10 @@ class EaseView(context: Context) : ReactViewGroup(context) {
498
587
  }
499
588
 
500
589
  fun cleanup() {
590
+ for (runnable in pendingDelayedRunnables) {
591
+ removeCallbacks(runnable)
592
+ }
593
+ pendingDelayedRunnables.clear()
501
594
  for (animator in runningAnimators.values) {
502
595
  animator.cancel()
503
596
  }
@@ -524,6 +617,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
524
617
  prevRotateX = null
525
618
  prevRotateY = null
526
619
  prevBorderRadius = null
620
+ prevBackgroundColor = null
527
621
 
528
622
  this.alpha = 1f
529
623
  this.translationX = 0f
@@ -533,7 +627,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
533
627
  this.rotation = 0f
534
628
  this.rotationX = 0f
535
629
  this.rotationY = 0f
536
- setBorderRadius(0f)
630
+ setAnimateBorderRadius(0f)
631
+ applyBackgroundColor(Color.TRANSPARENT)
537
632
 
538
633
  isFirstMount = true
539
634
  transitionLoop = "none"
@@ -1,5 +1,6 @@
1
1
  package com.ease
2
2
 
3
+ import android.graphics.Color
3
4
  import com.facebook.react.bridge.Arguments
4
5
  import com.facebook.react.bridge.ReadableArray
5
6
  import com.facebook.react.bridge.WritableMap
@@ -172,6 +173,11 @@ class EaseViewManager : ReactViewManager() {
172
173
  view.transitionLoop = value ?: "none"
173
174
  }
174
175
 
176
+ @ReactProp(name = "transitionDelay", defaultInt = 0)
177
+ fun setTransitionDelay(view: EaseView, value: Int) {
178
+ view.transitionDelay = value.toLong()
179
+ }
180
+
175
181
  // --- Border radius ---
176
182
 
177
183
  @ReactProp(name = "animateBorderRadius", defaultFloat = 0f)
@@ -179,6 +185,18 @@ class EaseViewManager : ReactViewManager() {
179
185
  view.pendingBorderRadius = PixelUtil.toPixelFromDIP(value)
180
186
  }
181
187
 
188
+ // --- Background color ---
189
+
190
+ @ReactProp(name = "animateBackgroundColor", customType = "Color")
191
+ fun setAnimateBackgroundColor(view: EaseView, value: Int?) {
192
+ view.pendingBackgroundColor = value ?: Color.TRANSPARENT
193
+ }
194
+
195
+ @ReactProp(name = "initialAnimateBackgroundColor", customType = "Color")
196
+ fun setInitialAnimateBackgroundColor(view: EaseView, value: Int?) {
197
+ view.initialAnimateBackgroundColor = value ?: Color.TRANSPARENT
198
+ }
199
+
182
200
  // --- Hardware layer ---
183
201
 
184
202
  @ReactProp(name = "useHardwareLayer", defaultBoolean = false)