From 718ec0f1d09f11533cc72ddc8e43a1bbaace5523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Fri, 31 Jul 2026 20:43:03 +0200 Subject: [PATCH 1/5] Drive platform CSS transitions from a single frame pump --- .../css/CSSPlatformTransitionReconciler.kt | 5 +- .../css/CSSPlatformTransitionsManager.kt | 324 ++++++++++++------ .../swmansion/reanimated/css/DurationScale.kt | 7 +- 3 files changed, 225 insertions(+), 111 deletions(-) diff --git a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionReconciler.kt b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionReconciler.kt index 6ca8ab015386..8685205be3bc 100644 --- a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionReconciler.kt +++ b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionReconciler.kt @@ -29,9 +29,8 @@ internal class CSSPlatformTransitionReconciler( override fun onPreDraw(): Boolean { if (repair()) return true - // Retiring here rather than when the registry empties: cancelling an - // animator runs its end callback part way through installing the - // replacement, when the registry is briefly empty. + // Retiring lazily at draw time keeps the lifecycle one-sided: the + // manager only ever tracks, and a registry left empty retires here. if (observer.isAlive) observer.removeOnPreDrawListener(this) tracked.remove(observer) return true diff --git a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionsManager.kt b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionsManager.kt index 1ed140f1438b..460491d3a115 100644 --- a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionsManager.kt +++ b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionsManager.kt @@ -1,8 +1,5 @@ package com.swmansion.reanimated.css -import android.animation.Animator -import android.animation.AnimatorListenerAdapter -import android.animation.ObjectAnimator import android.animation.TimeInterpolator import android.util.FloatProperty import android.view.Choreographer @@ -18,7 +15,7 @@ internal class CSSPlatformTransitionsManager( private val reactContext: WeakReference, private val animationTimestamp: () -> Long, ) { - private val animators = HashMap() + private val transitions = HashMap() private val reconciler = CSSPlatformTransitionReconciler(::repairClobberedValues) private val startTokens = HashMap() @@ -32,34 +29,75 @@ internal class CSSPlatformTransitionsManager( ) private class RunningTransition( - val animator: ObjectAnimator, + view: View, val writer: FloatProperty, val startValue: Float, + val toValue: Float, + val startTimeMs: Double, + val durationMs: Double, + val interpolator: TimeInterpolator, + val persistent: Boolean, ) { - /** Final value of a finished persistent transition, which outlives its animator. */ - var heldValue: Float? = null - - /** - * ObjectAnimator leaves its animated value uninitialised until its first frame, which - * a start delay defers, so until then the property must show the start value. - */ - fun currentValue(): Float = heldValue ?: if (animator.isRunning) animator.animatedValue as Float else startValue - } + val viewRef = WeakReference(view) - /** Holds the start value for the leading [delayFraction], then plays [inner] over the rest. */ - private class HoldThenEase( - private val delayFraction: Float, - private val inner: TimeInterpolator, - ) : TimeInterpolator { - override fun getInterpolation(input: Float): Float = - if (input <= delayFraction) 0f else inner.getInterpolation((input - delayFraction) / (1f - delayFraction)) + /** Last value this manager wrote; the repair pass re-asserts it after commits. */ + var current: Float = startValue + + /** A finished persistent transition stays registered so its value keeps being defended. */ + var finished: Boolean = false } - private data class InterpolatorKey( - val type: Int, - val pointsX: List, - val pointsY: List, - ) + private var pumping = false + + /** + * Single per-frame driver for every running transition. The absolute start timestamp + * makes delay and late-start seeking fall out of the arithmetic: t < 0 during the + * delay clamps to 0 and holds the start value, and a late first frame lands mid-curve + * instead of shifting the whole timeline. + */ + private val framePump = + object : Choreographer.FrameCallback { + override fun doFrame(frameTimeNanos: Long) { + val now = animationTimestamp().toDouble() + var active = false + val iterator = transitions.entries.iterator() + while (iterator.hasNext()) { + val running = iterator.next().value + if (running.finished) continue + val view = running.viewRef.get() + if (view == null) { + iterator.remove() + continue + } + val t = ((now - running.startTimeMs) / running.durationMs).toFloat().coerceIn(0f, 1f) + val value = + running.startValue + + (running.toValue - running.startValue) * running.interpolator.getInterpolation(t) + running.current = value + // setValue takes a primitive and View.setAlpha early-outs on an unchanged + // value, so unconditional writes allocate nothing and invalidate only on + // real changes. + running.writer.setValue(view, value) + if (t >= 1f) { + if (running.persistent) running.finished = true else iterator.remove() + } else { + active = true + } + } + if (active) { + Choreographer.getInstance().postFrameCallback(this) + } else { + pumping = false + } + } + } + + private fun ensurePumping() { + if (!pumping) { + pumping = true + Choreographer.getInstance().postFrameCallback(framePump) + } + } /** * Returns whether the property is accepted for native playback, decided before the @@ -81,24 +119,27 @@ internal class CSSPlatformTransitionsManager( ): Boolean { val writer = cssPropertyWriterFor(propertyName) ?: return false val context = reactContext.get() ?: return false - val scale = DurationScale.effectiveScale(context) - // Scale 0 means animations are off, and ValueAnimator would finish instantly. - if (scale <= 0f) return false - - UiThreadUtil.runOnUiThread { - val key = Key(viewTag, propertyName) - // Claiming a fresh token invalidates any retry still queued for this key, - // so a superseded request cannot start on a later frame. - val token = ++nextStartToken - startTokens[key] = token - - beginWhenMounted(key, token, startTimestampMs + durationMs) { - viewForTag(viewTag)?.also { view -> - val interpolator = interpolatorFor(easingType, easingPointsX, easingPointsY) - start(view, key, writer, fromValue, toValue, durationMs, startTimestampMs, interpolator, scale, persistent) - } != null - } - } + // Scale 0 means animations are off; refusing sends the property to the loop, which + // resolves the final style without animating. Other scales are not applied here: + // platform transitions follow the authored CSS timeline, and the slow-animations + // toggle already flows through the shared animation clock. + if (DurationScale.effectiveScale(context) <= 0f) return false + + enqueue( + Command.Start( + viewTag, + propertyName, + writer, + fromValue, + toValue, + durationMs, + startTimestampMs, + easingType, + easingPointsX, + easingPointsY, + persistent, + ), + ) return true } @@ -106,11 +147,93 @@ internal class CSSPlatformTransitionsManager( viewTag: Int, propertyName: String, ) { - UiThreadUtil.runOnUiThread { - val key = Key(viewTag, propertyName) - // Also drops any queued retry for this key. - startTokens.remove(key) - animators.remove(key)?.animator?.cancel() + enqueue(Command.Remove(viewTag, propertyName)) + } + + private sealed class Command { + class Start( + val viewTag: Int, + val propertyName: String, + val writer: FloatProperty, + val fromValue: Double, + val toValue: Double, + val durationMs: Double, + val startTimestampMs: Double, + val easingType: Int, + val pointsX: FloatArray, + val pointsY: FloatArray, + val persistent: Boolean, + ) : Command() + + class Remove( + val viewTag: Int, + val propertyName: String, + ) : Command() + } + + private val commandLock = Any() + private var pendingCommands = ArrayList() + private var spareCommands = ArrayList() + private var flushScheduled = false + + /** + * One main-looper message per burst instead of one per transition: a render that + * starts hundreds of transitions would otherwise flood the queue, and by the time + * late messages ran their transitions would already be stale. + */ + private fun enqueue(command: Command) { + val schedule: Boolean + synchronized(commandLock) { + pendingCommands.add(command) + schedule = !flushScheduled + if (schedule) flushScheduled = true + } + if (schedule) UiThreadUtil.runOnUiThread { flushCommands() } + } + + private fun flushCommands() { + val batch: ArrayList + synchronized(commandLock) { + batch = pendingCommands + pendingCommands = spareCommands + flushScheduled = false + } + for (command in batch) { + when (command) { + is Command.Start -> beginTransition(command) + is Command.Remove -> { + val key = Key(command.viewTag, command.propertyName) + // Also drops any queued retry for this key. + startTokens.remove(key) + transitions.remove(key) + } + } + } + batch.clear() + spareCommands = batch + } + + private fun beginTransition(command: Command.Start) { + val key = Key(command.viewTag, command.propertyName) + // Claiming a fresh token invalidates any retry still queued for this key, so a + // superseded request cannot start on a later frame. + val token = ++nextStartToken + startTokens[key] = token + beginWhenMounted(key, token, command.startTimestampMs + command.durationMs) { + viewForTag(command.viewTag)?.also { view -> + val interpolator = interpolatorFor(command.easingType, command.pointsX, command.pointsY) + start( + view, + key, + command.writer, + command.fromValue, + command.toValue, + command.durationMs, + command.startTimestampMs, + interpolator, + command.persistent, + ) + } != null } } @@ -143,69 +266,62 @@ internal class CSSPlatformTransitionsManager( durationMs: Double, startTimestampMs: Double, interpolator: TimeInterpolator, - scale: Float, persistent: Boolean, ) { - // On interruption resume from what is on screen: fromValue is the committed - // style value, so starting there would snap the view back to where the - // cancelled transition began. - val interrupted = animators.remove(key) - val startValue = if (interrupted != null) writer.get(view) else fromValue.toFloat() - interrupted?.animator?.cancel() - - // React Native has already committed the target, and ObjectAnimator writes nothing - // until its first frame - which transition-delay defers. Without this the view - // would show the target for the whole delay and then jump back. + // On interruption resume from the value this engine last wrote: fromValue is the + // committed style value, so starting there would snap the view back to where the + // superseded transition began. + val startValue = transitions.remove(key)?.current ?: fromValue.toFloat() + + // React Native has already committed the target; show the start value until the + // first pump frame (and through any transition-delay). writer.setValue(view, startValue) - val animator = ObjectAnimator.ofFloat(view, writer, startValue, toValue.toFloat()) - animator.addListener( - object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - // A replacement may already own the key. - val running = animators[key] ?: return - if (running.animator !== animation) return - // A persistent value has no committed style behind it, so dropping the entry - // would let the next commit revert the view. - if (persistent) { - running.heldValue = animator.animatedValue as Float - } else { - animators.remove(key) - } - } - }, - ) - // ObjectAnimator has no absolute start time, so resolve one against the clock - // here rather than in C++: the hop to this thread and the wait for the next - // frame would otherwise shift the whole timeline late. - val elapsedMs = animationTimestamp().toDouble() - startTimestampMs - val delayMs = if (elapsedMs < 0) -elapsedMs else 0.0 - // Play through the delay rather than using startDelay. A delayed ObjectAnimator - // writes nothing while it waits, so any commit landing in that window stays on - // screen; holding the start value inside the curve makes the animator rewrite the - // property every frame instead. This is what kCAFillModeBackwards gives us on Apple. - animator.duration = ((delayMs + durationMs) / scale).toLong().coerceAtLeast(1L) - animator.interpolator = - if (delayMs > 0) HoldThenEase((delayMs / (delayMs + durationMs)).toFloat(), interpolator) else interpolator - if (elapsedMs > 0 && durationMs > 0) { - // Seek before starting: ValueAnimator.start() gates on `mSeekFraction >= 0` when - // deciding whether to begin immediately, so a later seek races the first frame. - animator.setCurrentFraction((elapsedMs / durationMs).toFloat().coerceIn(0f, 1f)) - } - animator.start() - animators[key] = RunningTransition(animator, writer, startValue) + transitions[key] = + RunningTransition( + view, + writer, + startValue, + toValue.toFloat(), + startTimestampMs, + durationMs.coerceAtLeast(1.0), + interpolator, + persistent, + ) reconciler.track(view) + ensurePumping() } - /** Re-asserts each animator's own value wherever a commit overwrote it. */ + /** Re-asserts each transition's own value wherever a commit overwrote it. */ private fun repairClobberedValues(): Boolean { - animators.values.forEach { running -> - // target is held weakly, so read the View through it rather than keeping one. - val view = running.animator.target as? View ?: return@forEach - val current = running.currentValue() - if (running.writer.get(view) != current) running.writer.setValue(view, current) + val iterator = transitions.values.iterator() + while (iterator.hasNext()) { + val running = iterator.next() + val view = running.viewRef.get() + if (view == null) { + iterator.remove() + continue + } + // setValue takes a primitive and setAlpha early-outs when unchanged, so an + // unconditional re-assert is allocation-free. + running.writer.setValue(view, running.current) } - return animators.isNotEmpty() + return transitions.isNotEmpty() + } + + /** Compares the point arrays by content, so probing the cache allocates only the key. */ + private class InterpolatorKey( + private val type: Int, + private val pointsX: FloatArray, + private val pointsY: FloatArray, + ) { + override fun equals(other: Any?): Boolean = + other is InterpolatorKey && + type == other.type && + pointsX.contentEquals(other.pointsX) && + pointsY.contentEquals(other.pointsY) + + override fun hashCode(): Int = 31 * (31 * type + pointsX.contentHashCode()) + pointsY.contentHashCode() } /** @@ -218,7 +334,7 @@ internal class CSSPlatformTransitionsManager( pointsX: FloatArray, pointsY: FloatArray, ): TimeInterpolator { - val key = InterpolatorKey(type, pointsX.toList(), pointsY.toList()) + val key = InterpolatorKey(type, pointsX, pointsY) return interpolators.getOrPut(key) { CSSEasing.interpolator(type, pointsX, pointsY) } } diff --git a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/DurationScale.kt b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/DurationScale.kt index ff0597393045..c667f317dcae 100644 --- a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/DurationScale.kt +++ b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/DurationScale.kt @@ -6,10 +6,9 @@ import android.os.Build import android.provider.Settings /** - * `ValueAnimator` multiplies every duration and start delay by a process-global scale, - * but a CSS transition has to last exactly as long as the author wrote, so callers - * pre-divide by this. `overrideDurationScale` would opt a single animator out, but it - * is `@hide` with no greylist entry, and the only other setter is process-global. + * The process-global animator duration scale. A CSS transition follows its authored + * timeline, so callers only consult this for the zero case, where animations are + * disabled entirely and the property has to settle without animating. */ internal object DurationScale { fun effectiveScale(context: Context): Float { From 09c287d299afc27f535347da359cf09b5e48b973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Sat, 1 Aug 2026 11:23:00 +0200 Subject: [PATCH 2/5] Hold raw start values through delays and settle removed transitions --- .../css/CSSPlatformTransitionReconciler.kt | 18 ++++++++ .../css/CSSPlatformTransitionsManager.kt | 41 ++++++++++++++----- .../swmansion/reanimated/css/DurationScale.kt | 18 +++----- 3 files changed, 54 insertions(+), 23 deletions(-) diff --git a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionReconciler.kt b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionReconciler.kt index 8685205be3bc..7a472a9c75c3 100644 --- a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionReconciler.kt +++ b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionReconciler.kt @@ -18,6 +18,24 @@ internal class CSSPlatformTransitionReconciler( private val tracked = HashSet() fun track(view: View) { + if (!view.isAttachedToWindow) { + // A detached view hands out a floating observer that merges into the window + // observer on attach and dies, leaving the listener unremovable; register + // once the view is genuinely attached. + view.addOnAttachStateChangeListener( + object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(attached: View) { + attached.removeOnAttachStateChangeListener(this) + track(attached) + } + + override fun onViewDetachedFromWindow(detached: View) { + detached.removeOnAttachStateChangeListener(this) + } + }, + ) + return + } // A window torn down mid-animation never draws again, so its listener never retires. tracked.removeAll { !it.isAlive } diff --git a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionsManager.kt b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionsManager.kt index 460491d3a115..9e95bf500d06 100644 --- a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionsManager.kt +++ b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionsManager.kt @@ -63,13 +63,22 @@ internal class CSSPlatformTransitionsManager( val iterator = transitions.entries.iterator() while (iterator.hasNext()) { val running = iterator.next().value - if (running.finished) continue val view = running.viewRef.get() if (view == null) { iterator.remove() continue } - val t = ((now - running.startTimeMs) / running.durationMs).toFloat().coerceIn(0f, 1f) + if (running.finished) continue + val elapsedMs = now - running.startTimeMs + if (elapsedMs < 0) { + // Delay phase: hold the raw start value. Step-like easings are + // nonzero already at t = 0, so this cannot go through the curve. + running.current = running.startValue + running.writer.setValue(view, running.startValue) + active = true + continue + } + val t = (elapsedMs / running.durationMs).toFloat().coerceAtMost(1f) val value = running.startValue + (running.toValue - running.startValue) * running.interpolator.getInterpolation(t) @@ -119,11 +128,11 @@ internal class CSSPlatformTransitionsManager( ): Boolean { val writer = cssPropertyWriterFor(propertyName) ?: return false val context = reactContext.get() ?: return false - // Scale 0 means animations are off; refusing sends the property to the loop, which - // resolves the final style without animating. Other scales are not applied here: - // platform transitions follow the authored CSS timeline, and the slow-animations - // toggle already flows through the shared animation clock. - if (DurationScale.effectiveScale(context) <= 0f) return false + // Refusing sends the property to the loop, which resolves the final style without + // animating. The animator duration scale is otherwise not applied: platform + // transitions follow the authored CSS timeline, and the slow-animations toggle + // already flows through the shared animation clock. + if (!DurationScale.animationsEnabled(context)) return false enqueue( Command.Start( @@ -205,7 +214,14 @@ internal class CSSPlatformTransitionsManager( val key = Key(command.viewTag, command.propertyName) // Also drops any queued retry for this key. startTokens.remove(key) - transitions.remove(key) + val dropped = transitions.remove(key) + // The committed style already holds the target, but nothing re-writes + // it to the view once this entry stops being driven; without a final + // write the view would stick at the mid-flight value. A persistent + // value has no committed style to settle to, so it is left as drawn. + if (dropped != null && !dropped.persistent) { + dropped.viewRef.get()?.let { dropped.writer.setValue(it, dropped.toValue) } + } } } } @@ -219,7 +235,7 @@ internal class CSSPlatformTransitionsManager( // superseded request cannot start on a later frame. val token = ++nextStartToken startTokens[key] = token - beginWhenMounted(key, token, command.startTimestampMs + command.durationMs) { + beginWhenMounted(key, token, command.startTimestampMs + command.durationMs, command.persistent) { viewForTag(command.viewTag)?.also { view -> val interpolator = interpolatorFor(command.easingType, command.pointsX, command.pointsY) start( @@ -247,14 +263,17 @@ internal class CSSPlatformTransitionsManager( key: Key, token: Long, endTimestampMs: Double, + persistent: Boolean, begin: () -> Boolean, ) { if (startTokens[key] != token) return - if (begin() || animationTimestamp() >= endTimestampMs) { + // A persistent value outlives its own timeline, so its start never expires; it + // retries until the view mounts or the key is superseded or removed. + if (begin() || (!persistent && animationTimestamp() >= endTimestampMs)) { startTokens.remove(key) return } - Choreographer.getInstance().postFrameCallback { beginWhenMounted(key, token, endTimestampMs, begin) } + Choreographer.getInstance().postFrameCallback { beginWhenMounted(key, token, endTimestampMs, persistent, begin) } } private fun start( diff --git a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/DurationScale.kt b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/DurationScale.kt index c667f317dcae..ce423694ca01 100644 --- a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/DurationScale.kt +++ b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/DurationScale.kt @@ -6,24 +6,18 @@ import android.os.Build import android.provider.Settings /** - * The process-global animator duration scale. A CSS transition follows its authored - * timeline, so callers only consult this for the zero case, where animations are - * disabled entirely and the property has to settle without animating. + * Animations can be disabled process-wide (animator duration scale 0, or battery saver); + * a CSS transition then has to settle on its final style without animating. */ internal object DurationScale { - fun effectiveScale(context: Context): Float { - // Battery saver disables animations without touching Settings.Global, and - // areAnimatorsEnabled() (literally `scale != 0`) is the only signal for it. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !ValueAnimator.areAnimatorsEnabled()) { - return 0f - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - return ValueAnimator.getDurationScale() + fun animationsEnabled(context: Context): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + return ValueAnimator.areAnimatorsEnabled() } return Settings.Global.getFloat( context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f, - ) + ) > 0f } } From b621d3842d53a43c30c629c2080644d346e64818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Mon, 3 Aug 2026 07:53:53 +0200 Subject: [PATCH 3/5] Guard command races and cap reconciler retention in the frame pump --- .../css/CSSPlatformTransitionReconciler.kt | 19 +- .../css/CSSPlatformTransitionsManager.kt | 213 +++++++++++------- 2 files changed, 145 insertions(+), 87 deletions(-) diff --git a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionReconciler.kt b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionReconciler.kt index 7a472a9c75c3..040524b676e2 100644 --- a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionReconciler.kt +++ b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionReconciler.kt @@ -2,6 +2,8 @@ package com.swmansion.reanimated.css import android.view.View import android.view.ViewTreeObserver +import java.util.Collections +import java.util.WeakHashMap /** * A platform transition animates the property React Native itself writes, so a commit can @@ -14,23 +16,36 @@ import android.view.ViewTreeObserver internal class CSSPlatformTransitionReconciler( private val repair: () -> Boolean, ) { - /** Keyed by window: getViewTreeObserver is per window, not per view. */ - private val tracked = HashSet() + /** + * Keyed by window: getViewTreeObserver is per window, not per view. Weakly held, + * because a destroyed window's observer never reports !isAlive (only floating + * observers are killed, on merge) and its listener never retires (a dead window + * never draws), so a strong set would pin one observer per torn-down window. + */ + private val tracked: MutableSet = + Collections.newSetFromMap(WeakHashMap()) + + /** Views awaiting attach, so repeated starts do not stack one listener each. */ + private val pendingAttach: MutableSet = + Collections.newSetFromMap(WeakHashMap()) fun track(view: View) { if (!view.isAttachedToWindow) { // A detached view hands out a floating observer that merges into the window // observer on attach and dies, leaving the listener unremovable; register // once the view is genuinely attached. + if (!pendingAttach.add(view)) return view.addOnAttachStateChangeListener( object : View.OnAttachStateChangeListener { override fun onViewAttachedToWindow(attached: View) { attached.removeOnAttachStateChangeListener(this) + pendingAttach.remove(attached) track(attached) } override fun onViewDetachedFromWindow(detached: View) { detached.removeOnAttachStateChangeListener(this) + pendingAttach.remove(detached) } }, ) diff --git a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionsManager.kt b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionsManager.kt index 9e95bf500d06..35684da9f747 100644 --- a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionsManager.kt +++ b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionsManager.kt @@ -1,11 +1,13 @@ package com.swmansion.reanimated.css import android.animation.TimeInterpolator +import android.os.Handler +import android.os.Looper +import android.os.Message import android.util.FloatProperty import android.view.Choreographer import android.view.View import com.facebook.react.bridge.ReactApplicationContext -import com.facebook.react.bridge.UiThreadUtil import com.facebook.react.fabric.FabricUIManager import com.facebook.react.uimanager.IllegalViewOperationException import java.lang.ref.WeakReference @@ -21,34 +23,21 @@ internal class CSSPlatformTransitionsManager( /** Monotonic so a token is never reused, even after its entry is dropped. */ private var nextStartToken = 0L - private val interpolators = HashMap() - private data class Key( - val viewTag: Int, - val propertyName: String, - ) - - private class RunningTransition( - view: View, - val writer: FloatProperty, - val startValue: Float, - val toValue: Float, - val startTimeMs: Double, - val durationMs: Double, - val interpolator: TimeInterpolator, - val persistent: Boolean, - ) { - val viewRef = WeakReference(view) - - /** Last value this manager wrote; the repair pass re-asserts it after commits. */ - var current: Float = startValue - - /** A finished persistent transition stays registered so its value keeps being defended. */ - var finished: Boolean = false - } + /** Access-ordered and capped: runtime-computed easing points would otherwise grow it forever. */ + private val interpolators = + object : LinkedHashMap(16, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean = size > 64 + } private var pumping = false + private val commandLock = Any() + private var pendingCommands = ArrayList() + private var spareCommands = ArrayList() + private var flushScheduled = false + private val mainHandler = Handler(Looper.getMainLooper()) + /** * Single per-frame driver for every running transition. The absolute start timestamp * makes delay and late-start seeking fall out of the arithmetic: t < 0 during the @@ -101,11 +90,68 @@ internal class CSSPlatformTransitionsManager( } } - private fun ensurePumping() { - if (!pumping) { - pumping = true - Choreographer.getInstance().postFrameCallback(framePump) - } + private data class Key( + val viewTag: Int, + val propertyName: String, + ) + + private class RunningTransition( + view: View, + val writer: FloatProperty, + val startValue: Float, + val toValue: Float, + val startTimeMs: Double, + val durationMs: Double, + val interpolator: TimeInterpolator, + val persistent: Boolean, + ) { + val viewRef = WeakReference(view) + + /** Last value this manager wrote; the repair pass re-asserts it after commits. */ + var current: Float = startValue + + /** A finished persistent transition stays registered so its value keeps being defended. */ + var finished: Boolean = false + } + + private sealed class Command { + class Start( + val viewTag: Int, + val propertyName: String, + val writer: FloatProperty, + val fromValue: Double, + val toValue: Double, + val durationMs: Double, + val startTimestampMs: Double, + val easingType: Int, + val pointsX: FloatArray, + val pointsY: FloatArray, + val persistent: Boolean, + ) : Command() + + class Remove( + val viewTag: Int, + val propertyName: String, + ) : Command() + } + + /** + * FloatArray equals is identity, so a data class or Pair key would never hit the + * cache (every JNI call carries fresh arrays); the point arrays must be compared + * by content. + */ + private class InterpolatorKey( + private val type: Int, + private val pointsX: FloatArray, + private val pointsY: FloatArray, + ) { + override fun equals(other: Any?): Boolean = + other is InterpolatorKey && + type == other.type && + pointsX.contentEquals(other.pointsX) && + pointsY.contentEquals(other.pointsY) + + override fun hashCode(): Int = 31 * (31 * type + pointsX.contentHashCode()) + pointsY.contentHashCode() } /** @@ -159,36 +205,15 @@ internal class CSSPlatformTransitionsManager( enqueue(Command.Remove(viewTag, propertyName)) } - private sealed class Command { - class Start( - val viewTag: Int, - val propertyName: String, - val writer: FloatProperty, - val fromValue: Double, - val toValue: Double, - val durationMs: Double, - val startTimestampMs: Double, - val easingType: Int, - val pointsX: FloatArray, - val pointsY: FloatArray, - val persistent: Boolean, - ) : Command() - - class Remove( - val viewTag: Int, - val propertyName: String, - ) : Command() - } - - private val commandLock = Any() - private var pendingCommands = ArrayList() - private var spareCommands = ArrayList() - private var flushScheduled = false - /** * One main-looper message per burst instead of one per transition: a render that * starts hundreds of transitions would otherwise flood the queue, and by the time * late messages ran their transitions would already be stale. + * + * The message is asynchronous so a pending traversal's sync barrier cannot defer + * it past the frame that mounts the committed target: a plain message would then + * run only after that frame drew the end value, flashing it before the start + * value lands. */ private fun enqueue(command: Command) { val schedule: Boolean @@ -197,9 +222,23 @@ internal class CSSPlatformTransitionsManager( schedule = !flushScheduled if (schedule) flushScheduled = true } - if (schedule) UiThreadUtil.runOnUiThread { flushCommands() } + if (schedule) { + val message = Message.obtain(mainHandler) { flushCommands() } + message.isAsynchronous = true + mainHandler.sendMessage(message) + } } + private fun hasPendingCommand(key: Key): Boolean = + synchronized(commandLock) { + pendingCommands.any { command -> + when (command) { + is Command.Start -> command.viewTag == key.viewTag && command.propertyName == key.propertyName + is Command.Remove -> command.viewTag == key.viewTag && command.propertyName == key.propertyName + } + } + } + private fun flushCommands() { val batch: ArrayList synchronized(commandLock) { @@ -210,19 +249,7 @@ internal class CSSPlatformTransitionsManager( for (command in batch) { when (command) { is Command.Start -> beginTransition(command) - is Command.Remove -> { - val key = Key(command.viewTag, command.propertyName) - // Also drops any queued retry for this key. - startTokens.remove(key) - val dropped = transitions.remove(key) - // The committed style already holds the target, but nothing re-writes - // it to the view once this entry stops being driven; without a final - // write the view would stick at the mid-flight value. A persistent - // value has no committed style to settle to, so it is left as drawn. - if (dropped != null && !dropped.persistent) { - dropped.viewRef.get()?.let { dropped.writer.setValue(it, dropped.toValue) } - } - } + is Command.Remove -> dropTransition(command) } } batch.clear() @@ -253,6 +280,24 @@ internal class CSSPlatformTransitionsManager( } } + private fun dropTransition(command: Command.Remove) { + val key = Key(command.viewTag, command.propertyName) + // Also drops any queued retry for this key. + startTokens.remove(key) + val dropped = transitions.remove(key) ?: return + // A persistent value has no committed style to settle to; leave it as drawn. + if (dropped.persistent) return + val view = dropped.viewRef.get() ?: return + // The committed style already holds the target, but nothing re-writes it to + // the view once this entry stops being driven; without a final write the view + // would stick at the mid-flight value. A mount racing this removal may already + // have written a newer committed value though, so settle only while the view + // still shows the value this engine wrote last. + if (dropped.writer.get(view) == dropped.current) { + dropped.writer.setValue(view, dropped.toValue) + } + } + /** * A tag can be registered before its View exists, with the mount still in flight. * The start timestamp is absolute, so a late start seeks rather than drifting, and @@ -267,6 +312,12 @@ internal class CSSPlatformTransitionsManager( begin: () -> Boolean, ) { if (startTokens[key] != token) return + if (hasPendingCommand(key)) { + // A queued command for this key may invalidate this token when it flushes; + // retry after the flush instead of racing it with a stale start. + Choreographer.getInstance().postFrameCallback { beginWhenMounted(key, token, endTimestampMs, persistent, begin) } + return + } // A persistent value outlives its own timeline, so its start never expires; it // retries until the view mounts or the key is superseded or removed. if (begin() || (!persistent && animationTimestamp() >= endTimestampMs)) { @@ -311,6 +362,13 @@ internal class CSSPlatformTransitionsManager( ensurePumping() } + private fun ensurePumping() { + if (!pumping) { + pumping = true + Choreographer.getInstance().postFrameCallback(framePump) + } + } + /** Re-asserts each transition's own value wherever a commit overwrote it. */ private fun repairClobberedValues(): Boolean { val iterator = transitions.values.iterator() @@ -328,21 +386,6 @@ internal class CSSPlatformTransitionsManager( return transitions.isNotEmpty() } - /** Compares the point arrays by content, so probing the cache allocates only the key. */ - private class InterpolatorKey( - private val type: Int, - private val pointsX: FloatArray, - private val pointsY: FloatArray, - ) { - override fun equals(other: Any?): Boolean = - other is InterpolatorKey && - type == other.type && - pointsX.contentEquals(other.pointsX) && - pointsY.contentEquals(other.pointsY) - - override fun hashCode(): Int = 31 * (31 * type + pointsX.contentHashCode()) + pointsY.contentHashCode() - } - /** * PathInterpolator flattens its curve natively on construction, so cache it. The * type is part of the key because different families can share a point list: From eadf34c4a982c0d7de3b72d252927dda33ed19a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Mon, 3 Aug 2026 11:45:05 +0200 Subject: [PATCH 4/5] Shorten comments and make the pump completion branch explicit --- .../css/CSSPlatformTransitionReconciler.kt | 23 ++--- .../css/CSSPlatformTransitionsManager.kt | 91 +++++++------------ .../swmansion/reanimated/css/DurationScale.kt | 5 +- 3 files changed, 42 insertions(+), 77 deletions(-) diff --git a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionReconciler.kt b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionReconciler.kt index 040524b676e2..52ff4af2359f 100644 --- a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionReconciler.kt +++ b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionReconciler.kt @@ -7,20 +7,16 @@ import java.util.WeakHashMap /** * A platform transition animates the property React Native itself writes, so a commit can - * overwrite it mid-flight. Re-asserting just before the frame is drawn covers every writer - - * a Fabric mount, the synchronous props path, the animation backend - without knowing which - * one ran, since they all run earlier in the same frame. - * - * [repair] returns whether anything is still animating. + * overwrite it mid-flight; re-asserting just before draw covers every writer, since they + * all run earlier in the same frame. [repair] returns whether anything is still animating. */ internal class CSSPlatformTransitionReconciler( private val repair: () -> Boolean, ) { /** - * Keyed by window: getViewTreeObserver is per window, not per view. Weakly held, - * because a destroyed window's observer never reports !isAlive (only floating - * observers are killed, on merge) and its listener never retires (a dead window - * never draws), so a strong set would pin one observer per torn-down window. + * Keyed by window (getViewTreeObserver is per window). Weakly held: a destroyed + * window's observer stays isAlive forever and never draws, so a strong set would + * pin one observer per torn-down window. */ private val tracked: MutableSet = Collections.newSetFromMap(WeakHashMap()) @@ -31,9 +27,8 @@ internal class CSSPlatformTransitionReconciler( fun track(view: View) { if (!view.isAttachedToWindow) { - // A detached view hands out a floating observer that merges into the window - // observer on attach and dies, leaving the listener unremovable; register - // once the view is genuinely attached. + // A detached view's floating observer dies on attach, leaving its listener + // unremovable; register only once the view is genuinely attached. if (!pendingAttach.add(view)) return view.addOnAttachStateChangeListener( object : View.OnAttachStateChangeListener { @@ -51,7 +46,6 @@ internal class CSSPlatformTransitionReconciler( ) return } - // A window torn down mid-animation never draws again, so its listener never retires. tracked.removeAll { !it.isAlive } val observer = view.viewTreeObserver @@ -62,8 +56,7 @@ internal class CSSPlatformTransitionReconciler( override fun onPreDraw(): Boolean { if (repair()) return true - // Retiring lazily at draw time keeps the lifecycle one-sided: the - // manager only ever tracks, and a registry left empty retires here. + // Nothing left to defend: retire until the next track(). if (observer.isAlive) observer.removeOnPreDrawListener(this) tracked.remove(observer) return true diff --git a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionsManager.kt b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionsManager.kt index 35684da9f747..22c9308e163c 100644 --- a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionsManager.kt +++ b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionsManager.kt @@ -39,10 +39,8 @@ internal class CSSPlatformTransitionsManager( private val mainHandler = Handler(Looper.getMainLooper()) /** - * Single per-frame driver for every running transition. The absolute start timestamp - * makes delay and late-start seeking fall out of the arithmetic: t < 0 during the - * delay clamps to 0 and holds the start value, and a late first frame lands mid-curve - * instead of shifting the whole timeline. + * Single per-frame driver for every running transition. Values come from the absolute + * start timestamp, so delay (t < 0) and late starts need no extra state. */ private val framePump = object : Choreographer.FrameCallback { @@ -60,8 +58,7 @@ internal class CSSPlatformTransitionsManager( if (running.finished) continue val elapsedMs = now - running.startTimeMs if (elapsedMs < 0) { - // Delay phase: hold the raw start value. Step-like easings are - // nonzero already at t = 0, so this cannot go through the curve. + // Delay phase: hold the raw start value; step easings are already nonzero at t = 0. running.current = running.startValue running.writer.setValue(view, running.startValue) active = true @@ -72,14 +69,17 @@ internal class CSSPlatformTransitionsManager( running.startValue + (running.toValue - running.startValue) * running.interpolator.getInterpolation(t) running.current = value - // setValue takes a primitive and View.setAlpha early-outs on an unchanged - // value, so unconditional writes allocate nothing and invalidate only on - // real changes. + // setAlpha early-outs on unchanged values, so unconditional writes cost nothing. running.writer.setValue(view, value) - if (t >= 1f) { - if (running.persistent) running.finished = true else iterator.remove() - } else { + if (t < 1f) { active = true + continue + } + if (running.persistent) { + // Keeps its entry so the pre-draw repair defends the final value. + running.finished = true + } else { + iterator.remove() } } if (active) { @@ -135,11 +135,7 @@ internal class CSSPlatformTransitionsManager( ) : Command() } - /** - * FloatArray equals is identity, so a data class or Pair key would never hit the - * cache (every JNI call carries fresh arrays); the point arrays must be compared - * by content. - */ + /** FloatArray equals is identity; only content comparison makes cache hits possible. */ private class InterpolatorKey( private val type: Int, private val pointsX: FloatArray, @@ -155,10 +151,8 @@ internal class CSSPlatformTransitionsManager( } /** - * Returns whether the property is accepted for native playback, decided before the - * hop to the UI thread. Playback itself can still fail there if the View never - * mounts, and there is no path back to demote the property afterwards - the same - * contract the Apple backend has. + * Accepts or refuses the property for native playback; false sends it to the loop. + * There is no later demotion path - the same contract the Apple backend has. */ fun animateTransition( viewTag: Int, @@ -174,10 +168,8 @@ internal class CSSPlatformTransitionsManager( ): Boolean { val writer = cssPropertyWriterFor(propertyName) ?: return false val context = reactContext.get() ?: return false - // Refusing sends the property to the loop, which resolves the final style without - // animating. The animator duration scale is otherwise not applied: platform - // transitions follow the authored CSS timeline, and the slow-animations toggle - // already flows through the shared animation clock. + // With animations disabled the loop settles the final style without animating; + // platform transitions otherwise ignore the animator duration scale. if (!DurationScale.animationsEnabled(context)) return false enqueue( @@ -206,14 +198,9 @@ internal class CSSPlatformTransitionsManager( } /** - * One main-looper message per burst instead of one per transition: a render that - * starts hundreds of transitions would otherwise flood the queue, and by the time - * late messages ran their transitions would already be stale. - * - * The message is asynchronous so a pending traversal's sync barrier cannot defer - * it past the frame that mounts the committed target: a plain message would then - * run only after that frame drew the end value, flashing it before the start - * value lands. + * One asynchronous main-looper message per burst, not one per transition: per-command + * messages flood the looper under churn, and a synchronous message can be deferred by + * a traversal's sync barrier until the committed end value was already drawn. */ private fun enqueue(command: Command) { val schedule: Boolean @@ -258,8 +245,7 @@ internal class CSSPlatformTransitionsManager( private fun beginTransition(command: Command.Start) { val key = Key(command.viewTag, command.propertyName) - // Claiming a fresh token invalidates any retry still queued for this key, so a - // superseded request cannot start on a later frame. + // A fresh token invalidates any retry still queued for this key. val token = ++nextStartToken startTokens[key] = token beginWhenMounted(key, token, command.startTimestampMs + command.durationMs, command.persistent) { @@ -288,21 +274,17 @@ internal class CSSPlatformTransitionsManager( // A persistent value has no committed style to settle to; leave it as drawn. if (dropped.persistent) return val view = dropped.viewRef.get() ?: return - // The committed style already holds the target, but nothing re-writes it to - // the view once this entry stops being driven; without a final write the view - // would stick at the mid-flight value. A mount racing this removal may already - // have written a newer committed value though, so settle only while the view - // still shows the value this engine wrote last. + // Settle on the committed target so the view does not stick mid-flight, but only + // if nothing newer was written: a racing mount may have applied a fresh value. if (dropped.writer.get(view) == dropped.current) { dropped.writer.setValue(view, dropped.toValue) } } /** - * A tag can be registered before its View exists, with the mount still in flight. - * The start timestamp is absolute, so a late start seeks rather than drifting, and - * retrying needs no arbitrary timeout: once the transition would have ended there - * is nothing left to play. + * A tag can be registered before its View mounts; retry per frame until it does. + * The absolute start timestamp makes a late start seek instead of drift, and an + * already-ended timeline stops retrying without any arbitrary timeout. */ private fun beginWhenMounted( key: Key, @@ -313,13 +295,11 @@ internal class CSSPlatformTransitionsManager( ) { if (startTokens[key] != token) return if (hasPendingCommand(key)) { - // A queued command for this key may invalidate this token when it flushes; - // retry after the flush instead of racing it with a stale start. + // A queued command may invalidate this token; let the flush run first. Choreographer.getInstance().postFrameCallback { beginWhenMounted(key, token, endTimestampMs, persistent, begin) } return } - // A persistent value outlives its own timeline, so its start never expires; it - // retries until the view mounts or the key is superseded or removed. + // A persistent value outlives its timeline, so its start never expires. if (begin() || (!persistent && animationTimestamp() >= endTimestampMs)) { startTokens.remove(key) return @@ -338,13 +318,10 @@ internal class CSSPlatformTransitionsManager( interpolator: TimeInterpolator, persistent: Boolean, ) { - // On interruption resume from the value this engine last wrote: fromValue is the - // committed style value, so starting there would snap the view back to where the - // superseded transition began. + // Resume interruptions from the last written value; fromValue would snap back. val startValue = transitions.remove(key)?.current ?: fromValue.toFloat() - // React Native has already committed the target; show the start value until the - // first pump frame (and through any transition-delay). + // The target is already committed; show the start value until the first pump frame. writer.setValue(view, startValue) transitions[key] = @@ -379,17 +356,15 @@ internal class CSSPlatformTransitionsManager( iterator.remove() continue } - // setValue takes a primitive and setAlpha early-outs when unchanged, so an - // unconditional re-assert is allocation-free. + // setAlpha early-outs when unchanged, so re-asserting is free. running.writer.setValue(view, running.current) } return transitions.isNotEmpty() } /** - * PathInterpolator flattens its curve natively on construction, so cache it. The - * type is part of the key because different families can share a point list: - * linear(0.5, 1) and cubicBezier(0, 1, 0.5, 1) both normalize to [0,1],[0.5,1]. + * PathInterpolator flattens its curve natively on construction, so cache it. Type is + * part of the key: different easing families can share one point list. */ private fun interpolatorFor( type: Int, diff --git a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/DurationScale.kt b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/DurationScale.kt index ce423694ca01..a45c64e054ec 100644 --- a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/DurationScale.kt +++ b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/DurationScale.kt @@ -5,10 +5,7 @@ import android.content.Context import android.os.Build import android.provider.Settings -/** - * Animations can be disabled process-wide (animator duration scale 0, or battery saver); - * a CSS transition then has to settle on its final style without animating. - */ +/** Animations can be disabled system-wide (duration scale 0, battery saver). */ internal object DurationScale { fun animationsEnabled(context: Context): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { From 14740ed215fd0e77de438c4c7c946a2ab311902f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Mon, 3 Aug 2026 12:08:07 +0200 Subject: [PATCH 5/5] Name pump progress and test completion against elapsed time --- .../reanimated/css/CSSPlatformTransitionsManager.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionsManager.kt b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionsManager.kt index 22c9308e163c..6478af53d3fa 100644 --- a/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionsManager.kt +++ b/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/css/CSSPlatformTransitionsManager.kt @@ -64,14 +64,14 @@ internal class CSSPlatformTransitionsManager( active = true continue } - val t = (elapsedMs / running.durationMs).toFloat().coerceAtMost(1f) + val progress = (elapsedMs / running.durationMs).toFloat().coerceAtMost(1f) val value = running.startValue + - (running.toValue - running.startValue) * running.interpolator.getInterpolation(t) + (running.toValue - running.startValue) * running.interpolator.getInterpolation(progress) running.current = value // setAlpha early-outs on unchanged values, so unconditional writes cost nothing. running.writer.setValue(view, value) - if (t < 1f) { + if (elapsedMs < running.durationMs) { active = true continue }