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 +116 -0
- package/android/src/main/java/com/ease/EaseView.kt +261 -82
- package/android/src/main/java/com/ease/EaseViewManager.kt +5 -49
- package/ios/EaseView.mm +275 -79
- package/lib/module/EaseView.js +85 -28
- package/lib/module/EaseView.js.map +1 -1
- package/lib/module/EaseView.web.js +167 -26
- package/lib/module/EaseView.web.js.map +1 -1
- package/lib/module/EaseViewNativeComponent.ts +24 -16
- package/lib/typescript/src/EaseView.d.ts +2 -0
- package/lib/typescript/src/EaseView.d.ts.map +1 -1
- package/lib/typescript/src/EaseView.web.d.ts.map +1 -1
- package/lib/typescript/src/EaseViewNativeComponent.d.ts +20 -8
- package/lib/typescript/src/EaseViewNativeComponent.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +1 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/types.d.ts +17 -2
- package/lib/typescript/src/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/skills/react-native-ease-refactor/SKILL.md +8 -2
- package/src/EaseView.tsx +116 -53
- package/src/EaseView.web.tsx +209 -42
- package/src/EaseViewNativeComponent.ts +24 -16
- package/src/index.tsx +2 -0
- package/src/types.ts +22 -2
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
|
+

|
|
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
|
+

|
|
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
|
|
38
|
-
var
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 (
|
|
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
|
-
|
|
299
|
-
|
|
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
|
-
|
|
304
|
-
|
|
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
|
-
|
|
309
|
-
|
|
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
|
-
|
|
314
|
-
|
|
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
|
-
|
|
319
|
-
|
|
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
|
-
|
|
324
|
-
|
|
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
|
-
|
|
329
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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 =
|
|
389
|
-
startDelay =
|
|
551
|
+
duration = config.duration.toLong()
|
|
552
|
+
startDelay = config.delay
|
|
553
|
+
|
|
390
554
|
interpolator = PathInterpolator(
|
|
391
|
-
|
|
392
|
-
|
|
555
|
+
config.easingBezier[0], config.easingBezier[1],
|
|
556
|
+
config.easingBezier[2], config.easingBezier[3]
|
|
393
557
|
)
|
|
394
|
-
if (loop &&
|
|
558
|
+
if (loop && config.loop != "none") {
|
|
395
559
|
repeatCount = ValueAnimator.INFINITE
|
|
396
|
-
repeatMode = if (
|
|
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 (
|
|
434
|
-
|
|
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 =
|
|
449
|
-
startDelay =
|
|
624
|
+
duration = config.duration.toLong()
|
|
625
|
+
startDelay = config.delay
|
|
626
|
+
|
|
450
627
|
interpolator = PathInterpolator(
|
|
451
|
-
|
|
452
|
-
|
|
628
|
+
config.easingBezier[0], config.easingBezier[1],
|
|
629
|
+
config.easingBezier[2], config.easingBezier[3]
|
|
453
630
|
)
|
|
454
|
-
if (loop &&
|
|
631
|
+
if (loop && config.loop != "none") {
|
|
455
632
|
repeatCount = ObjectAnimator.INFINITE
|
|
456
|
-
repeatMode = if (
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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 = (
|
|
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 =
|
|
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 (
|
|
706
|
+
if (config.delay > 0) {
|
|
528
707
|
val runnable = Runnable { spring.start() }
|
|
529
708
|
pendingDelayedRunnables.add(runnable)
|
|
530
|
-
postDelayed(runnable,
|
|
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
|
-
|
|
813
|
+
transitionConfigs = emptyMap()
|
|
635
814
|
}
|
|
636
815
|
}
|