Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import android.view.ViewTreeObserver

/**
* React writes the same property the animator drives, so a commit can overwrite it mid-flight.
* Re-asserting just before the draw covers every writer without knowing which one ran, since
* they all run earlier in the frame. [repair] returns whether anything is still animating.
* Running [beforeDraw] at that point puts it after every writer without knowing which one ran,
* since they all run earlier in the frame. Its return says whether the listener stays.
*/
internal class CSSPlatformTransitionReconciler(
private val repair: () -> Boolean,
private val beforeDraw: () -> Boolean,
) {
/** Keyed by window: getViewTreeObserver is per window, not per view. */
private val tracked = HashMap<ViewTreeObserver, ViewTreeObserver.OnPreDrawListener>()
Expand All @@ -28,7 +28,7 @@ internal class CSSPlatformTransitionReconciler(
val listener =
object : ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
if (repair()) return true
if (beforeDraw()) return true

// Retire here, not when the registry empties: a cancel fires its end
// callback mid-replacement, when the registry is briefly empty.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator
import android.animation.TimeInterpolator
import android.os.Handler
import android.os.Looper
import android.util.FloatProperty
import android.view.Choreographer
import android.view.View
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.UIManager
import com.facebook.react.bridge.UIManagerListener
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.common.annotations.UnstableReactNativeAPI
import com.facebook.react.fabric.FabricUIManager
import com.facebook.react.uimanager.IllegalViewOperationException
Expand Down Expand Up @@ -51,14 +52,17 @@ internal class CSSPlatformTransitionsManager(
fabricUIManager.addUIManagerEventListener(mountListener)
}

private val reconciler = CSSPlatformTransitionReconciler(::repairClobberedValues)
private val reconciler = CSSPlatformTransitionReconciler(::onPreDraw)
private val startTokens = HashMap<Key, Long>()

@Volatile
private var invalidated = false

private var nextStartToken = 0L

private val commands = MainThreadCommandQueue<Command>(::executeCommand)
private val mainHandler = Handler(Looper.getMainLooper())

/**
* A C++ mutex serialises the callers but is not a happens-before edge for Java, so the
* map supplies that ordering itself.
Expand All @@ -82,6 +86,26 @@ internal class CSSPlatformTransitionsManager(
fun currentValue(): Float = heldValue ?: if (animator.isRunning) animator.animatedValue as Float else startValue
}

private sealed class Command {
abstract val key: Key

class Start(
override val key: Key,
val writer: FloatProperty<View>,
val fromValue: Double,
val toValue: Double,
val durationMs: Double,
val startTimestampMs: Double,
val interpolator: TimeInterpolator,
val scale: Float,
val persistent: Boolean,
) : Command()

class Remove(
override val key: Key,
) : Command()
}

/** Holds the start value for [delayFraction], then plays [inner] over the rest. */
private class HoldThenEase(
private val delayFraction: Float,
Expand Down Expand Up @@ -114,19 +138,19 @@ internal class CSSPlatformTransitionsManager(
val scale = DurationScale.effectiveScale(context)
if (scale <= 0f) return false

UiThreadUtil.runOnUiThread {
if (invalidated) return@runOnUiThread
val key = Key(viewTag, propertyId)
// A fresh token invalidates any retry still queued for this key.
val token = ++nextStartToken
startTokens[key] = token

beginWhenMounted(key, token, startTimestampMs + durationMs) {
viewForTag(viewTag)?.also { view ->
start(view, key, writer, fromValue, toValue, durationMs, startTimestampMs, interpolator, scale, persistent)
} != null
}
}
commands.enqueue(
Command.Start(
Key(viewTag, propertyId),
writer,
fromValue,
toValue,
durationMs,
startTimestampMs,
interpolator,
scale,
persistent,
),
)
return true
}

Expand All @@ -137,7 +161,7 @@ internal class CSSPlatformTransitionsManager(
invalidated = true
@OptIn(UnstableReactNativeAPI::class)
fabricUIManager.removeUIManagerEventListener(mountListener)
UiThreadUtil.runOnUiThread {
mainHandler.post {
startTokens.clear()
// Snapshot first: cancel() runs onAnimationEnd, which reads the map.
val running = animators.values.toList()
Expand All @@ -152,10 +176,33 @@ internal class CSSPlatformTransitionsManager(
viewTag: Int,
propertyId: Int,
) {
UiThreadUtil.runOnUiThread {
val key = Key(viewTag, propertyId)
startTokens.remove(key)
animators.remove(key)?.animator?.cancel()
// Teardown streams a removal per routed property, and each would post its own message.
if (invalidated) return
commands.enqueue(Command.Remove(Key(viewTag, propertyId)))
}

private fun executeCommand(command: Command) {
// A start must not register an animator behind cleanup that is already posted.
if (invalidated) return
when (command) {
is Command.Start -> beginStart(command)
is Command.Remove -> removeNow(command.key)
}
}

private fun removeNow(key: Key) {
startTokens.remove(key)
animators.remove(key)?.animator?.cancel()
}

private fun beginStart(command: Command.Start) {
val key = command.key
// 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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
beginWhenMounted(key, token, command.startTimestampMs + command.durationMs) {
viewForTag(key.viewTag)?.also { view -> start(view, command) } != null
}
Comment thread
MatiPl01 marked this conversation as resolved.
}

Expand All @@ -169,7 +216,8 @@ internal class CSSPlatformTransitionsManager(
endTimestampMs: Double,
begin: () -> Boolean,
) {
if (startTokens[key] != token) return
// A queued callback can outlive invalidate(), which only posts its cleanup.
if (invalidated || startTokens[key] != token) return
if (begin() || animationTimestamp() >= endTimestampMs) {
startTokens.remove(key)
return
Expand All @@ -179,34 +227,31 @@ internal class CSSPlatformTransitionsManager(

private fun start(
view: View,
key: Key,
writer: FloatProperty<View>,
fromValue: Double,
toValue: Double,
durationMs: Double,
startTimestampMs: Double,
interpolator: TimeInterpolator,
scale: Float,
persistent: Boolean,
command: Command.Start,
) {
val key = command.key
val writer = command.writer
val durationMs = command.durationMs
val interpolator = command.interpolator

// Resume from what is on screen; fromValue is the committed style, which would snap back.
val interrupted = animators.remove(key)
val startValue = if (interrupted != null) writer.get(view) else fromValue.toFloat()
val startValue = if (interrupted != null) writer.get(view) else command.fromValue.toFloat()
interrupted?.animator?.cancel()

// ObjectAnimator writes nothing until its first frame, so the view would show the
// already-committed target for the whole delay.
writer.setValue(view, startValue)

val animator = ObjectAnimator.ofFloat(view, writer, startValue, toValue.toFloat())
val animator = ObjectAnimator.ofFloat(view, writer, startValue, command.toValue.toFloat())
animator.addListener(
object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
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) {
if (command.persistent) {
running.heldValue = animator.animatedValue as Float
} else {
animators.remove(key)
Expand All @@ -216,11 +261,11 @@ internal class CSSPlatformTransitionsManager(
)
// ObjectAnimator has no absolute start time, so resolve it after the thread hop
// rather than in C++, which would shift the timeline late.
val elapsedMs = animationTimestamp().toDouble() - startTimestampMs
val elapsedMs = animationTimestamp().toDouble() - command.startTimestampMs
val delayMs = if (elapsedMs < 0) -elapsedMs else 0.0
// startDelay writes nothing while it waits, so a commit landing in the delay would
// stay on screen. Folding the delay into the curve rewrites the property instead.
animator.duration = ((delayMs + durationMs) / scale).toLong().coerceAtLeast(1L)
animator.duration = ((delayMs + durationMs) / command.scale).toLong().coerceAtLeast(1L)
animator.interpolator =
if (delayMs > 0) HoldThenEase((delayMs / (delayMs + durationMs)).toFloat(), interpolator) else interpolator
if (elapsedMs > 0 && durationMs > 0) {
Expand All @@ -237,17 +282,27 @@ internal class CSSPlatformTransitionsManager(
reactWroteSinceLastDraw = true
}

/**
* Draining here as well as from the posted message puts a queued start after the commit
* that wrote the target and before the draw, so it replaces the target in the same frame
* rather than showing it once. Returns whether the listener is still needed.
*/
private fun onPreDraw(): Boolean {
commands.drain()
repairClobberedValues()
return animators.isNotEmpty() || commands.hasPending()
}

/** Re-asserts each animator's own value wherever a commit overwrote it. */
private fun repairClobberedValues(): Boolean {
if (!reactWroteSinceLastDraw) return animators.isNotEmpty()
private fun repairClobberedValues() {
if (!reactWroteSinceLastDraw) return
reactWroteSinceLastDraw = false
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)
}
return animators.isNotEmpty()
}

/** PathInterpolator flattens its curve natively on construction, so build once per id. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.swmansion.reanimated.css

import android.os.Handler
import android.os.Looper
import android.os.Message

/**
* Coalesces commands from any thread into one main-looper message per burst: a commit can
* produce a command per view on screen, and a message each floods the looper. The message is
* asynchronous so a traversal's sync barrier cannot defer it past the frame whose committed
* values the commands are meant to replace. [execute] runs on the main thread, from the posted
* message or from an explicit [drain].
*/
internal class MainThreadCommandQueue<T : Any>(
private val execute: (T) -> Unit,
) {
private val lock = Any()
private var pending = ArrayList<T>()

/** Null while a drain holds it, so the two buffers can never be the same list. */
private var spare: ArrayList<T>? = ArrayList()
private var scheduled = false
private val mainHandler = Handler(Looper.getMainLooper())
private val drainRunnable = Runnable { drain() }

fun enqueue(command: T) {
synchronized(lock) {
pending.add(command)
if (scheduled) return
scheduled = true
// Posted under the lock: a drain between deciding and posting would reset
// `scheduled`, and the next enqueue would post a second message for this burst.
val message = Message.obtain(mainHandler, drainRunnable)
message.isAsynchronous = true
mainHandler.sendMessage(message)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

/** Runs every queued command now; a later message for the same burst finds nothing left. */
fun drain() {
val batch: ArrayList<T>
synchronized(lock) {
batch = pending
pending = spare ?: ArrayList()
spare = null
scheduled = false
}
try {
for (command in batch) execute(command)
} finally {
batch.clear()
synchronized(lock) { spare = batch }
}
}

fun hasPending(): Boolean = synchronized(lock) { pending.isNotEmpty() }
}
Loading