react-native-ease 0.3.0 → 0.4.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.
package/README.md CHANGED
@@ -100,6 +100,7 @@ Timing animations transition from one value to another over a fixed duration wit
100
100
  | ---------- | ------------ | ------------- | ------------------------------------------------------------------------ |
101
101
  | `duration` | `number` | `300` | Duration in milliseconds |
102
102
  | `easing` | `EasingType` | `'easeInOut'` | Easing curve (preset name or `[x1, y1, x2, y2]` cubic bezier) |
103
+ | `delay` | `number` | `0` | Delay in milliseconds before the animation starts |
103
104
  | `loop` | `string` | — | `'repeat'` restarts from the beginning, `'reverse'` alternates direction |
104
105
 
105
106
  Available easing curves:
@@ -146,6 +147,7 @@ Spring animations use a physics-based model for natural-feeling motion. Great fo
146
147
  | `damping` | `number` | `15` | Friction — higher values reduce oscillation |
147
148
  | `stiffness` | `number` | `120` | Spring constant — higher values mean faster animation |
148
149
  | `mass` | `number` | `1` | Mass of the object — higher values mean slower, more momentum |
150
+ | `delay` | `number` | `0` | Delay in milliseconds before the animation starts |
149
151
 
150
152
  Spring presets for common feels:
151
153
 
@@ -176,6 +178,46 @@ Use `{ type: 'none' }` to apply values immediately without animation. Useful for
176
178
 
177
179
  `onTransitionEnd` fires immediately with `{ finished: true }`.
178
180
 
181
+ ### Per-Property Transitions
182
+
183
+ Pass a map instead of a single config to use different animation types per property category.
184
+
185
+ ```tsx
186
+ <EaseView
187
+ animate={{ opacity: visible ? 1 : 0, translateY: visible ? 0 : 30 }}
188
+ transition={{
189
+ opacity: { type: 'timing', duration: 150, easing: 'easeOut' },
190
+ transform: { type: 'spring', damping: 12, stiffness: 200 },
191
+ }}
192
+ />
193
+ ```
194
+
195
+ Available category keys:
196
+
197
+ | Key | Properties |
198
+ | ----------------- | ---------------------------------------------------------------- |
199
+ | `default` | Fallback for categories not explicitly listed |
200
+ | `transform` | translateX, translateY, scaleX, scaleY, rotate, rotateX, rotateY |
201
+ | `opacity` | opacity |
202
+ | `borderRadius` | borderRadius |
203
+ | `backgroundColor` | backgroundColor |
204
+
205
+ Use `default` as a fallback for categories not explicitly listed:
206
+
207
+ ```tsx
208
+ <EaseView
209
+ animate={{ opacity: 1, scale: 1.2, translateY: -20 }}
210
+ transition={{
211
+ default: { type: 'spring', damping: 15, stiffness: 120 },
212
+ opacity: { type: 'timing', duration: 200, easing: 'easeOut' },
213
+ }}
214
+ />
215
+ ```
216
+
217
+ When no `default` key is provided, the library default (timing 300ms easeInOut) applies to all categories.
218
+
219
+ > **Android note:** Android animates `backgroundColor` with `ValueAnimator` (timing only). If a per-property map specifies `type: 'spring'` for `backgroundColor`, it silently falls back to timing 300ms.
220
+
179
221
  ### Border Radius
180
222
 
181
223
  `borderRadius` can be animated just like other properties. It uses hardware-accelerated platform APIs — `ViewOutlineProvider` + `clipToOutline` on Android and `layer.cornerRadius` + `layer.masksToBounds` on iOS. Unlike RN's style-based `borderRadius` (which uses a Canvas drawable on Android), this clips children properly and is GPU-accelerated.
@@ -276,6 +318,26 @@ Use `initialAnimate` to set starting values. On mount, the view starts at `initi
276
318
 
277
319
  Without `initialAnimate`, the view renders at the `animate` values immediately with no animation on mount.
278
320
 
321
+ ### Delay
322
+
323
+ Use `delay` to postpone the start of an animation. This is useful for staggering enter animations across multiple elements.
324
+
325
+ ```tsx
326
+ // Staggered fade-in list
327
+ {items.map((item, i) => (
328
+ <EaseView
329
+ key={item.id}
330
+ initialAnimate={{ opacity: 0, translateY: 20 }}
331
+ animate={{ opacity: 1, translateY: 0 }}
332
+ transition={{ type: 'timing', duration: 300, delay: i * 100 }}
333
+ >
334
+ <Text>{item.label}</Text>
335
+ </EaseView>
336
+ ))}
337
+ ```
338
+
339
+ `delay` works with both timing and spring transitions.
340
+
279
341
  ### Interruption
280
342
 
281
343
  Animations are interruptible by default. If you change `animate` values while an animation is running, it smoothly redirects to the new target from wherever it currently is — no jumping or restarting.
@@ -386,6 +448,7 @@ Properties not specified in `animate` default to their identity values.
386
448
  type: 'timing';
387
449
  duration?: number; // default: 300 (ms)
388
450
  easing?: EasingType; // default: 'easeInOut' — preset name or [x1, y1, x2, y2]
451
+ delay?: number; // default: 0 (ms)
389
452
  loop?: 'repeat' | 'reverse'; // default: none
390
453
  }
391
454
  ```
@@ -398,6 +461,7 @@ Properties not specified in `animate` default to their identity values.
398
461
  damping?: number; // default: 15
399
462
  stiffness?: number; // default: 120
400
463
  mass?: number; // default: 1
464
+ delay?: number; // default: 0 (ms)
401
465
  }
402
466
  ```
403
467
 
@@ -427,6 +491,58 @@ Setting `useHardwareLayer` rasterizes the view into a GPU texture for the durati
427
491
 
428
492
  No-op on iOS where Core Animation already composites off the main thread.
429
493
 
494
+ ## Benchmarks
495
+
496
+ The example app includes a benchmark that measures per-frame animation overhead across different approaches. All approaches run the same animation (translateX loop, linear, 2s) on a configurable number of views.
497
+
498
+ ### Android (release build, emulator, M4 MacBook Pro)
499
+
500
+ UI thread time per frame: anim + layout + draw (ms). Lower is better.
501
+
502
+ ![Android benchmark](https://github.com/user-attachments/assets/f0e5cf26-76be-4dd3-ae04-e17c6d13b49c)
503
+
504
+ <details>
505
+ <summary>Detailed numbers</summary>
506
+
507
+ | Views | Metric | Ease | Reanimated SV | Reanimated SV (FF) | Reanimated CSS | Reanimated CSS (FF) | RN Animated |
508
+ |-------|--------|------|---------------|---------------------|----------------|----------------------|-------------|
509
+ | 10 | Avg | 0.21 | 1.15 | 0.75 | 0.99 | 0.45 | 0.36 |
510
+ | 10 | P95 | 0.33 | 1.70 | 1.53 | 1.44 | 0.80 | 0.62 |
511
+ | 10 | P99 | 0.48 | 1.94 | 2.26 | 1.62 | 1.35 | 0.98 |
512
+ | 100 | Avg | 0.36 | 2.71 | 1.81 | 2.19 | 1.01 | 0.71 |
513
+ | 100 | P95 | 0.56 | 3.09 | 2.29 | 2.67 | 1.91 | 1.08 |
514
+ | 100 | P99 | 0.71 | 3.20 | 2.63 | 2.97 | 2.25 | 1.36 |
515
+ | 500 | Avg | 0.60 | 8.31 | 5.37 | 5.50 | 2.37 | 1.60 |
516
+ | 500 | P95 | 0.75 | 9.26 | 6.36 | 6.34 | 2.86 | 1.88 |
517
+ | 500 | P99 | 0.87 | 9.59 | 6.89 | 6.88 | 3.22 | 3.84 |
518
+
519
+ </details>
520
+
521
+ ### iOS (release build, simulator, iPhone 16 Pro, M4 MacBook Pro)
522
+
523
+ Display link callback time per frame (ms). Lower is better.
524
+
525
+ ![iOS benchmark](https://github.com/user-attachments/assets/c39a7a71-bf21-4276-b02f-b29983989832)
526
+
527
+ <details>
528
+ <summary>Detailed numbers</summary>
529
+
530
+ | Views | Metric | Ease | Reanimated SV | Reanimated SV (FF) | Reanimated CSS | Reanimated CSS (FF) | RN Animated |
531
+ |-------|--------|------|---------------|---------------------|----------------|----------------------|-------------|
532
+ | 10 | Avg | 0.01 | 1.33 | 1.08 | 1.06 | 0.63 | 0.83 |
533
+ | 10 | P95 | 0.02 | 1.67 | 1.59 | 1.34 | 1.01 | 1.18 |
534
+ | 10 | P99 | 0.03 | 1.90 | 1.68 | 1.50 | 1.08 | 1.31 |
535
+ | 100 | Avg | 0.01 | 3.72 | 3.33 | 2.71 | 2.48 | 3.32 |
536
+ | 100 | P95 | 0.01 | 5.21 | 4.50 | 3.83 | 3.39 | 4.28 |
537
+ | 100 | P99 | 0.02 | 5.68 | 4.75 | 4.91 | 3.79 | 4.55 |
538
+ | 500 | Avg | 0.01 | 6.84 | 6.54 | 4.16 | 3.70 | 4.91 |
539
+ | 500 | P95 | 0.01 | 7.69 | 7.32 | 4.59 | 4.22 | 5.66 |
540
+ | 500 | P99 | 0.02 | 8.10 | 7.45 | 4.71 | 4.33 | 5.89 |
541
+
542
+ </details>
543
+
544
+ Ease stays near zero because animations run entirely on platform APIs. On iOS, Core Animation runs on a separate render server process off the main thread, which is why Ease shows ~0ms. On Android, ObjectAnimator runs on the UI thread but is significantly lighter than other approaches. Reanimated results shown with experimental [feature flags](https://docs.swmansion.com/react-native-reanimated/docs/guides/feature-flags/) OFF (default) and ON (FF). Run the benchmark yourself in the [example app](example/).
545
+
430
546
  ## How It Works
431
547
 
432
548
  `EaseView` is a native Fabric component. The JS side flattens your `animate` and `transition` props into flat native props. When those props change, the native view:
@@ -13,6 +13,7 @@ import android.view.animation.PathInterpolator
13
13
  import androidx.dynamicanimation.animation.DynamicAnimation
14
14
  import androidx.dynamicanimation.animation.SpringAnimation
15
15
  import androidx.dynamicanimation.animation.SpringForce
16
+ import com.facebook.react.bridge.ReadableMap
16
17
  import com.facebook.react.views.view.ReactViewGroup
17
18
  import kotlin.math.sqrt
18
19
 
@@ -34,15 +35,90 @@ class EaseView(context: Context) : ReactViewGroup(context) {
34
35
  // --- First mount tracking ---
35
36
  private var isFirstMount: Boolean = true
36
37
 
37
- // --- Transition config (set by ViewManager) ---
38
- var transitionType: String = "timing"
39
- var transitionDuration: Int = 300
40
- var transitionEasingBezier: FloatArray = floatArrayOf(0.42f, 0f, 0.58f, 1.0f)
41
- var transitionDamping: Float = 15.0f
42
- var transitionStiffness: Float = 120.0f
43
- var transitionMass: Float = 1.0f
44
- var transitionLoop: String = "none"
45
- var transitionDelay: Long = 0L
38
+ // --- Transition configs (set by ViewManager via ReadableMap) ---
39
+ private var transitionConfigs: Map<String, TransitionConfig> = emptyMap()
40
+
41
+ data class TransitionConfig(
42
+ val type: String,
43
+ val duration: Int,
44
+ val easingBezier: FloatArray,
45
+ val damping: Float,
46
+ val stiffness: Float,
47
+ val mass: Float,
48
+ val loop: String,
49
+ val delay: Long
50
+ )
51
+
52
+ fun setTransitionsFromMap(map: ReadableMap?) {
53
+ if (map == null) {
54
+ transitionConfigs = emptyMap()
55
+ return
56
+ }
57
+ val configs = mutableMapOf<String, TransitionConfig>()
58
+ val keys = listOf("defaultConfig", "transform", "opacity", "borderRadius", "backgroundColor")
59
+ for (key in keys) {
60
+ if (map.hasKey(key)) {
61
+ val configMap = map.getMap(key) ?: continue
62
+ val bezierArray = configMap.getArray("easingBezier")!!
63
+ configs[key] = TransitionConfig(
64
+ type = configMap.getString("type")!!,
65
+ duration = configMap.getInt("duration"),
66
+ easingBezier = floatArrayOf(
67
+ bezierArray.getDouble(0).toFloat(),
68
+ bezierArray.getDouble(1).toFloat(),
69
+ bezierArray.getDouble(2).toFloat(),
70
+ bezierArray.getDouble(3).toFloat()
71
+ ),
72
+ damping = configMap.getDouble("damping").toFloat(),
73
+ stiffness = configMap.getDouble("stiffness").toFloat(),
74
+ mass = configMap.getDouble("mass").toFloat(),
75
+ loop = configMap.getString("loop")!!,
76
+ delay = configMap.getInt("delay").toLong()
77
+ )
78
+ }
79
+ }
80
+ transitionConfigs = configs
81
+ }
82
+
83
+ /** Map property name to category key, then fall back to defaultConfig. */
84
+ fun getTransitionConfig(name: String): TransitionConfig {
85
+ val categoryKey = when (name) {
86
+ "opacity" -> "opacity"
87
+ "translateX", "translateY", "scaleX", "scaleY",
88
+ "rotate", "rotateX", "rotateY" -> "transform"
89
+ "borderRadius" -> "borderRadius"
90
+ "backgroundColor" -> "backgroundColor"
91
+ else -> null
92
+ }
93
+ if (categoryKey != null) {
94
+ transitionConfigs[categoryKey]?.let { return it }
95
+ }
96
+ return transitionConfigs["defaultConfig"]!!
97
+ }
98
+
99
+ private fun allTransitionsNone(): Boolean {
100
+ val defaultConfig = transitionConfigs["defaultConfig"]
101
+ if (defaultConfig == null || defaultConfig.type != "none") return false
102
+ val categories = listOf("transform", "opacity", "borderRadius", "backgroundColor")
103
+ return categories.all { key ->
104
+ val config = transitionConfigs[key]
105
+ config == null || config.type == "none"
106
+ }
107
+ }
108
+
109
+ companion object {
110
+ // Bitmask flags — must match JS constants
111
+ const val MASK_OPACITY = 1 shl 0
112
+ const val MASK_TRANSLATE_X = 1 shl 1
113
+ const val MASK_TRANSLATE_Y = 1 shl 2
114
+ const val MASK_SCALE_X = 1 shl 3
115
+ const val MASK_SCALE_Y = 1 shl 4
116
+ const val MASK_ROTATE = 1 shl 5
117
+ const val MASK_ROTATE_X = 1 shl 6
118
+ const val MASK_ROTATE_Y = 1 shl 7
119
+ const val MASK_BORDER_RADIUS = 1 shl 8
120
+ const val MASK_BACKGROUND_COLOR = 1 shl 9
121
+ }
46
122
 
47
123
  // --- Transform origin (0–1 fractions) ---
48
124
  var transformOriginX: Float = 0.5f
@@ -118,21 +194,6 @@ class EaseView(context: Context) : ReactViewGroup(context) {
118
194
  // --- Animated properties bitmask (set by ViewManager) ---
119
195
  var animatedProperties: Int = 0
120
196
 
121
- // --- Easing interpolators (lazy singletons shared across all instances) ---
122
- companion object {
123
- // Bitmask flags — must match JS constants
124
- const val MASK_OPACITY = 1 shl 0
125
- const val MASK_TRANSLATE_X = 1 shl 1
126
- const val MASK_TRANSLATE_Y = 1 shl 2
127
- const val MASK_SCALE_X = 1 shl 3
128
- const val MASK_SCALE_Y = 1 shl 4
129
- const val MASK_ROTATE = 1 shl 5
130
- const val MASK_ROTATE_X = 1 shl 6
131
- const val MASK_ROTATE_Y = 1 shl 7
132
- const val MASK_BORDER_RADIUS = 1 shl 8
133
- const val MASK_BACKGROUND_COLOR = 1 shl 9
134
- }
135
-
136
197
  init {
137
198
  // Set camera distance for 3D perspective rotations (rotateX/rotateY)
138
199
  cameraDistance = resources.displayMetrics.density * 850f
@@ -236,34 +297,40 @@ class EaseView(context: Context) : ReactViewGroup(context) {
236
297
 
237
298
  // Animate properties that differ from initial to target
238
299
  if (mask and MASK_OPACITY != 0 && initialAnimateOpacity != opacity) {
239
- animateProperty("alpha", DynamicAnimation.ALPHA, initialAnimateOpacity, opacity, loop = true)
300
+ animateProperty("alpha", DynamicAnimation.ALPHA, initialAnimateOpacity, opacity, getTransitionConfig("opacity"), loop = true)
240
301
  }
241
302
  if (mask and MASK_TRANSLATE_X != 0 && initialAnimateTranslateX != translateX) {
242
- animateProperty("translationX", DynamicAnimation.TRANSLATION_X, initialAnimateTranslateX, translateX, loop = true)
303
+ animateProperty("translationX", DynamicAnimation.TRANSLATION_X, initialAnimateTranslateX, translateX, getTransitionConfig("translateX"), loop = true)
243
304
  }
244
305
  if (mask and MASK_TRANSLATE_Y != 0 && initialAnimateTranslateY != translateY) {
245
- animateProperty("translationY", DynamicAnimation.TRANSLATION_Y, initialAnimateTranslateY, translateY, loop = true)
306
+ animateProperty("translationY", DynamicAnimation.TRANSLATION_Y, initialAnimateTranslateY, translateY, getTransitionConfig("translateY"), loop = true)
246
307
  }
247
308
  if (mask and MASK_SCALE_X != 0 && initialAnimateScaleX != scaleX) {
248
- animateProperty("scaleX", DynamicAnimation.SCALE_X, initialAnimateScaleX, scaleX, loop = true)
309
+ animateProperty("scaleX", DynamicAnimation.SCALE_X, initialAnimateScaleX, scaleX, getTransitionConfig("scaleX"), loop = true)
249
310
  }
250
311
  if (mask and MASK_SCALE_Y != 0 && initialAnimateScaleY != scaleY) {
251
- animateProperty("scaleY", DynamicAnimation.SCALE_Y, initialAnimateScaleY, scaleY, loop = true)
312
+ animateProperty("scaleY", DynamicAnimation.SCALE_Y, initialAnimateScaleY, scaleY, getTransitionConfig("scaleY"), loop = true)
252
313
  }
253
314
  if (mask and MASK_ROTATE != 0 && initialAnimateRotate != rotate) {
254
- animateProperty("rotation", DynamicAnimation.ROTATION, initialAnimateRotate, rotate, loop = true)
315
+ animateProperty("rotation", DynamicAnimation.ROTATION, initialAnimateRotate, rotate, getTransitionConfig("rotate"), loop = true)
255
316
  }
256
317
  if (mask and MASK_ROTATE_X != 0 && initialAnimateRotateX != rotateX) {
257
- animateProperty("rotationX", DynamicAnimation.ROTATION_X, initialAnimateRotateX, rotateX, loop = true)
318
+ animateProperty("rotationX", DynamicAnimation.ROTATION_X, initialAnimateRotateX, rotateX, getTransitionConfig("rotateX"), loop = true)
258
319
  }
259
320
  if (mask and MASK_ROTATE_Y != 0 && initialAnimateRotateY != rotateY) {
260
- animateProperty("rotationY", DynamicAnimation.ROTATION_Y, initialAnimateRotateY, rotateY, loop = true)
321
+ animateProperty("rotationY", DynamicAnimation.ROTATION_Y, initialAnimateRotateY, rotateY, getTransitionConfig("rotateY"), loop = true)
261
322
  }
262
323
  if (mask and MASK_BORDER_RADIUS != 0 && initialAnimateBorderRadius != borderRadius) {
263
- animateProperty("animateBorderRadius", null, initialAnimateBorderRadius, borderRadius, loop = true)
324
+ animateProperty("animateBorderRadius", null, initialAnimateBorderRadius, borderRadius, getTransitionConfig("borderRadius"), loop = true)
264
325
  }
265
326
  if (mask and MASK_BACKGROUND_COLOR != 0 && initialAnimateBackgroundColor != backgroundColor) {
266
- animateBackgroundColor(initialAnimateBackgroundColor, backgroundColor, loop = true)
327
+ animateBackgroundColor(initialAnimateBackgroundColor, backgroundColor, getTransitionConfig("backgroundColor"), loop = true)
328
+ }
329
+
330
+ // If all per-property configs were 'none', no animations were queued.
331
+ // Fire onTransitionEnd immediately to match the scalar 'none' contract.
332
+ if (pendingBatchAnimationCount == 0) {
333
+ onTransitionEnd?.invoke(true)
267
334
  }
268
335
  } else {
269
336
  // No initial animation — set target values directly (skip non-animated)
@@ -278,8 +345,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
278
345
  if (mask and MASK_BORDER_RADIUS != 0) setAnimateBorderRadius(borderRadius)
279
346
  if (mask and MASK_BACKGROUND_COLOR != 0) applyBackgroundColor(backgroundColor)
280
347
  }
281
- } else if (transitionType == "none") {
282
- // No transition — set values immediately, cancel running animations
348
+ } else if (allTransitionsNone()) {
349
+ // No transition (scalar) — set values immediately, cancel running animations
283
350
  cancelAllAnimations()
284
351
  if (mask and MASK_OPACITY != 0) this.alpha = opacity
285
352
  if (mask and MASK_TRANSLATE_X != 0) this.translationX = translateX
@@ -294,53 +361,149 @@ class EaseView(context: Context) : ReactViewGroup(context) {
294
361
  onTransitionEnd?.invoke(true)
295
362
  } else {
296
363
  // Subsequent updates: animate changed properties (skip non-animated)
364
+ var anyPropertyChanged = false
365
+
297
366
  if (prevOpacity != null && mask and MASK_OPACITY != 0 && prevOpacity != opacity) {
298
- val from = getCurrentValue("alpha")
299
- animateProperty("alpha", DynamicAnimation.ALPHA, from, opacity)
367
+ anyPropertyChanged = true
368
+ val config = getTransitionConfig("opacity")
369
+ if (config.type == "none") {
370
+ cancelSpringForProperty("alpha")
371
+ runningAnimators["alpha"]?.cancel()
372
+ runningAnimators.remove("alpha")
373
+ this.alpha = opacity
374
+ } else {
375
+ val from = getCurrentValue("alpha")
376
+ animateProperty("alpha", DynamicAnimation.ALPHA, from, opacity, config)
377
+ }
300
378
  }
301
379
 
302
380
  if (prevTranslateX != null && mask and MASK_TRANSLATE_X != 0 && prevTranslateX != translateX) {
303
- val from = getCurrentValue("translationX")
304
- animateProperty("translationX", DynamicAnimation.TRANSLATION_X, from, translateX)
381
+ anyPropertyChanged = true
382
+ val config = getTransitionConfig("translateX")
383
+ if (config.type == "none") {
384
+ cancelSpringForProperty("translationX")
385
+ runningAnimators["translationX"]?.cancel()
386
+ runningAnimators.remove("translationX")
387
+ this.translationX = translateX
388
+ } else {
389
+ val from = getCurrentValue("translationX")
390
+ animateProperty("translationX", DynamicAnimation.TRANSLATION_X, from, translateX, config)
391
+ }
305
392
  }
306
393
 
307
394
  if (prevTranslateY != null && mask and MASK_TRANSLATE_Y != 0 && prevTranslateY != translateY) {
308
- val from = getCurrentValue("translationY")
309
- animateProperty("translationY", DynamicAnimation.TRANSLATION_Y, from, translateY)
395
+ anyPropertyChanged = true
396
+ val config = getTransitionConfig("translateY")
397
+ if (config.type == "none") {
398
+ cancelSpringForProperty("translationY")
399
+ runningAnimators["translationY"]?.cancel()
400
+ runningAnimators.remove("translationY")
401
+ this.translationY = translateY
402
+ } else {
403
+ val from = getCurrentValue("translationY")
404
+ animateProperty("translationY", DynamicAnimation.TRANSLATION_Y, from, translateY, config)
405
+ }
310
406
  }
311
407
 
312
408
  if (prevScaleX != null && mask and MASK_SCALE_X != 0 && prevScaleX != scaleX) {
313
- val from = getCurrentValue("scaleX")
314
- animateProperty("scaleX", DynamicAnimation.SCALE_X, from, scaleX)
409
+ anyPropertyChanged = true
410
+ val config = getTransitionConfig("scaleX")
411
+ if (config.type == "none") {
412
+ cancelSpringForProperty("scaleX")
413
+ runningAnimators["scaleX"]?.cancel()
414
+ runningAnimators.remove("scaleX")
415
+ this.scaleX = scaleX
416
+ } else {
417
+ val from = getCurrentValue("scaleX")
418
+ animateProperty("scaleX", DynamicAnimation.SCALE_X, from, scaleX, config)
419
+ }
315
420
  }
316
421
 
317
422
  if (prevScaleY != null && mask and MASK_SCALE_Y != 0 && prevScaleY != scaleY) {
318
- val from = getCurrentValue("scaleY")
319
- animateProperty("scaleY", DynamicAnimation.SCALE_Y, from, scaleY)
423
+ anyPropertyChanged = true
424
+ val config = getTransitionConfig("scaleY")
425
+ if (config.type == "none") {
426
+ cancelSpringForProperty("scaleY")
427
+ runningAnimators["scaleY"]?.cancel()
428
+ runningAnimators.remove("scaleY")
429
+ this.scaleY = scaleY
430
+ } else {
431
+ val from = getCurrentValue("scaleY")
432
+ animateProperty("scaleY", DynamicAnimation.SCALE_Y, from, scaleY, config)
433
+ }
320
434
  }
321
435
 
322
436
  if (prevRotate != null && mask and MASK_ROTATE != 0 && prevRotate != rotate) {
323
- val from = getCurrentValue("rotation")
324
- animateProperty("rotation", DynamicAnimation.ROTATION, from, rotate)
437
+ anyPropertyChanged = true
438
+ val config = getTransitionConfig("rotate")
439
+ if (config.type == "none") {
440
+ cancelSpringForProperty("rotation")
441
+ runningAnimators["rotation"]?.cancel()
442
+ runningAnimators.remove("rotation")
443
+ this.rotation = rotate
444
+ } else {
445
+ val from = getCurrentValue("rotation")
446
+ animateProperty("rotation", DynamicAnimation.ROTATION, from, rotate, config)
447
+ }
325
448
  }
326
449
 
327
450
  if (prevRotateX != null && mask and MASK_ROTATE_X != 0 && prevRotateX != rotateX) {
328
- val from = getCurrentValue("rotationX")
329
- animateProperty("rotationX", DynamicAnimation.ROTATION_X, from, rotateX)
451
+ anyPropertyChanged = true
452
+ val config = getTransitionConfig("rotateX")
453
+ if (config.type == "none") {
454
+ cancelSpringForProperty("rotationX")
455
+ runningAnimators["rotationX"]?.cancel()
456
+ runningAnimators.remove("rotationX")
457
+ this.rotationX = rotateX
458
+ } else {
459
+ val from = getCurrentValue("rotationX")
460
+ animateProperty("rotationX", DynamicAnimation.ROTATION_X, from, rotateX, config)
461
+ }
330
462
  }
331
463
 
332
464
  if (prevRotateY != null && mask and MASK_ROTATE_Y != 0 && prevRotateY != rotateY) {
333
- val from = getCurrentValue("rotationY")
334
- animateProperty("rotationY", DynamicAnimation.ROTATION_Y, from, rotateY)
465
+ anyPropertyChanged = true
466
+ val config = getTransitionConfig("rotateY")
467
+ if (config.type == "none") {
468
+ cancelSpringForProperty("rotationY")
469
+ runningAnimators["rotationY"]?.cancel()
470
+ runningAnimators.remove("rotationY")
471
+ this.rotationY = rotateY
472
+ } else {
473
+ val from = getCurrentValue("rotationY")
474
+ animateProperty("rotationY", DynamicAnimation.ROTATION_Y, from, rotateY, config)
475
+ }
335
476
  }
336
477
 
337
478
  if (prevBorderRadius != null && mask and MASK_BORDER_RADIUS != 0 && prevBorderRadius != borderRadius) {
338
- val from = getCurrentValue("animateBorderRadius")
339
- animateProperty("animateBorderRadius", null, from, borderRadius)
479
+ anyPropertyChanged = true
480
+ val config = getTransitionConfig("borderRadius")
481
+ if (config.type == "none") {
482
+ runningAnimators["animateBorderRadius"]?.cancel()
483
+ runningAnimators.remove("animateBorderRadius")
484
+ setAnimateBorderRadius(borderRadius)
485
+ } else {
486
+ val from = getCurrentValue("animateBorderRadius")
487
+ animateProperty("animateBorderRadius", null, from, borderRadius, config)
488
+ }
340
489
  }
341
490
 
342
491
  if (prevBackgroundColor != null && mask and MASK_BACKGROUND_COLOR != 0 && prevBackgroundColor != backgroundColor) {
343
- animateBackgroundColor(getCurrentBackgroundColor(), backgroundColor)
492
+ anyPropertyChanged = true
493
+ val config = getTransitionConfig("backgroundColor")
494
+ if (config.type == "none") {
495
+ runningAnimators["backgroundColor"]?.cancel()
496
+ runningAnimators.remove("backgroundColor")
497
+ applyBackgroundColor(backgroundColor)
498
+ } else {
499
+ animateBackgroundColor(getCurrentBackgroundColor(), backgroundColor, config)
500
+ }
501
+ }
502
+
503
+ // If all changed properties resolved to 'none', no animations were queued.
504
+ // Fire onTransitionEnd immediately.
505
+ if (anyPropertyChanged && pendingBatchAnimationCount == 0) {
506
+ onTransitionEnd?.invoke(true)
344
507
  }
345
508
  }
346
509
 
@@ -378,22 +541,23 @@ class EaseView(context: Context) : ReactViewGroup(context) {
378
541
  setBackgroundColor(color)
379
542
  }
380
543
 
381
- private fun animateBackgroundColor(fromColor: Int, toColor: Int, loop: Boolean = false) {
544
+ private fun animateBackgroundColor(fromColor: Int, toColor: Int, config: TransitionConfig, loop: Boolean = false) {
382
545
  runningAnimators["backgroundColor"]?.cancel()
383
546
 
384
547
  val batchId = animationBatchId
385
548
  pendingBatchAnimationCount++
386
549
 
387
550
  val animator = ValueAnimator.ofArgb(fromColor, toColor).apply {
388
- duration = transitionDuration.toLong()
389
- startDelay = transitionDelay
551
+ duration = config.duration.toLong()
552
+ startDelay = config.delay
553
+
390
554
  interpolator = PathInterpolator(
391
- transitionEasingBezier[0], transitionEasingBezier[1],
392
- transitionEasingBezier[2], transitionEasingBezier[3]
555
+ config.easingBezier[0], config.easingBezier[1],
556
+ config.easingBezier[2], config.easingBezier[3]
393
557
  )
394
- if (loop && transitionLoop != "none") {
558
+ if (loop && config.loop != "none") {
395
559
  repeatCount = ValueAnimator.INFINITE
396
- repeatMode = if (transitionLoop == "reverse") ValueAnimator.REVERSE else ValueAnimator.RESTART
560
+ repeatMode = if (config.loop == "reverse") ValueAnimator.REVERSE else ValueAnimator.RESTART
397
561
  }
398
562
  addUpdateListener { animation ->
399
563
  val color = animation.animatedValue as Int
@@ -428,16 +592,28 @@ class EaseView(context: Context) : ReactViewGroup(context) {
428
592
  viewProperty: DynamicAnimation.ViewProperty?,
429
593
  fromValue: Float,
430
594
  toValue: Float,
595
+ config: TransitionConfig,
431
596
  loop: Boolean = false
432
597
  ) {
433
- if (transitionType == "spring" && viewProperty != null) {
434
- animateSpring(viewProperty, toValue)
598
+ if (config.type == "none") {
599
+ // Set immediately — cancel any running animation for this property
600
+ cancelSpringForProperty(propertyName)
601
+ runningAnimators[propertyName]?.cancel()
602
+ runningAnimators.remove(propertyName)
603
+ ObjectAnimator.ofFloat(this, propertyName, toValue).apply {
604
+ duration = 0
605
+ start()
606
+ }
607
+ return
608
+ }
609
+ if (config.type == "spring" && viewProperty != null) {
610
+ animateSpring(viewProperty, toValue, config)
435
611
  } else {
436
- animateTiming(propertyName, fromValue, toValue, loop)
612
+ animateTiming(propertyName, fromValue, toValue, config, loop)
437
613
  }
438
614
  }
439
615
 
440
- private fun animateTiming(propertyName: String, fromValue: Float, toValue: Float, loop: Boolean = false) {
616
+ private fun animateTiming(propertyName: String, fromValue: Float, toValue: Float, config: TransitionConfig, loop: Boolean = false) {
441
617
  cancelSpringForProperty(propertyName)
442
618
  runningAnimators[propertyName]?.cancel()
443
619
 
@@ -445,15 +621,16 @@ class EaseView(context: Context) : ReactViewGroup(context) {
445
621
  pendingBatchAnimationCount++
446
622
 
447
623
  val animator = ObjectAnimator.ofFloat(this, propertyName, fromValue, toValue).apply {
448
- duration = transitionDuration.toLong()
449
- startDelay = transitionDelay
624
+ duration = config.duration.toLong()
625
+ startDelay = config.delay
626
+
450
627
  interpolator = PathInterpolator(
451
- transitionEasingBezier[0], transitionEasingBezier[1],
452
- transitionEasingBezier[2], transitionEasingBezier[3]
628
+ config.easingBezier[0], config.easingBezier[1],
629
+ config.easingBezier[2], config.easingBezier[3]
453
630
  )
454
- if (loop && transitionLoop != "none") {
631
+ if (loop && config.loop != "none") {
455
632
  repeatCount = ObjectAnimator.INFINITE
456
- repeatMode = if (transitionLoop == "reverse") {
633
+ repeatMode = if (config.loop == "reverse") {
457
634
  ObjectAnimator.REVERSE
458
635
  } else {
459
636
  ObjectAnimator.RESTART
@@ -484,25 +661,27 @@ class EaseView(context: Context) : ReactViewGroup(context) {
484
661
  animator.start()
485
662
  }
486
663
 
487
- private fun animateSpring(viewProperty: DynamicAnimation.ViewProperty, toValue: Float) {
664
+ private fun animateSpring(viewProperty: DynamicAnimation.ViewProperty, toValue: Float, config: TransitionConfig) {
488
665
  cancelTimingForViewProperty(viewProperty)
489
666
 
490
- val existingSpring = runningSpringAnimations[viewProperty]
491
- if (existingSpring != null && existingSpring.isRunning) {
492
- existingSpring.animateToFinalPosition(toValue)
493
- return
667
+ // Cancel any existing spring so we get a fresh end listener with the current batchId.
668
+ runningSpringAnimations[viewProperty]?.let { existing ->
669
+ if (existing.isRunning) {
670
+ existing.cancel()
671
+ }
494
672
  }
673
+ runningSpringAnimations.remove(viewProperty)
495
674
 
496
675
  val batchId = animationBatchId
497
676
  pendingBatchAnimationCount++
498
677
 
499
- val dampingRatio = (transitionDamping / (2.0f * sqrt(transitionStiffness * transitionMass)))
678
+ val dampingRatio = (config.damping / (2.0f * sqrt(config.stiffness * config.mass)))
500
679
  .coerceAtLeast(0.01f)
501
680
 
502
681
  val spring = SpringAnimation(this, viewProperty).apply {
503
682
  spring = SpringForce(toValue).apply {
504
683
  this.dampingRatio = dampingRatio
505
- this.stiffness = transitionStiffness
684
+ this.stiffness = config.stiffness
506
685
  }
507
686
  addUpdateListener { _, _, _ ->
508
687
  // First update — enable hardware layer
@@ -524,10 +703,10 @@ class EaseView(context: Context) : ReactViewGroup(context) {
524
703
 
525
704
  onEaseAnimationStart()
526
705
  runningSpringAnimations[viewProperty] = spring
527
- if (transitionDelay > 0) {
706
+ if (config.delay > 0) {
528
707
  val runnable = Runnable { spring.start() }
529
708
  pendingDelayedRunnables.add(runnable)
530
- postDelayed(runnable, transitionDelay)
709
+ postDelayed(runnable, config.delay)
531
710
  } else {
532
711
  spring.start()
533
712
  }
@@ -631,6 +810,6 @@ class EaseView(context: Context) : ReactViewGroup(context) {
631
810
  applyBackgroundColor(Color.TRANSPARENT)
632
811
 
633
812
  isFirstMount = true
634
- transitionLoop = "none"
813
+ transitionConfigs = emptyMap()
635
814
  }
636
815
  }