Skip to content

Commit 0990a06

Browse files
feat: simplify onTransitionEnd to batch-fire once per animation group
Instead of firing per-property, onTransitionEnd now fires a single event when all animations in a batch complete. Uses batch ID tracking to handle interruptions correctly — starting new animations immediately fires the old batch as interrupted. Also adds transformOrigin prop (0-1 fractions) for controlling the pivot point of scale/rotation animations.
1 parent 3a325f9 commit 0990a06

7 files changed

Lines changed: 190 additions & 78 deletions

File tree

android/src/main/java/com/ease/EaseView.kt

Lines changed: 56 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,27 @@ class EaseView(context: Context) : ReactViewGroup(context) {
3535
var transitionMass: Float = 1.0f
3636
var transitionLoop: String = "none"
3737

38+
// --- Transform origin (0–1 fractions) ---
39+
var transformOriginX: Float = 0.5f
40+
set(value) {
41+
field = value
42+
applyTransformOrigin()
43+
}
44+
var transformOriginY: Float = 0.5f
45+
set(value) {
46+
field = value
47+
applyTransformOrigin()
48+
}
49+
3850
// --- Hardware layer ---
39-
var useHardwareLayer: Boolean = true
51+
var useHardwareLayer: Boolean = false
4052

4153
// --- Event callback ---
42-
var onTransitionEnd: ((property: String, finished: Boolean) -> Unit)? = null
54+
var onTransitionEnd: ((finished: Boolean) -> Unit)? = null
4355
private var activeAnimationCount: Int = 0
56+
private var animationBatchId: Int = 0
57+
private var pendingBatchAnimationCount: Int = 0
58+
private var anyInterrupted: Boolean = false
4459
private var savedLayerType: Int = View.LAYER_TYPE_NONE
4560

4661
// --- Initial animate values (set by ViewManager) ---
@@ -69,16 +84,6 @@ class EaseView(context: Context) : ReactViewGroup(context) {
6984
private val INTERPOLATOR_LINEAR by lazy { LinearInterpolator() }
7085
}
7186

72-
private fun toJsPropertyName(propertyName: String): String? = when (propertyName) {
73-
"alpha" -> "opacity"
74-
"translationX" -> "translateX"
75-
"translationY" -> "translateY"
76-
"scaleX" -> "scale"
77-
"scaleY" -> null // Skip — scaleX already fires for scale
78-
"rotation" -> "rotate"
79-
else -> null
80-
}
81-
8287
private fun getInterpolator(easing: String): TimeInterpolator = when (easing) {
8388
"easeIn" -> INTERPOLATOR_EASE_IN
8489
"easeOut" -> INTERPOLATOR_EASE_OUT
@@ -107,6 +112,20 @@ class EaseView(context: Context) : ReactViewGroup(context) {
107112
}
108113
}
109114

115+
// --- Transform origin ---
116+
117+
fun applyTransformOrigin() {
118+
if (width > 0 && height > 0) {
119+
pivotX = width * transformOriginX
120+
pivotY = height * transformOriginY
121+
}
122+
}
123+
124+
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
125+
super.onLayout(changed, left, top, right, bottom)
126+
applyTransformOrigin()
127+
}
128+
110129
fun applyPendingAnimateValues() {
111130
applyAnimateValues(pendingOpacity, pendingTranslateX, pendingTranslateY, pendingScale, pendingRotate)
112131
}
@@ -118,6 +137,14 @@ class EaseView(context: Context) : ReactViewGroup(context) {
118137
scale: Float,
119138
rotate: Float
120139
) {
140+
if (pendingBatchAnimationCount > 0) {
141+
onTransitionEnd?.invoke(false)
142+
}
143+
144+
animationBatchId++
145+
pendingBatchAnimationCount = 0
146+
anyInterrupted = false
147+
121148
if (isFirstMount) {
122149
isFirstMount = false
123150

@@ -239,6 +266,9 @@ class EaseView(context: Context) : ReactViewGroup(context) {
239266
cancelSpringForProperty(propertyName)
240267
runningAnimators[propertyName]?.cancel()
241268

269+
val batchId = animationBatchId
270+
pendingBatchAnimationCount++
271+
242272
val animator = ObjectAnimator.ofFloat(this, propertyName, fromValue, toValue).apply {
243273
duration = transitionDuration.toLong()
244274
interpolator = getInterpolator(transitionEasing)
@@ -260,8 +290,12 @@ class EaseView(context: Context) : ReactViewGroup(context) {
260290
}
261291
override fun onAnimationEnd(animation: Animator) {
262292
this@EaseView.onEaseAnimationEnd()
263-
toJsPropertyName(propertyName)?.let { jsProp ->
264-
onTransitionEnd?.invoke(jsProp, !cancelled)
293+
if (batchId == animationBatchId) {
294+
if (cancelled) anyInterrupted = true
295+
pendingBatchAnimationCount--
296+
if (pendingBatchAnimationCount <= 0) {
297+
onTransitionEnd?.invoke(!anyInterrupted)
298+
}
265299
}
266300
}
267301
})
@@ -280,6 +314,9 @@ class EaseView(context: Context) : ReactViewGroup(context) {
280314
return
281315
}
282316

317+
val batchId = animationBatchId
318+
pendingBatchAnimationCount++
319+
283320
val dampingRatio = (transitionDamping / (2.0f * sqrt(transitionStiffness * transitionMass)))
284321
.coerceAtLeast(0.01f)
285322

@@ -296,18 +333,11 @@ class EaseView(context: Context) : ReactViewGroup(context) {
296333
}
297334
addEndListener { _, canceled, _, _ ->
298335
this@EaseView.onEaseAnimationEnd()
299-
val viewPropertyName = when (viewProperty) {
300-
DynamicAnimation.ALPHA -> "alpha"
301-
DynamicAnimation.TRANSLATION_X -> "translationX"
302-
DynamicAnimation.TRANSLATION_Y -> "translationY"
303-
DynamicAnimation.SCALE_X -> "scaleX"
304-
DynamicAnimation.SCALE_Y -> "scaleY"
305-
DynamicAnimation.ROTATION -> "rotation"
306-
else -> null
307-
}
308-
viewPropertyName?.let { name ->
309-
toJsPropertyName(name)?.let { jsProp ->
310-
onTransitionEnd?.invoke(jsProp, !canceled)
336+
if (batchId == animationBatchId) {
337+
if (canceled) anyInterrupted = true
338+
pendingBatchAnimationCount--
339+
if (pendingBatchAnimationCount <= 0) {
340+
onTransitionEnd?.invoke(!anyInterrupted)
311341
}
312342
}
313343
}

android/src/main/java/com/ease/EaseViewManager.kt

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package com.ease
22

33
import com.facebook.react.bridge.Arguments
4-
import com.facebook.react.bridge.ReadableArray
54
import com.facebook.react.bridge.WritableMap
65
import com.facebook.react.common.MapBuilder
76
import com.facebook.react.module.annotations.ReactModule
@@ -20,11 +19,11 @@ class EaseViewManager : ReactViewManager() {
2019

2120
override fun createViewInstance(context: ThemedReactContext): EaseView {
2221
val view = EaseView(context)
23-
view.onTransitionEnd = { property, finished ->
22+
view.onTransitionEnd = { finished ->
2423
val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, view.id)
2524
val surfaceId = UIManagerHelper.getSurfaceId(context)
2625
eventDispatcher?.dispatchEvent(
27-
TransitionEndEvent(surfaceId, view.id, property, finished)
26+
TransitionEndEvent(surfaceId, view.id, finished)
2827
)
2928
}
3029
return view
@@ -123,23 +122,21 @@ class EaseViewManager : ReactViewManager() {
123122

124123
// --- Hardware layer ---
125124

126-
@ReactProp(name = "useHardwareLayer", defaultBoolean = true)
125+
@ReactProp(name = "useHardwareLayer", defaultBoolean = false)
127126
fun setUseHardwareLayer(view: EaseView, value: Boolean) {
128127
view.useHardwareLayer = value
129128
}
130129

131-
// --- Prevent BaseViewManager from resetting animated properties ---
130+
// --- Transform origin ---
132131

133-
override fun setTransformProperty(
134-
view: ReactViewGroup,
135-
transforms: ReadableArray?,
136-
transformOrigin: ReadableArray?
137-
) {
138-
// No-op: we manage translationX/Y, scaleX/Y, rotation ourselves via animations.
132+
@ReactProp(name = "transformOriginX", defaultFloat = 0.5f)
133+
fun setTransformOriginX(view: EaseView, value: Float) {
134+
view.transformOriginX = value
139135
}
140136

141-
override fun setOpacity(view: ReactViewGroup, opacity: Float) {
142-
// No-op: we manage opacity ourselves via animations.
137+
@ReactProp(name = "transformOriginY", defaultFloat = 0.5f)
138+
fun setTransformOriginY(view: EaseView, value: Float) {
139+
view.transformOriginY = value
143140
}
144141

145142
// --- Lifecycle ---
@@ -163,12 +160,10 @@ class EaseViewManager : ReactViewManager() {
163160
private class TransitionEndEvent(
164161
surfaceId: Int,
165162
viewId: Int,
166-
private val property: String,
167163
private val finished: Boolean
168164
) : Event<TransitionEndEvent>(surfaceId, viewId) {
169165
override fun getEventName() = "onTransitionEnd"
170166
override fun getEventData(): WritableMap = Arguments.createMap().apply {
171-
putString("property", property)
172167
putBoolean("finished", finished)
173168
}
174169
}

ios/EaseView.mm

Lines changed: 79 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ static inline CGFloat degreesToRadians(CGFloat degrees) {
4040

4141
@implementation EaseView {
4242
BOOL _isFirstMount;
43+
NSInteger _animationBatchId;
44+
NSInteger _pendingAnimationCount;
45+
BOOL _anyInterrupted;
46+
CGFloat _transformOriginX;
47+
CGFloat _transformOriginY;
4348
}
4449

4550
+ (ComponentDescriptorProvider)componentDescriptorProvider {
@@ -51,10 +56,51 @@ - (instancetype)initWithFrame:(CGRect)frame {
5156
static const auto defaultProps = std::make_shared<const EaseViewProps>();
5257
_props = defaultProps;
5358
_isFirstMount = YES;
59+
_transformOriginX = 0.5;
60+
_transformOriginY = 0.5;
5461
}
5562
return self;
5663
}
5764

65+
#pragma mark - Transform origin
66+
67+
- (void)updateAnchorPoint {
68+
CGPoint newAnchor = CGPointMake(_transformOriginX, _transformOriginY);
69+
if (CGPointEqualToPoint(newAnchor, self.layer.anchorPoint)) {
70+
return;
71+
}
72+
CGPoint oldAnchor = self.layer.anchorPoint;
73+
CGSize size = self.layer.bounds.size;
74+
CGPoint pos = self.layer.position;
75+
pos.x += (newAnchor.x - oldAnchor.x) * size.width;
76+
pos.y += (newAnchor.y - oldAnchor.y) * size.height;
77+
self.layer.anchorPoint = newAnchor;
78+
self.layer.position = pos;
79+
}
80+
81+
- (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics
82+
oldLayoutMetrics:(const LayoutMetrics &)oldLayoutMetrics {
83+
// Temporarily reset to default anchorPoint so super's frame setting
84+
// computes position correctly, then re-apply our custom anchorPoint.
85+
CGPoint customAnchor = self.layer.anchorPoint;
86+
BOOL hasCustomAnchor =
87+
!CGPointEqualToPoint(customAnchor, CGPointMake(0.5, 0.5));
88+
if (hasCustomAnchor) {
89+
self.layer.anchorPoint = CGPointMake(0.5, 0.5);
90+
}
91+
92+
[super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics];
93+
94+
if (hasCustomAnchor) {
95+
CGSize size = self.layer.bounds.size;
96+
CGPoint pos = self.layer.position;
97+
pos.x += (customAnchor.x - 0.5) * size.width;
98+
pos.y += (customAnchor.y - 0.5) * size.height;
99+
self.layer.anchorPoint = customAnchor;
100+
self.layer.position = pos;
101+
}
102+
}
103+
58104
#pragma mark - Animation helpers
59105

60106
- (NSValue *)presentationValueForKeyPath:(NSString *)keyPath {
@@ -112,7 +158,8 @@ - (void)applyAnimationForKeyPath:(NSString *)keyPath
112158
toValue:toValue
113159
props:props
114160
loop:loop];
115-
[animation setValue:animationKey forKey:@"easeAnimKey"];
161+
_pendingAnimationCount++;
162+
[animation setValue:@(_animationBatchId) forKey:@"easeBatchId"];
116163
animation.delegate = self;
117164
[self.layer addAnimation:animation forKey:animationKey];
118165
}
@@ -131,6 +178,25 @@ - (void)updateProps:(const Props::Shared &)props
131178
[CATransaction begin];
132179
[CATransaction setDisableActions:YES];
133180

181+
if (_transformOriginX != newViewProps.transformOriginX ||
182+
_transformOriginY != newViewProps.transformOriginY) {
183+
_transformOriginX = newViewProps.transformOriginX;
184+
_transformOriginY = newViewProps.transformOriginY;
185+
[self updateAnchorPoint];
186+
}
187+
188+
if (_pendingAnimationCount > 0 && _eventEmitter) {
189+
auto emitter =
190+
std::static_pointer_cast<const EaseViewEventEmitter>(_eventEmitter);
191+
emitter->onTransitionEnd(EaseViewEventEmitter::OnTransitionEnd{
192+
.finished = false,
193+
});
194+
}
195+
196+
_animationBatchId++;
197+
_pendingAnimationCount = 0;
198+
_anyInterrupted = NO;
199+
134200
if (_isFirstMount) {
135201
_isFirstMount = NO;
136202

@@ -291,34 +357,20 @@ - (void)updateProps:(const Props::Shared &)props
291357
#pragma mark - CAAnimationDelegate
292358

293359
- (void)animationDidStop:(CAAnimation *)anim finished:(BOOL)flag {
294-
NSString *animKey = [anim valueForKey:@"easeAnimKey"];
295-
if (!animKey || !_eventEmitter) {
360+
NSNumber *batchId = [anim valueForKey:@"easeBatchId"];
361+
if (!batchId || batchId.integerValue != _animationBatchId || !_eventEmitter) {
296362
return;
297363
}
298364

299-
// Map animation key to JS property name
300-
NSString *jsProperty = nil;
301-
if ([animKey isEqualToString:kAnimKeyOpacity]) {
302-
jsProperty = @"opacity";
303-
} else if ([animKey isEqualToString:kAnimKeyTranslateX]) {
304-
jsProperty = @"translateX";
305-
} else if ([animKey isEqualToString:kAnimKeyTranslateY]) {
306-
jsProperty = @"translateY";
307-
} else if ([animKey isEqualToString:kAnimKeyScaleX]) {
308-
jsProperty = @"scale";
309-
} else if ([animKey isEqualToString:kAnimKeyScaleY]) {
310-
// Skip — scaleX already fires for scale
311-
return;
312-
} else if ([animKey isEqualToString:kAnimKeyRotate]) {
313-
jsProperty = @"rotate";
365+
if (!flag) {
366+
_anyInterrupted = YES;
314367
}
315-
316-
if (jsProperty) {
368+
_pendingAnimationCount--;
369+
if (_pendingAnimationCount <= 0) {
317370
auto emitter =
318371
std::static_pointer_cast<const EaseViewEventEmitter>(_eventEmitter);
319372
emitter->onTransitionEnd(EaseViewEventEmitter::OnTransitionEnd{
320-
.property = std::string([jsProperty UTF8String]),
321-
.finished = static_cast<bool>(flag),
373+
.finished = !_anyInterrupted,
322374
});
323375
}
324376
}
@@ -327,6 +379,11 @@ - (void)prepareForRecycle {
327379
[super prepareForRecycle];
328380
[self.layer removeAllAnimations];
329381
_isFirstMount = YES;
382+
_pendingAnimationCount = 0;
383+
_anyInterrupted = NO;
384+
_transformOriginX = 0.5;
385+
_transformOriginY = 0.5;
386+
self.layer.anchorPoint = CGPointMake(0.5, 0.5);
330387
}
331388

332389
@end

0 commit comments

Comments
 (0)