react-native-ease 0.2.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.
@@ -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,14 +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"
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
+ }
45
122
 
46
123
  // --- Transform origin (0–1 fractions) ---
47
124
  var transformOriginX: Float = 0.5f
@@ -112,25 +189,11 @@ class EaseView(context: Context) : ReactViewGroup(context) {
112
189
  // --- Running animations ---
113
190
  private val runningAnimators = mutableMapOf<String, Animator>()
114
191
  private val runningSpringAnimations = mutableMapOf<DynamicAnimation.ViewProperty, SpringAnimation>()
192
+ private val pendingDelayedRunnables = mutableListOf<Runnable>()
115
193
 
116
194
  // --- Animated properties bitmask (set by ViewManager) ---
117
195
  var animatedProperties: Int = 0
118
196
 
119
- // --- Easing interpolators (lazy singletons shared across all instances) ---
120
- companion object {
121
- // Bitmask flags — must match JS constants
122
- const val MASK_OPACITY = 1 shl 0
123
- const val MASK_TRANSLATE_X = 1 shl 1
124
- const val MASK_TRANSLATE_Y = 1 shl 2
125
- const val MASK_SCALE_X = 1 shl 3
126
- const val MASK_SCALE_Y = 1 shl 4
127
- const val MASK_ROTATE = 1 shl 5
128
- const val MASK_ROTATE_X = 1 shl 6
129
- const val MASK_ROTATE_Y = 1 shl 7
130
- const val MASK_BORDER_RADIUS = 1 shl 8
131
- const val MASK_BACKGROUND_COLOR = 1 shl 9
132
- }
133
-
134
197
  init {
135
198
  // Set camera distance for 3D perspective rotations (rotateX/rotateY)
136
199
  cameraDistance = resources.displayMetrics.density * 850f
@@ -234,34 +297,40 @@ class EaseView(context: Context) : ReactViewGroup(context) {
234
297
 
235
298
  // Animate properties that differ from initial to target
236
299
  if (mask and MASK_OPACITY != 0 && initialAnimateOpacity != opacity) {
237
- animateProperty("alpha", DynamicAnimation.ALPHA, initialAnimateOpacity, opacity, loop = true)
300
+ animateProperty("alpha", DynamicAnimation.ALPHA, initialAnimateOpacity, opacity, getTransitionConfig("opacity"), loop = true)
238
301
  }
239
302
  if (mask and MASK_TRANSLATE_X != 0 && initialAnimateTranslateX != translateX) {
240
- animateProperty("translationX", DynamicAnimation.TRANSLATION_X, initialAnimateTranslateX, translateX, loop = true)
303
+ animateProperty("translationX", DynamicAnimation.TRANSLATION_X, initialAnimateTranslateX, translateX, getTransitionConfig("translateX"), loop = true)
241
304
  }
242
305
  if (mask and MASK_TRANSLATE_Y != 0 && initialAnimateTranslateY != translateY) {
243
- animateProperty("translationY", DynamicAnimation.TRANSLATION_Y, initialAnimateTranslateY, translateY, loop = true)
306
+ animateProperty("translationY", DynamicAnimation.TRANSLATION_Y, initialAnimateTranslateY, translateY, getTransitionConfig("translateY"), loop = true)
244
307
  }
245
308
  if (mask and MASK_SCALE_X != 0 && initialAnimateScaleX != scaleX) {
246
- animateProperty("scaleX", DynamicAnimation.SCALE_X, initialAnimateScaleX, scaleX, loop = true)
309
+ animateProperty("scaleX", DynamicAnimation.SCALE_X, initialAnimateScaleX, scaleX, getTransitionConfig("scaleX"), loop = true)
247
310
  }
248
311
  if (mask and MASK_SCALE_Y != 0 && initialAnimateScaleY != scaleY) {
249
- animateProperty("scaleY", DynamicAnimation.SCALE_Y, initialAnimateScaleY, scaleY, loop = true)
312
+ animateProperty("scaleY", DynamicAnimation.SCALE_Y, initialAnimateScaleY, scaleY, getTransitionConfig("scaleY"), loop = true)
250
313
  }
251
314
  if (mask and MASK_ROTATE != 0 && initialAnimateRotate != rotate) {
252
- animateProperty("rotation", DynamicAnimation.ROTATION, initialAnimateRotate, rotate, loop = true)
315
+ animateProperty("rotation", DynamicAnimation.ROTATION, initialAnimateRotate, rotate, getTransitionConfig("rotate"), loop = true)
253
316
  }
254
317
  if (mask and MASK_ROTATE_X != 0 && initialAnimateRotateX != rotateX) {
255
- animateProperty("rotationX", DynamicAnimation.ROTATION_X, initialAnimateRotateX, rotateX, loop = true)
318
+ animateProperty("rotationX", DynamicAnimation.ROTATION_X, initialAnimateRotateX, rotateX, getTransitionConfig("rotateX"), loop = true)
256
319
  }
257
320
  if (mask and MASK_ROTATE_Y != 0 && initialAnimateRotateY != rotateY) {
258
- animateProperty("rotationY", DynamicAnimation.ROTATION_Y, initialAnimateRotateY, rotateY, loop = true)
321
+ animateProperty("rotationY", DynamicAnimation.ROTATION_Y, initialAnimateRotateY, rotateY, getTransitionConfig("rotateY"), loop = true)
259
322
  }
260
323
  if (mask and MASK_BORDER_RADIUS != 0 && initialAnimateBorderRadius != borderRadius) {
261
- animateProperty("animateBorderRadius", null, initialAnimateBorderRadius, borderRadius, loop = true)
324
+ animateProperty("animateBorderRadius", null, initialAnimateBorderRadius, borderRadius, getTransitionConfig("borderRadius"), loop = true)
262
325
  }
263
326
  if (mask and MASK_BACKGROUND_COLOR != 0 && initialAnimateBackgroundColor != backgroundColor) {
264
- 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)
265
334
  }
266
335
  } else {
267
336
  // No initial animation — set target values directly (skip non-animated)
@@ -276,8 +345,8 @@ class EaseView(context: Context) : ReactViewGroup(context) {
276
345
  if (mask and MASK_BORDER_RADIUS != 0) setAnimateBorderRadius(borderRadius)
277
346
  if (mask and MASK_BACKGROUND_COLOR != 0) applyBackgroundColor(backgroundColor)
278
347
  }
279
- } else if (transitionType == "none") {
280
- // No transition — set values immediately, cancel running animations
348
+ } else if (allTransitionsNone()) {
349
+ // No transition (scalar) — set values immediately, cancel running animations
281
350
  cancelAllAnimations()
282
351
  if (mask and MASK_OPACITY != 0) this.alpha = opacity
283
352
  if (mask and MASK_TRANSLATE_X != 0) this.translationX = translateX
@@ -292,53 +361,149 @@ class EaseView(context: Context) : ReactViewGroup(context) {
292
361
  onTransitionEnd?.invoke(true)
293
362
  } else {
294
363
  // Subsequent updates: animate changed properties (skip non-animated)
364
+ var anyPropertyChanged = false
365
+
295
366
  if (prevOpacity != null && mask and MASK_OPACITY != 0 && prevOpacity != opacity) {
296
- val from = getCurrentValue("alpha")
297
- 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
+ }
298
378
  }
299
379
 
300
380
  if (prevTranslateX != null && mask and MASK_TRANSLATE_X != 0 && prevTranslateX != translateX) {
301
- val from = getCurrentValue("translationX")
302
- 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
+ }
303
392
  }
304
393
 
305
394
  if (prevTranslateY != null && mask and MASK_TRANSLATE_Y != 0 && prevTranslateY != translateY) {
306
- val from = getCurrentValue("translationY")
307
- 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
+ }
308
406
  }
309
407
 
310
408
  if (prevScaleX != null && mask and MASK_SCALE_X != 0 && prevScaleX != scaleX) {
311
- val from = getCurrentValue("scaleX")
312
- 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
+ }
313
420
  }
314
421
 
315
422
  if (prevScaleY != null && mask and MASK_SCALE_Y != 0 && prevScaleY != scaleY) {
316
- val from = getCurrentValue("scaleY")
317
- 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
+ }
318
434
  }
319
435
 
320
436
  if (prevRotate != null && mask and MASK_ROTATE != 0 && prevRotate != rotate) {
321
- val from = getCurrentValue("rotation")
322
- 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
+ }
323
448
  }
324
449
 
325
450
  if (prevRotateX != null && mask and MASK_ROTATE_X != 0 && prevRotateX != rotateX) {
326
- val from = getCurrentValue("rotationX")
327
- 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
+ }
328
462
  }
329
463
 
330
464
  if (prevRotateY != null && mask and MASK_ROTATE_Y != 0 && prevRotateY != rotateY) {
331
- val from = getCurrentValue("rotationY")
332
- 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
+ }
333
476
  }
334
477
 
335
478
  if (prevBorderRadius != null && mask and MASK_BORDER_RADIUS != 0 && prevBorderRadius != borderRadius) {
336
- val from = getCurrentValue("animateBorderRadius")
337
- 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
+ }
338
489
  }
339
490
 
340
491
  if (prevBackgroundColor != null && mask and MASK_BACKGROUND_COLOR != 0 && prevBackgroundColor != backgroundColor) {
341
- 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)
342
507
  }
343
508
  }
344
509
 
@@ -376,21 +541,23 @@ class EaseView(context: Context) : ReactViewGroup(context) {
376
541
  setBackgroundColor(color)
377
542
  }
378
543
 
379
- private fun animateBackgroundColor(fromColor: Int, toColor: Int, loop: Boolean = false) {
544
+ private fun animateBackgroundColor(fromColor: Int, toColor: Int, config: TransitionConfig, loop: Boolean = false) {
380
545
  runningAnimators["backgroundColor"]?.cancel()
381
546
 
382
547
  val batchId = animationBatchId
383
548
  pendingBatchAnimationCount++
384
549
 
385
550
  val animator = ValueAnimator.ofArgb(fromColor, toColor).apply {
386
- duration = transitionDuration.toLong()
551
+ duration = config.duration.toLong()
552
+ startDelay = config.delay
553
+
387
554
  interpolator = PathInterpolator(
388
- transitionEasingBezier[0], transitionEasingBezier[1],
389
- transitionEasingBezier[2], transitionEasingBezier[3]
555
+ config.easingBezier[0], config.easingBezier[1],
556
+ config.easingBezier[2], config.easingBezier[3]
390
557
  )
391
- if (loop && transitionLoop != "none") {
558
+ if (loop && config.loop != "none") {
392
559
  repeatCount = ValueAnimator.INFINITE
393
- repeatMode = if (transitionLoop == "reverse") ValueAnimator.REVERSE else ValueAnimator.RESTART
560
+ repeatMode = if (config.loop == "reverse") ValueAnimator.REVERSE else ValueAnimator.RESTART
394
561
  }
395
562
  addUpdateListener { animation ->
396
563
  val color = animation.animatedValue as Int
@@ -425,16 +592,28 @@ class EaseView(context: Context) : ReactViewGroup(context) {
425
592
  viewProperty: DynamicAnimation.ViewProperty?,
426
593
  fromValue: Float,
427
594
  toValue: Float,
595
+ config: TransitionConfig,
428
596
  loop: Boolean = false
429
597
  ) {
430
- if (transitionType == "spring" && viewProperty != null) {
431
- 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)
432
611
  } else {
433
- animateTiming(propertyName, fromValue, toValue, loop)
612
+ animateTiming(propertyName, fromValue, toValue, config, loop)
434
613
  }
435
614
  }
436
615
 
437
- 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) {
438
617
  cancelSpringForProperty(propertyName)
439
618
  runningAnimators[propertyName]?.cancel()
440
619
 
@@ -442,14 +621,16 @@ class EaseView(context: Context) : ReactViewGroup(context) {
442
621
  pendingBatchAnimationCount++
443
622
 
444
623
  val animator = ObjectAnimator.ofFloat(this, propertyName, fromValue, toValue).apply {
445
- duration = transitionDuration.toLong()
624
+ duration = config.duration.toLong()
625
+ startDelay = config.delay
626
+
446
627
  interpolator = PathInterpolator(
447
- transitionEasingBezier[0], transitionEasingBezier[1],
448
- transitionEasingBezier[2], transitionEasingBezier[3]
628
+ config.easingBezier[0], config.easingBezier[1],
629
+ config.easingBezier[2], config.easingBezier[3]
449
630
  )
450
- if (loop && transitionLoop != "none") {
631
+ if (loop && config.loop != "none") {
451
632
  repeatCount = ObjectAnimator.INFINITE
452
- repeatMode = if (transitionLoop == "reverse") {
633
+ repeatMode = if (config.loop == "reverse") {
453
634
  ObjectAnimator.REVERSE
454
635
  } else {
455
636
  ObjectAnimator.RESTART
@@ -480,25 +661,27 @@ class EaseView(context: Context) : ReactViewGroup(context) {
480
661
  animator.start()
481
662
  }
482
663
 
483
- private fun animateSpring(viewProperty: DynamicAnimation.ViewProperty, toValue: Float) {
664
+ private fun animateSpring(viewProperty: DynamicAnimation.ViewProperty, toValue: Float, config: TransitionConfig) {
484
665
  cancelTimingForViewProperty(viewProperty)
485
666
 
486
- val existingSpring = runningSpringAnimations[viewProperty]
487
- if (existingSpring != null && existingSpring.isRunning) {
488
- existingSpring.animateToFinalPosition(toValue)
489
- 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
+ }
490
672
  }
673
+ runningSpringAnimations.remove(viewProperty)
491
674
 
492
675
  val batchId = animationBatchId
493
676
  pendingBatchAnimationCount++
494
677
 
495
- val dampingRatio = (transitionDamping / (2.0f * sqrt(transitionStiffness * transitionMass)))
678
+ val dampingRatio = (config.damping / (2.0f * sqrt(config.stiffness * config.mass)))
496
679
  .coerceAtLeast(0.01f)
497
680
 
498
681
  val spring = SpringAnimation(this, viewProperty).apply {
499
682
  spring = SpringForce(toValue).apply {
500
683
  this.dampingRatio = dampingRatio
501
- this.stiffness = transitionStiffness
684
+ this.stiffness = config.stiffness
502
685
  }
503
686
  addUpdateListener { _, _, _ ->
504
687
  // First update — enable hardware layer
@@ -520,10 +703,20 @@ class EaseView(context: Context) : ReactViewGroup(context) {
520
703
 
521
704
  onEaseAnimationStart()
522
705
  runningSpringAnimations[viewProperty] = spring
523
- spring.start()
706
+ if (config.delay > 0) {
707
+ val runnable = Runnable { spring.start() }
708
+ pendingDelayedRunnables.add(runnable)
709
+ postDelayed(runnable, config.delay)
710
+ } else {
711
+ spring.start()
712
+ }
524
713
  }
525
714
 
526
715
  private fun cancelAllAnimations() {
716
+ for (runnable in pendingDelayedRunnables) {
717
+ removeCallbacks(runnable)
718
+ }
719
+ pendingDelayedRunnables.clear()
527
720
  for (animator in runningAnimators.values) {
528
721
  animator.cancel()
529
722
  }
@@ -573,6 +766,10 @@ class EaseView(context: Context) : ReactViewGroup(context) {
573
766
  }
574
767
 
575
768
  fun cleanup() {
769
+ for (runnable in pendingDelayedRunnables) {
770
+ removeCallbacks(runnable)
771
+ }
772
+ pendingDelayedRunnables.clear()
576
773
  for (animator in runningAnimators.values) {
577
774
  animator.cancel()
578
775
  }
@@ -613,6 +810,6 @@ class EaseView(context: Context) : ReactViewGroup(context) {
613
810
  applyBackgroundColor(Color.TRANSPARENT)
614
811
 
615
812
  isFirstMount = true
616
- transitionLoop = "none"
813
+ transitionConfigs = emptyMap()
617
814
  }
618
815
  }
@@ -3,6 +3,7 @@ package com.ease
3
3
  import android.graphics.Color
4
4
  import com.facebook.react.bridge.Arguments
5
5
  import com.facebook.react.bridge.ReadableArray
6
+ import com.facebook.react.bridge.ReadableMap
6
7
  import com.facebook.react.bridge.WritableMap
7
8
  import com.facebook.react.module.annotations.ReactModule
8
9
  import com.facebook.react.uimanager.PixelUtil
@@ -126,51 +127,11 @@ class EaseViewManager : ReactViewManager() {
126
127
  view.initialAnimateBorderRadius = PixelUtil.toPixelFromDIP(value)
127
128
  }
128
129
 
129
- // --- Transition config setters ---
130
+ // --- Transitions config (single ReadableMap) ---
130
131
 
131
- @ReactProp(name = "transitionType")
132
- fun setTransitionType(view: EaseView, value: String?) {
133
- view.transitionType = value ?: "timing"
134
- }
135
-
136
- @ReactProp(name = "transitionDuration", defaultInt = 300)
137
- fun setTransitionDuration(view: EaseView, value: Int) {
138
- view.transitionDuration = value
139
- }
140
-
141
- @ReactProp(name = "transitionEasingBezier")
142
- fun setTransitionEasingBezier(view: EaseView, value: ReadableArray?) {
143
- if (value != null && value.size() == 4) {
144
- view.transitionEasingBezier = floatArrayOf(
145
- value.getDouble(0).toFloat(),
146
- value.getDouble(1).toFloat(),
147
- value.getDouble(2).toFloat(),
148
- value.getDouble(3).toFloat()
149
- )
150
- } else {
151
- // Fallback: easeInOut
152
- view.transitionEasingBezier = floatArrayOf(0.42f, 0f, 0.58f, 1.0f)
153
- }
154
- }
155
-
156
- @ReactProp(name = "transitionDamping", defaultFloat = 15f)
157
- fun setTransitionDamping(view: EaseView, value: Float) {
158
- view.transitionDamping = value
159
- }
160
-
161
- @ReactProp(name = "transitionStiffness", defaultFloat = 120f)
162
- fun setTransitionStiffness(view: EaseView, value: Float) {
163
- view.transitionStiffness = value
164
- }
165
-
166
- @ReactProp(name = "transitionMass", defaultFloat = 1f)
167
- fun setTransitionMass(view: EaseView, value: Float) {
168
- view.transitionMass = value
169
- }
170
-
171
- @ReactProp(name = "transitionLoop")
172
- fun setTransitionLoop(view: EaseView, value: String?) {
173
- view.transitionLoop = value ?: "none"
132
+ @ReactProp(name = "transitions")
133
+ fun setTransitions(view: EaseView, value: ReadableMap?) {
134
+ view.setTransitionsFromMap(value)
174
135
  }
175
136
 
176
137
  // --- Border radius ---