From bfb274c244a9744b6f8cfc9c6b95cc7edca83ef1 Mon Sep 17 00:00:00 2001 From: Zeya Peng Date: Thu, 10 Apr 2025 13:53:30 -0700 Subject: [PATCH] Convert NativeAnimatedNodesManager to kotlin Summary: ## Changelog: [Android] [Changed] - Convert NativeAnimatedNodesManager to kotlin Reviewed By: alanleedev Differential Revision: D72657697 fbshipit-source-id: 36180d0906f6ef621b8b667442b61642f9ccc5d1 --- .../ReactAndroid/api/ReactAndroid.api | 56 +- .../facebook/react/animated/AnimatedNode.kt | 22 +- .../animated/AnimatedNodeValueListener.kt | 4 +- .../animated/NativeAnimatedNodesManager.java | 900 ------------------ .../animated/NativeAnimatedNodesManager.kt | 768 +++++++++++++++ 5 files changed, 813 insertions(+), 937 deletions(-) delete mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedNodesManager.java create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedNodesManager.kt diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 8c8d4281d23..9fb61ee95be 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -439,6 +439,14 @@ public abstract interface class com/facebook/react/ViewManagerOnDemandReactPacka public abstract fun getViewManagerNames (Lcom/facebook/react/bridge/ReactApplicationContext;)Ljava/util/Collection; } +public abstract class com/facebook/react/animated/AnimatedNode { + public fun ()V +} + +public abstract interface class com/facebook/react/animated/AnimatedNodeValueListener { + public abstract fun onValueUpdate (D)V +} + public final class com/facebook/react/animated/NativeAnimatedModule : com/facebook/fbreact/specs/NativeAnimatedModuleSpec, com/facebook/react/bridge/LifecycleEventListener, com/facebook/react/bridge/UIManagerListener { public static final field ANIMATED_MODULE_DEBUG Z public static final field Companion Lcom/facebook/react/animated/NativeAnimatedModule$Companion; @@ -485,32 +493,32 @@ public final class com/facebook/react/animated/NativeAnimatedModule : com/facebo public final class com/facebook/react/animated/NativeAnimatedModule$Companion { } -public class com/facebook/react/animated/NativeAnimatedNodesManager : com/facebook/react/uimanager/events/EventDispatcherListener { +public final class com/facebook/react/animated/NativeAnimatedNodesManager : com/facebook/react/uimanager/events/EventDispatcherListener { public fun (Lcom/facebook/react/bridge/ReactApplicationContext;)V - public fun addAnimatedEventToView (ILjava/lang/String;Lcom/facebook/react/bridge/ReadableMap;)V - public fun connectAnimatedNodeToView (II)V - public fun connectAnimatedNodes (II)V - public fun createAnimatedNode (ILcom/facebook/react/bridge/ReadableMap;)V - public fun disconnectAnimatedNodeFromView (II)V - public fun disconnectAnimatedNodes (II)V - public fun dropAnimatedNode (I)V - public fun extractAnimatedNodeOffset (I)V - public fun flattenAnimatedNodeOffset (I)V - public fun getNodeById (I)Lcom/facebook/react/animated/AnimatedNode; - public fun getValue (ILcom/facebook/react/bridge/Callback;)V - public fun hasActiveAnimations ()Z - public fun initializeEventListenerForUIManagerType (I)V + public final fun addAnimatedEventToView (ILjava/lang/String;Lcom/facebook/react/bridge/ReadableMap;)V + public final fun connectAnimatedNodeToView (II)V + public final fun connectAnimatedNodes (II)V + public final fun createAnimatedNode (ILcom/facebook/react/bridge/ReadableMap;)V + public final fun disconnectAnimatedNodeFromView (II)V + public final fun disconnectAnimatedNodes (II)V + public final fun dropAnimatedNode (I)V + public final fun extractAnimatedNodeOffset (I)V + public final fun flattenAnimatedNodeOffset (I)V + public final fun getNodeById (I)Lcom/facebook/react/animated/AnimatedNode; + public final fun getValue (ILcom/facebook/react/bridge/Callback;)V + public final fun hasActiveAnimations ()Z + public final fun initializeEventListenerForUIManagerType (I)V public fun onEventDispatch (Lcom/facebook/react/uimanager/events/Event;)V - public fun removeAnimatedEventFromView (ILjava/lang/String;I)V - public fun restoreDefaultValues (I)V - public fun runUpdates (J)V - public fun setAnimatedNodeOffset (ID)V - public fun setAnimatedNodeValue (ID)V - public fun startAnimatingNode (IILcom/facebook/react/bridge/ReadableMap;Lcom/facebook/react/bridge/Callback;)V - public fun startListeningToAnimatedNodeValue (ILcom/facebook/react/animated/AnimatedNodeValueListener;)V - public fun stopAnimation (I)V - public fun stopListeningToAnimatedNodeValue (I)V - public fun updateAnimatedNodeConfig (ILcom/facebook/react/bridge/ReadableMap;)V + public final fun removeAnimatedEventFromView (ILjava/lang/String;I)V + public final fun restoreDefaultValues (I)V + public final fun runUpdates (J)V + public final fun setAnimatedNodeOffset (ID)V + public final fun setAnimatedNodeValue (ID)V + public final fun startAnimatingNode (IILcom/facebook/react/bridge/ReadableMap;Lcom/facebook/react/bridge/Callback;)V + public final fun startListeningToAnimatedNodeValue (ILcom/facebook/react/animated/AnimatedNodeValueListener;)V + public final fun stopAnimation (I)V + public final fun stopListeningToAnimatedNodeValue (I)V + public final fun updateAnimatedNodeConfig (ILcom/facebook/react/bridge/ReadableMap;)V } public abstract interface class com/facebook/react/bridge/ActivityEventListener { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/animated/AnimatedNode.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/animated/AnimatedNode.kt index 3759555142e..66746bdc2c9 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/animated/AnimatedNode.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/animated/AnimatedNode.kt @@ -10,11 +10,11 @@ package com.facebook.react.animated import java.util.ArrayList /** Base class for all Animated.js library node types that can be created on the "native" side. */ -internal abstract class AnimatedNode { +public abstract class AnimatedNode { - companion object { - const val INITIAL_BFS_COLOR: Int = 0 - const val DEFAULT_ANIMATED_NODE_CHILD_COUNT: Int = 1 + internal companion object { + internal const val INITIAL_BFS_COLOR: Int = 0 + internal const val DEFAULT_ANIMATED_NODE_CHILD_COUNT: Int = 1 } // TODO: T196787278 Reduce the visibility of these fields to package once we have @@ -27,7 +27,7 @@ internal abstract class AnimatedNode { @JvmField internal var BFSColor: Int = INITIAL_BFS_COLOR @JvmField internal var tag: Int = -1 - fun addChild(child: AnimatedNode): Unit { + internal fun addChild(child: AnimatedNode): Unit { val currentChildren = children ?: ArrayList(DEFAULT_ANIMATED_NODE_CHILD_COUNT).also { children = it } @@ -36,7 +36,7 @@ internal abstract class AnimatedNode { child.onAttachedToNode(this) } - fun removeChild(child: AnimatedNode): Unit { + internal fun removeChild(child: AnimatedNode): Unit { val currentChildren = children ?: return child.onDetachedFromNode(this) currentChildren.remove(child) @@ -48,24 +48,24 @@ internal abstract class AnimatedNode { * is important to also override [onDetachedFromNode] to clear that reference once current node * gets detached. */ - open fun onAttachedToNode(parent: AnimatedNode): Unit = Unit + internal open fun onAttachedToNode(parent: AnimatedNode): Unit = Unit /** See [onAttachedToNode] */ - open fun onDetachedFromNode(parent: AnimatedNode): Unit = Unit + internal open fun onDetachedFromNode(parent: AnimatedNode): Unit = Unit /** * This method will be run on each node at most once every repetition of the animation loop. It * will be executed on a node only when all the node's parent has already been updated. Therefore * it can be used to calculate node's value. */ - open fun update(): Unit = Unit + internal open fun update(): Unit = Unit /** * Pretty-printer for the AnimatedNode. Only called in production pre-crash for debug diagnostics. */ - abstract fun prettyPrint(): String + internal abstract fun prettyPrint(): String - fun prettyPrintWithChildren(): String { + internal fun prettyPrintWithChildren(): String { val currentChildren = children?.joinToString(" ") return prettyPrint() + diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/animated/AnimatedNodeValueListener.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/animated/AnimatedNodeValueListener.kt index 513b8bdde8d..234f1ce01a8 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/animated/AnimatedNodeValueListener.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/animated/AnimatedNodeValueListener.kt @@ -8,6 +8,6 @@ package com.facebook.react.animated /** Interface used to listen to [ValueAnimatedNode] updates. */ -internal fun interface AnimatedNodeValueListener { - fun onValueUpdate(value: Double) +public fun interface AnimatedNodeValueListener { + public fun onValueUpdate(value: Double) } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedNodesManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedNodesManager.java deleted file mode 100644 index 328b55db90b..00000000000 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedNodesManager.java +++ /dev/null @@ -1,900 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -package com.facebook.react.animated; - -import android.util.SparseArray; -import androidx.annotation.Nullable; -import androidx.annotation.UiThread; -import com.facebook.common.logging.FLog; -import com.facebook.infer.annotation.Nullsafe; -import com.facebook.react.bridge.Arguments; -import com.facebook.react.bridge.Callback; -import com.facebook.react.bridge.JSApplicationCausedNativeException; -import com.facebook.react.bridge.JSApplicationIllegalArgumentException; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.ReactNoCrashSoftException; -import com.facebook.react.bridge.ReactSoftExceptionLogger; -import com.facebook.react.bridge.ReadableArray; -import com.facebook.react.bridge.ReadableMap; -import com.facebook.react.bridge.UIManager; -import com.facebook.react.bridge.UiThreadUtil; -import com.facebook.react.bridge.WritableArray; -import com.facebook.react.bridge.WritableMap; -import com.facebook.react.uimanager.UIManagerHelper; -import com.facebook.react.uimanager.common.UIManagerType; -import com.facebook.react.uimanager.events.Event; -import com.facebook.react.uimanager.events.EventDispatcher; -import com.facebook.react.uimanager.events.EventDispatcherListener; -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.LinkedList; -import java.util.List; -import java.util.ListIterator; -import java.util.Queue; -import java.util.Set; - -/** - * This is the main class that coordinates how native animated JS implementation drives UI changes. - * - *

It implements a management interface for animated nodes graph as well as implements a graph - * traversal algorithm that is run for each animation frame. - * - *

For each animation frame we visit animated nodes that might've been updated as well as their - * children that may use parent's values to update themselves. At the end of the traversal algorithm - * we expect to reach a special type of the node: PropsAnimatedNode that is then responsible for - * calculating property map which can be sent to native view hierarchy to update the view. - * - *

IMPORTANT: This class should be accessed only from the UI Thread - */ -@Nullsafe(Nullsafe.Mode.LOCAL) -public class NativeAnimatedNodesManager implements EventDispatcherListener { - - private static final String TAG = "NativeAnimatedNodesManager"; - - private final SparseArray mAnimatedNodes = new SparseArray<>(); - private final SparseArray mActiveAnimations = new SparseArray<>(); - private final SparseArray mUpdatedNodes = new SparseArray<>(); - // List of event animation drivers for an event on view. - // There may be multiple drivers for the same event and view. - private final List mEventDrivers = new ArrayList<>(); - private final ReactApplicationContext mReactApplicationContext; - private int mAnimatedGraphBFSColor = 0; - // Used to avoid allocating a new array on every frame in `runUpdates` and `onEventDispatch`. - private final List mRunUpdateNodeList = new LinkedList<>(); - - private boolean mEventListenerInitializedForFabric = false; - private boolean mEventListenerInitializedForNonFabric = false; - - private boolean mWarnedAboutGraphTraversal = false; - - public NativeAnimatedNodesManager(ReactApplicationContext reactApplicationContext) { - mReactApplicationContext = reactApplicationContext; - } - - /** - * Initialize event listeners for Fabric UIManager or non-Fabric UIManager, exactly once. Once - * Fabric is the only UIManager, this logic can be simplified. This is expected to only be called - * from the native module thread. - * - * @param uiManagerType - */ - public void initializeEventListenerForUIManagerType(@UIManagerType final int uiManagerType) { - if (uiManagerType == UIManagerType.FABRIC - ? mEventListenerInitializedForFabric - : mEventListenerInitializedForNonFabric) { - return; - } - - UIManager uiManager = UIManagerHelper.getUIManager(mReactApplicationContext, uiManagerType); - if (uiManager != null) { - EventDispatcher eventDispatcher = (EventDispatcher) uiManager.getEventDispatcher(); - eventDispatcher.addListener(this); - if (uiManagerType == UIManagerType.FABRIC) { - mEventListenerInitializedForFabric = true; - } else { - mEventListenerInitializedForNonFabric = true; - } - } - } - - @Nullable - public AnimatedNode getNodeById(int id) { - return mAnimatedNodes.get(id); - } - - public boolean hasActiveAnimations() { - return mActiveAnimations.size() > 0 || mUpdatedNodes.size() > 0; - } - - @UiThread - public void createAnimatedNode(int tag, ReadableMap config) { - if (mAnimatedNodes.get(tag) != null) { - throw new JSApplicationIllegalArgumentException( - "createAnimatedNode: Animated node [" + tag + "] already exists"); - } - String type = config.getString("type"); - final AnimatedNode node; - if ("style".equals(type)) { - node = new StyleAnimatedNode(config, this); - } else if ("value".equals(type)) { - node = new ValueAnimatedNode(config); - } else if ("color".equals(type)) { - node = new ColorAnimatedNode(config, this, mReactApplicationContext); - } else if ("props".equals(type)) { - node = new PropsAnimatedNode(config, this); - } else if ("interpolation".equals(type)) { - node = new InterpolationAnimatedNode(config); - } else if ("addition".equals(type)) { - node = new AdditionAnimatedNode(config, this); - } else if ("subtraction".equals(type)) { - node = new SubtractionAnimatedNode(config, this); - } else if ("division".equals(type)) { - node = new DivisionAnimatedNode(config, this); - } else if ("multiplication".equals(type)) { - node = new MultiplicationAnimatedNode(config, this); - } else if ("modulus".equals(type)) { - node = new ModulusAnimatedNode(config, this); - } else if ("diffclamp".equals(type)) { - node = new DiffClampAnimatedNode(config, this); - } else if ("transform".equals(type)) { - node = new TransformAnimatedNode(config, this); - } else if ("tracking".equals(type)) { - node = new TrackingAnimatedNode(config, this); - } else if ("object".equals(type)) { - node = new ObjectAnimatedNode(config, this); - } else { - throw new JSApplicationIllegalArgumentException("Unsupported node type: " + type); - } - node.tag = tag; - mAnimatedNodes.put(tag, node); - mUpdatedNodes.put(tag, node); - } - - @UiThread - public void updateAnimatedNodeConfig(int tag, ReadableMap config) { - AnimatedNode node = mAnimatedNodes.get(tag); - if (node == null) { - throw new JSApplicationIllegalArgumentException( - "updateAnimatedNode: Animated node [" + tag + "] does not exist"); - } - - if (node instanceof AnimatedNodeWithUpdateableConfig) { - stopAnimationsForNode(node); - ((AnimatedNodeWithUpdateableConfig) node).onUpdateConfig(config); - mUpdatedNodes.put(tag, node); - } - } - - @UiThread - public void dropAnimatedNode(int tag) { - mAnimatedNodes.remove(tag); - mUpdatedNodes.remove(tag); - } - - @UiThread - public void startListeningToAnimatedNodeValue(int tag, AnimatedNodeValueListener listener) { - AnimatedNode node = mAnimatedNodes.get(tag); - if (node == null || !(node instanceof ValueAnimatedNode)) { - throw new JSApplicationIllegalArgumentException( - "startListeningToAnimatedNodeValue: Animated node [" - + tag - + "] does not exist, or is not a 'value' node"); - } - ((ValueAnimatedNode) node).setValueListener(listener); - } - - @UiThread - public void stopListeningToAnimatedNodeValue(int tag) { - AnimatedNode node = mAnimatedNodes.get(tag); - if (node == null || !(node instanceof ValueAnimatedNode)) { - throw new JSApplicationIllegalArgumentException( - "startListeningToAnimatedNodeValue: Animated node [" - + tag - + "] does not exist, or is not a 'value' node"); - } - ((ValueAnimatedNode) node).setValueListener(null); - } - - @UiThread - public void setAnimatedNodeValue(int tag, double value) { - AnimatedNode node = mAnimatedNodes.get(tag); - if (node == null || !(node instanceof ValueAnimatedNode)) { - throw new JSApplicationIllegalArgumentException( - "setAnimatedNodeValue: Animated node [" - + tag - + "] does not exist, or is not a 'value' node"); - } - stopAnimationsForNode(node); - ((ValueAnimatedNode) node).nodeValue = value; - mUpdatedNodes.put(tag, node); - } - - @UiThread - public void setAnimatedNodeOffset(int tag, double offset) { - AnimatedNode node = mAnimatedNodes.get(tag); - if (node == null || !(node instanceof ValueAnimatedNode)) { - throw new JSApplicationIllegalArgumentException( - "setAnimatedNodeOffset: Animated node [" - + tag - + "] does not exist, or is not a 'value' node"); - } - ((ValueAnimatedNode) node).offset = offset; - mUpdatedNodes.put(tag, node); - } - - @UiThread - public void flattenAnimatedNodeOffset(int tag) { - AnimatedNode node = mAnimatedNodes.get(tag); - if (node == null || !(node instanceof ValueAnimatedNode)) { - throw new JSApplicationIllegalArgumentException( - "flattenAnimatedNodeOffset: Animated node [" - + tag - + "] does not exist, or is not a 'value' node"); - } - ((ValueAnimatedNode) node).flattenOffset(); - } - - @UiThread - public void extractAnimatedNodeOffset(int tag) { - AnimatedNode node = mAnimatedNodes.get(tag); - if (node == null || !(node instanceof ValueAnimatedNode)) { - throw new JSApplicationIllegalArgumentException( - "extractAnimatedNodeOffset: Animated node [" - + tag - + "] does not exist, or is not a 'value' node"); - } - ((ValueAnimatedNode) node).extractOffset(); - } - - @UiThread - public void startAnimatingNode( - int animationId, - int animatedNodeTag, - ReadableMap animationConfig, - @Nullable Callback endCallback) { - AnimatedNode node = mAnimatedNodes.get(animatedNodeTag); - if (node == null) { - throw new JSApplicationIllegalArgumentException( - "startAnimatingNode: Animated node [" + animatedNodeTag + "] does not exist"); - } - if (!(node instanceof ValueAnimatedNode)) { - throw new JSApplicationIllegalArgumentException( - "startAnimatingNode: Animated node [" - + animatedNodeTag - + "] should be of type " - + ValueAnimatedNode.class.getName()); - } - - final AnimationDriver existingDriver = mActiveAnimations.get(animationId); - if (existingDriver != null) { - // animation with the given ID is already running, we need to update its configuration instead - // of spawning a new one - existingDriver.resetConfig(animationConfig); - return; - } - - String type = animationConfig.getString("type"); - final AnimationDriver animation; - if ("frames".equals(type)) { - animation = new FrameBasedAnimationDriver(animationConfig); - } else if ("spring".equals(type)) { - animation = new SpringAnimation(animationConfig); - } else if ("decay".equals(type)) { - animation = new DecayAnimation(animationConfig); - } else { - throw new JSApplicationIllegalArgumentException( - "startAnimatingNode: Unsupported animation type [" + animatedNodeTag + "]: " + type); - } - animation.id = animationId; - animation.endCallback = endCallback; - animation.animatedValue = (ValueAnimatedNode) node; - mActiveAnimations.put(animationId, animation); - } - - @UiThread - private void stopAnimationsForNode(AnimatedNode animatedNode) { - // in most of the cases there should never be more than a few active animations running at the - // same time. Therefore it does not make much sense to create an animationId -> animation - // object map that would require additional memory just to support the use-case of stopping - // an animation - WritableArray events = null; - for (int i = 0; i < mActiveAnimations.size(); i++) { - AnimationDriver animation = mActiveAnimations.valueAt(i); - if (animatedNode.equals(animation.animatedValue)) { - if (animation.endCallback != null) { - // Invoke animation end callback with {finished: false} - WritableMap endCallbackResponse = Arguments.createMap(); - endCallbackResponse.putBoolean("finished", false); - // NULLSAFE_FIXME[Nullable Dereference] - endCallbackResponse.putDouble("value", animation.animatedValue.nodeValue); - animation.endCallback.invoke(endCallbackResponse); - } else if (mReactApplicationContext != null) { - // If no callback is passed in, this /may/ be an animation set up by the single-op - // instruction from JS, meaning that no jsi::functions are passed into native and - // we communicate via RCTDeviceEventEmitter instead of callbacks. - WritableMap params = Arguments.createMap(); - params.putInt("animationId", animation.id); - params.putBoolean("finished", false); - // NULLSAFE_FIXME[Nullable Dereference] - params.putDouble("value", animation.animatedValue.nodeValue); - if (events == null) { - events = Arguments.createArray(); - } - events.pushMap(params); - } - mActiveAnimations.removeAt(i); - i--; - } - } - if (events != null) { - mReactApplicationContext.emitDeviceEvent("onNativeAnimatedModuleAnimationFinished", events); - } - } - - @UiThread - public void stopAnimation(int animationId) { - // in most of the cases there should never be more than a few active animations running at the - // same time. Therefore it does not make much sense to create an animationId -> animation - // object map that would require additional memory just to support the use-case of stopping - // an animation - WritableArray events = null; - for (int i = 0; i < mActiveAnimations.size(); i++) { - AnimationDriver animation = mActiveAnimations.valueAt(i); - if (animation.id == animationId) { - if (animation.endCallback != null) { - // Invoke animation end callback with {finished: false} - WritableMap endCallbackResponse = Arguments.createMap(); - endCallbackResponse.putBoolean("finished", false); - // NULLSAFE_FIXME[Nullable Dereference] - endCallbackResponse.putDouble("value", animation.animatedValue.nodeValue); - animation.endCallback.invoke(endCallbackResponse); - } else if (mReactApplicationContext != null) { - // If no callback is passed in, this /may/ be an animation set up by the single-op - // instruction from JS, meaning that no jsi::functions are passed into native and - // we communicate via RCTDeviceEventEmitter instead of callbacks. - WritableMap params = Arguments.createMap(); - params.putInt("animationId", animation.id); - params.putBoolean("finished", false); - // NULLSAFE_FIXME[Nullable Dereference] - params.putDouble("value", animation.animatedValue.nodeValue); - if (events == null) { - events = Arguments.createArray(); - } - events.pushMap(params); - } - mActiveAnimations.removeAt(i); - break; - } - } - if (events != null) { - mReactApplicationContext.emitDeviceEvent("onNativeAnimatedModuleAnimationFinished", events); - } - // Do not throw an error in the case animation could not be found. We only keep "active" - // animations in the registry and there is a chance that Animated.js will enqueue a - // stopAnimation call after the animation has ended or the call will reach native thread only - // when the animation is already over. - } - - @UiThread - public void connectAnimatedNodes(int parentNodeTag, int childNodeTag) { - AnimatedNode parentNode = mAnimatedNodes.get(parentNodeTag); - if (parentNode == null) { - throw new JSApplicationIllegalArgumentException( - "connectAnimatedNodes: Animated node with tag (parent) [" - + parentNodeTag - + "] does not exist"); - } - AnimatedNode childNode = mAnimatedNodes.get(childNodeTag); - if (childNode == null) { - throw new JSApplicationIllegalArgumentException( - "connectAnimatedNodes: Animated node with tag (child) [" - + childNodeTag - + "] does not exist"); - } - parentNode.addChild(childNode); - mUpdatedNodes.put(childNodeTag, childNode); - } - - public void disconnectAnimatedNodes(int parentNodeTag, int childNodeTag) { - AnimatedNode parentNode = mAnimatedNodes.get(parentNodeTag); - if (parentNode == null) { - throw new JSApplicationIllegalArgumentException( - "disconnectAnimatedNodes: Animated node with tag (parent) [" - + parentNodeTag - + "] does not exist"); - } - AnimatedNode childNode = mAnimatedNodes.get(childNodeTag); - if (childNode == null) { - throw new JSApplicationIllegalArgumentException( - "disconnectAnimatedNodes: Animated node with tag (child) [" - + childNodeTag - + "] does not exist"); - } - parentNode.removeChild(childNode); - mUpdatedNodes.put(childNodeTag, childNode); - } - - @UiThread - public void connectAnimatedNodeToView(int animatedNodeTag, int viewTag) { - AnimatedNode node = mAnimatedNodes.get(animatedNodeTag); - if (node == null) { - throw new JSApplicationIllegalArgumentException( - "connectAnimatedNodeToView: Animated node with tag [" - + animatedNodeTag - + "] does not exist"); - } - if (!(node instanceof PropsAnimatedNode)) { - throw new JSApplicationIllegalArgumentException( - "connectAnimatedNodeToView: Animated node connected to view [" - + viewTag - + "] should be of type " - + PropsAnimatedNode.class.getName()); - } - if (mReactApplicationContext == null) { - throw new IllegalStateException( - "connectAnimatedNodeToView: Animated node could not be connected, no" - + " ReactApplicationContext: " - + viewTag); - } - - @Nullable - UIManager uiManager = - UIManagerHelper.getUIManagerForReactTag(mReactApplicationContext, viewTag); - if (uiManager == null) { - ReactSoftExceptionLogger.logSoftException( - TAG, - new ReactNoCrashSoftException( - "connectAnimatedNodeToView: Animated node could not be connected to UIManager -" - + " uiManager disappeared for tag: " - + viewTag)); - return; - } - - PropsAnimatedNode propsAnimatedNode = (PropsAnimatedNode) node; - propsAnimatedNode.connectToView(viewTag, uiManager); - mUpdatedNodes.put(animatedNodeTag, node); - } - - @UiThread - public void disconnectAnimatedNodeFromView(int animatedNodeTag, int viewTag) { - AnimatedNode node = mAnimatedNodes.get(animatedNodeTag); - if (node == null) { - throw new JSApplicationIllegalArgumentException( - "disconnectAnimatedNodeFromView: Animated node with tag [" - + animatedNodeTag - + "] does not exist"); - } - if (!(node instanceof PropsAnimatedNode)) { - throw new JSApplicationIllegalArgumentException( - "disconnectAnimatedNodeFromView: Animated node connected to view [" - + viewTag - + "] should be of type " - + PropsAnimatedNode.class.getName()); - } - PropsAnimatedNode propsAnimatedNode = (PropsAnimatedNode) node; - propsAnimatedNode.disconnectFromView(viewTag); - } - - @UiThread - public void getValue(int tag, @Nullable Callback callback) { - AnimatedNode node = mAnimatedNodes.get(tag); - if (node == null || !(node instanceof ValueAnimatedNode)) { - throw new JSApplicationIllegalArgumentException( - "getValue: Animated node with tag [" + tag + "] does not exist or is not a 'value' node"); - } - double value = ((ValueAnimatedNode) node).getValue(); - if (callback != null) { - callback.invoke(value); - return; - } - - // If there's no callback, that means that JS is using the single-operation mode, and not - // passing any callbacks into Java. - // See NativeAnimatedHelper.js for details. - // Instead, we use RCTDeviceEventEmitter to pass data back to JS and emulate callbacks. - if (mReactApplicationContext == null) { - return; - } - WritableMap params = Arguments.createMap(); - params.putInt("tag", tag); - params.putDouble("value", value); - mReactApplicationContext.emitDeviceEvent("onNativeAnimatedModuleGetValue", params); - } - - @UiThread - public void restoreDefaultValues(int animatedNodeTag) { - AnimatedNode node = mAnimatedNodes.get(animatedNodeTag); - // Restoring default values needs to happen before UIManager operations so it is - // possible the node hasn't been created yet if it is being connected and - // disconnected in the same batch. In that case we don't need to restore - // default values since it will never actually update the view. - if (node == null) { - return; - } - if (!(node instanceof PropsAnimatedNode)) { - throw new JSApplicationIllegalArgumentException( - "Animated node connected to view [?] should be of type " - + PropsAnimatedNode.class.getName()); - } - PropsAnimatedNode propsAnimatedNode = (PropsAnimatedNode) node; - propsAnimatedNode.restoreDefaultValues(); - } - - @UiThread - public void addAnimatedEventToView( - int viewTag, String eventHandlerName, ReadableMap eventMapping) { - int nodeTag = eventMapping.getInt("animatedValueTag"); - AnimatedNode node = mAnimatedNodes.get(nodeTag); - if (node == null) { - throw new JSApplicationIllegalArgumentException( - "addAnimatedEventToView: Animated node with tag [" + nodeTag + "] does not exist"); - } - if (!(node instanceof ValueAnimatedNode)) { - throw new JSApplicationIllegalArgumentException( - "addAnimatedEventToView: Animated node on view [" - + viewTag - + "] connected to event handler (" - + eventHandlerName - + ") should be of type " - + ValueAnimatedNode.class.getName()); - } - - ReadableArray path = eventMapping.getArray("nativeEventPath"); - // NULLSAFE_FIXME[Nullable Dereference] - List pathList = new ArrayList<>(path.size()); - // NULLSAFE_FIXME[Nullable Dereference] - for (int i = 0; i < path.size(); i++) { - // NULLSAFE_FIXME[Nullable Dereference] - pathList.add(path.getString(i)); - } - - String eventName = normalizeEventName(eventHandlerName); - - EventAnimationDriver eventDriver = - new EventAnimationDriver(eventName, viewTag, pathList, (ValueAnimatedNode) node); - mEventDrivers.add(eventDriver); - - if (eventName.equals("topScroll")) { - // Handle the custom topScrollEnded event sent by the ScrollViews when the user stops dragging - addAnimatedEventToView(viewTag, "topScrollEnded", eventMapping); - } - } - - @UiThread - public void removeAnimatedEventFromView( - int viewTag, String eventHandlerName, int animatedValueTag) { - String eventName = normalizeEventName(eventHandlerName); - - ListIterator it = mEventDrivers.listIterator(); - while (it.hasNext()) { - EventAnimationDriver driver = it.next(); - // NULLSAFE_FIXME[Nullable Dereference] - if (eventName.equals(driver.eventName) - // NULLSAFE_FIXME[Nullable Dereference] - && viewTag == driver.viewTag - // NULLSAFE_FIXME[Nullable Dereference] - && animatedValueTag == driver.valueNode.tag) { - it.remove(); - break; - } - } - - if (eventName.equals("topScroll")) { - // Handle the custom topScrollEnded event sent by the ScrollViews when the user stops dragging - removeAnimatedEventFromView(viewTag, "topScrollEnded", animatedValueTag); - } - } - - @Override - public void onEventDispatch(final Event event) { - // Events can be dispatched from any thread so we have to make sure handleEvent is run from the - // UI thread. - if (UiThreadUtil.isOnUiThread()) { - handleEvent(event); - } else { - UiThreadUtil.runOnUiThread( - new Runnable() { - @Override - public void run() { - handleEvent(event); - } - }); - } - } - - @UiThread - private void handleEvent(Event event) { - if (mEventDrivers.isEmpty()) { - return; - } - - boolean foundAtLeastOneDriver = false; - Event.EventAnimationDriverMatchSpec matchSpec = event.getEventAnimationDriverMatchSpec(); - for (EventAnimationDriver driver : mEventDrivers) { - if (matchSpec.match(driver.viewTag, driver.eventName)) { - foundAtLeastOneDriver = true; - stopAnimationsForNode(driver.valueNode); - event.dispatchModern(driver); - mRunUpdateNodeList.add(driver.valueNode); - } - } - - if (foundAtLeastOneDriver) { - updateNodes(mRunUpdateNodeList); - mRunUpdateNodeList.clear(); - } - } - - /** - * Animation loop performs two BFSes over the graph of animated nodes. We use incremented {@code - * mAnimatedGraphBFSColor} to mark nodes as visited in each of the BFSes which saves additional - * loops for clearing "visited" states. - * - *

First BFS starts with nodes that are in {@code mUpdatedNodes} (that is, their value have - * been modified from JS in the last batch of JS operations) or directly attached to an active - * animation (hence linked to objects from {@code mActiveAnimations}). In that step we calculate - * an attribute {@code activeIncomingNodes}. The second BFS runs in topological order over the - * sub-graph of *active* nodes. This is done by adding node to the BFS queue only if all its - * "predecessors" have already been visited. - */ - @UiThread - public void runUpdates(long frameTimeNanos) { - UiThreadUtil.assertOnUiThread(); - boolean hasFinishedAnimations = false; - - for (int i = 0; i < mUpdatedNodes.size(); i++) { - AnimatedNode node = mUpdatedNodes.valueAt(i); - mRunUpdateNodeList.add(node); - } - - // Clean mUpdatedNodes queue - mUpdatedNodes.clear(); - - for (int i = 0; i < mActiveAnimations.size(); i++) { - AnimationDriver animation = mActiveAnimations.valueAt(i); - animation.runAnimationStep(frameTimeNanos); - AnimatedNode valueNode = animation.animatedValue; - mRunUpdateNodeList.add(valueNode); - if (animation.hasFinished) { - hasFinishedAnimations = true; - } - } - - updateNodes(mRunUpdateNodeList); - mRunUpdateNodeList.clear(); - - // Cleanup finished animations. Iterate over the array of animations and override ones that has - // finished, then resize `mActiveAnimations`. - if (hasFinishedAnimations) { - WritableArray events = null; - for (int i = mActiveAnimations.size() - 1; i >= 0; i--) { - AnimationDriver animation = mActiveAnimations.valueAt(i); - if (animation.hasFinished) { - if (animation.endCallback != null) { - WritableMap endCallbackResponse = Arguments.createMap(); - endCallbackResponse.putBoolean("finished", true); - // NULLSAFE_FIXME[Nullable Dereference] - endCallbackResponse.putDouble("value", animation.animatedValue.nodeValue); - animation.endCallback.invoke(endCallbackResponse); - } else if (mReactApplicationContext != null) { - // If no callback is passed in, this /may/ be an animation set up by the single-op - // instruction from JS, meaning that no jsi::functions are passed into native and - // we communicate via RCTDeviceEventEmitter instead of callbacks. - WritableMap params = Arguments.createMap(); - params.putInt("animationId", animation.id); - params.putBoolean("finished", true); - // NULLSAFE_FIXME[Nullable Dereference] - params.putDouble("value", animation.animatedValue.nodeValue); - if (events == null) { - events = Arguments.createArray(); - } - events.pushMap(params); - } - mActiveAnimations.removeAt(i); - } - } - if (events != null) { - mReactApplicationContext.emitDeviceEvent("onNativeAnimatedModuleAnimationFinished", events); - } - } - } - - Set getTagsOfConnectedNodes(int tag, String eventName) { - Set tags = new HashSet<>(); - - // Filter only relevant animation drivers - ListIterator it = mEventDrivers.listIterator(); - while (it.hasNext()) { - EventAnimationDriver driver = it.next(); - if (driver != null) { - if (eventName.equals(driver.eventName) && tag == driver.viewTag) { - tags.add(driver.viewTag); - if (driver.valueNode != null && driver.valueNode.children != null) { - for (AnimatedNode node : driver.valueNode.children) { - tags.add(node.tag); - } - } - } - } - } - - return tags; - } - - @UiThread - private void updateNodes(List nodes) { - int activeNodesCount = 0; - int updatedNodesCount = 0; - - // STEP 1. - // BFS over graph of nodes. Update `mIncomingNodes` attribute for each node during that BFS. - // Store number of visited nodes in `activeNodesCount`. We "execute" active animations as a part - // of this step. - - mAnimatedGraphBFSColor++; /* use new color */ - if (mAnimatedGraphBFSColor == AnimatedNode.INITIAL_BFS_COLOR) { - // value "0" is used as an initial color for a new node, using it in BFS may cause some nodes - // to be skipped. - mAnimatedGraphBFSColor++; - } - - Queue nodesQueue = new ArrayDeque<>(); - for (AnimatedNode node : nodes) { - if (node.BFSColor != mAnimatedGraphBFSColor) { - node.BFSColor = mAnimatedGraphBFSColor; - activeNodesCount++; - nodesQueue.add(node); - } - } - - while (!nodesQueue.isEmpty()) { - AnimatedNode nextNode = nodesQueue.poll(); - // NULLSAFE_FIXME[Nullable Dereference] - if (nextNode.children != null) { - // NULLSAFE_FIXME[Nullable Dereference] - for (int i = 0; i < nextNode.children.size(); i++) { - // NULLSAFE_FIXME[Nullable Dereference] - AnimatedNode child = nextNode.children.get(i); - child.activeIncomingNodes++; - if (child.BFSColor != mAnimatedGraphBFSColor) { - child.BFSColor = mAnimatedGraphBFSColor; - activeNodesCount++; - nodesQueue.add(child); - } - } - } - } - - // STEP 2 - // BFS over the graph of active nodes in topological order -> visit node only when all its - // "predecessors" in the graph have already been visited. It is important to visit nodes in that - // order as they may often use values of their predecessors in order to calculate "next state" - // of their own. We start by determining the starting set of nodes by looking for nodes with - // `activeIncomingNodes = 0` (those can only be the ones that we start BFS in the previous - // step). We store number of visited nodes in this step in `updatedNodesCount` - - mAnimatedGraphBFSColor++; - if (mAnimatedGraphBFSColor == AnimatedNode.INITIAL_BFS_COLOR) { - // see reasoning for this check a few lines above - mAnimatedGraphBFSColor++; - } - - // find nodes with zero "incoming nodes", those can be either nodes from `mUpdatedNodes` or - // ones connected to active animations - for (AnimatedNode node : nodes) { - if (node.activeIncomingNodes == 0 && node.BFSColor != mAnimatedGraphBFSColor) { - node.BFSColor = mAnimatedGraphBFSColor; - updatedNodesCount++; - nodesQueue.add(node); - } - } - - // Run main "update" loop - int cyclesDetected = 0; - while (!nodesQueue.isEmpty()) { - AnimatedNode nextNode = nodesQueue.poll(); - try { - // NULLSAFE_FIXME[Nullable Dereference] - nextNode.update(); - if (nextNode instanceof PropsAnimatedNode) { - // Send property updates to native view manager - ((PropsAnimatedNode) nextNode).updateView(); - } - } catch (JSApplicationCausedNativeException e) { - // An exception is thrown if the view hasn't been created yet. This can happen because - // views are created in batches. If this particular view didn't make it into a batch yet, - // the view won't exist and an exception will be thrown when attempting to start an - // animation on it. - // - // Eat the exception rather than crashing. The impact is that we may drop one or more - // frames of the animation. - FLog.e(TAG, "Native animation workaround, frame lost as result of race condition", e); - } - if (nextNode instanceof ValueAnimatedNode) { - // Potentially send events to JS when the node's value is updated - ((ValueAnimatedNode) nextNode).onValueUpdate(); - } - // NULLSAFE_FIXME[Nullable Dereference] - if (nextNode.children != null) { - // NULLSAFE_FIXME[Nullable Dereference] - for (int i = 0; i < nextNode.children.size(); i++) { - // NULLSAFE_FIXME[Nullable Dereference] - AnimatedNode child = nextNode.children.get(i); - child.activeIncomingNodes--; - if (child.BFSColor != mAnimatedGraphBFSColor && child.activeIncomingNodes == 0) { - child.BFSColor = mAnimatedGraphBFSColor; - updatedNodesCount++; - nodesQueue.add(child); - } else if (child.BFSColor == mAnimatedGraphBFSColor) { - cyclesDetected++; - } - } - } - } - - // Verify that we've visited *all* active nodes. Throw otherwise as this could mean there is a - // cycle in animated node graph, or that the graph is only partially set up. We also take - // advantage of the fact that all active nodes are visited in the step above so that all the - // nodes properties `activeIncomingNodes` are set to zero. - // In Fabric there can be race conditions between the JS thread setting up or tearing down - // animated nodes, and Fabric executing them on the UI thread, leading to temporary inconsistent - // states. - if (activeNodesCount != updatedNodesCount) { - if (mWarnedAboutGraphTraversal) { - return; - } - mWarnedAboutGraphTraversal = true; - - // Before crashing or logging soft exception, log details about current graph setup - FLog.e(TAG, "Detected animation cycle or disconnected graph. "); - for (AnimatedNode node : nodes) { - FLog.e(TAG, node.prettyPrintWithChildren()); - } - - // If we're running only in non-Fabric, we still throw an exception. - // In Fabric, it seems that animations enter an inconsistent state fairly often. - // We detect if the inconsistency is due to a cycle (a fatal error for which we must crash) - // or disconnected regions, indicating a partially-set-up animation graph, which is not - // fatal and can stay a warning. - String reason = - cyclesDetected > 0 ? "cycles (" + cyclesDetected + ")" : "disconnected regions"; - IllegalStateException ex = - new IllegalStateException( - "Looks like animated nodes graph has " - + reason - + ", there are " - + activeNodesCount - + " but toposort visited only " - + updatedNodesCount); - if (mEventListenerInitializedForFabric && cyclesDetected == 0) { - // TODO T71377544: investigate these SoftExceptions and see if we can remove entirely - // or fix the root cause - ReactSoftExceptionLogger.logSoftException(TAG, new ReactNoCrashSoftException(ex)); - } else if (mEventListenerInitializedForFabric) { - // TODO T71377544: investigate these SoftExceptions and see if we can remove entirely - // or fix the root cause - ReactSoftExceptionLogger.logSoftException(TAG, new ReactNoCrashSoftException(ex)); - } else { - throw ex; - } - } else { - mWarnedAboutGraphTraversal = false; - } - } - - private String normalizeEventName(String eventHandlerName) { - // Fabric UIManager also makes this assumption - String eventName = eventHandlerName; - if (eventHandlerName.startsWith("on")) { - eventName = "top" + eventHandlerName.substring(2); - } - - return eventName; - } -} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedNodesManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedNodesManager.kt new file mode 100644 index 00000000000..ac844e45f9d --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedNodesManager.kt @@ -0,0 +1,768 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.animated + +import android.util.SparseArray +import androidx.annotation.UiThread +import com.facebook.common.logging.FLog +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Callback +import com.facebook.react.bridge.JSApplicationCausedNativeException +import com.facebook.react.bridge.JSApplicationIllegalArgumentException +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactNoCrashSoftException +import com.facebook.react.bridge.ReactSoftExceptionLogger +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.UiThreadUtil +import com.facebook.react.bridge.WritableArray +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.common.UIManagerType +import com.facebook.react.uimanager.events.Event +import com.facebook.react.uimanager.events.EventDispatcherListener +import java.util.ArrayDeque +import java.util.ArrayList +import java.util.HashSet +import java.util.LinkedList +import java.util.Queue + +/** + * This is the main class that coordinates how native animated JS implementation drives UI changes. + * + * It implements a management interface for animated nodes graph as well as implements a graph + * traversal algorithm that is run for each animation frame. + * + * For each animation frame we visit animated nodes that might've been updated as well as their + * children that may use parent's values to update themselves. At the end of the traversal algorithm + * we expect to reach a special type of the node: PropsAnimatedNode that is then responsible for + * calculating property map which can be sent to native view hierarchy to update the view. + * + * IMPORTANT: This class should be accessed only from the UI Thread + */ +public class NativeAnimatedNodesManager( + private val reactApplicationContext: ReactApplicationContext? +) : EventDispatcherListener { + + private val animatedNodes = SparseArray() + private val activeAnimations = SparseArray() + private val updatedNodes = SparseArray() + + // List of event animation drivers for an event on view. + // There may be multiple drivers for the same event and view. + private val eventDrivers: MutableList = ArrayList() + private var animatedGraphBFSColor = 0 + + // Used to avoid allocating a new array on every frame in `runUpdates` and `onEventDispatch`. + private val runUpdateNodeList: MutableList = LinkedList() + + private var eventListenerInitializedForFabric = false + private var eventListenerInitializedForNonFabric = false + + private var warnedAboutGraphTraversal = false + + /** + * Initialize event listeners for Fabric UIManager or non-Fabric UIManager, exactly once. Once + * Fabric is the only UIManager, this logic can be simplified. This is expected to only be called + * from the native module thread. + * + * @param uiManagerType + */ + public fun initializeEventListenerForUIManagerType(@UIManagerType uiManagerType: Int): Unit { + val isEventListenerInitialized = + when (uiManagerType) { + UIManagerType.FABRIC -> eventListenerInitializedForFabric + else -> eventListenerInitializedForNonFabric + } + + if (isEventListenerInitialized) { + return + } + + val uiManager = + UIManagerHelper.getUIManager(checkNotNull(reactApplicationContext), uiManagerType) + if (uiManager != null) { + uiManager.eventDispatcher.addListener(this) + if (uiManagerType == UIManagerType.FABRIC) { + eventListenerInitializedForFabric = true + } else { + eventListenerInitializedForNonFabric = true + } + } + } + + public fun getNodeById(id: Int): AnimatedNode? = animatedNodes.get(id) + + public fun hasActiveAnimations(): Boolean = activeAnimations.size() > 0 || updatedNodes.size() > 0 + + @UiThread + public fun createAnimatedNode(tag: Int, config: ReadableMap): Unit { + if (animatedNodes.get(tag) != null) { + throw JSApplicationIllegalArgumentException( + "createAnimatedNode: Animated node [$tag] already exists") + } + val type = config.getString("type") + val node = + when (type) { + "style" -> StyleAnimatedNode(config, this) + "value" -> ValueAnimatedNode(config) + "color" -> ColorAnimatedNode(config, this, checkNotNull(reactApplicationContext)) + "props" -> PropsAnimatedNode(config, this) + "interpolation" -> InterpolationAnimatedNode(config) + "addition" -> AdditionAnimatedNode(config, this) + "subtraction" -> SubtractionAnimatedNode(config, this) + "division" -> DivisionAnimatedNode(config, this) + "multiplication" -> MultiplicationAnimatedNode(config, this) + "modulus" -> ModulusAnimatedNode(config, this) + "diffclamp" -> DiffClampAnimatedNode(config, this) + "transform" -> TransformAnimatedNode(config, this) + "tracking" -> TrackingAnimatedNode(config, this) + "object" -> ObjectAnimatedNode(config, this) + else -> throw JSApplicationIllegalArgumentException("Unsupported node type: $type") + } + node.tag = tag + animatedNodes.put(tag, node) + updatedNodes.put(tag, node) + } + + @UiThread + public fun updateAnimatedNodeConfig(tag: Int, config: ReadableMap?): Unit { + val node = + animatedNodes.get(tag) + ?: throw JSApplicationIllegalArgumentException( + "updateAnimatedNode: Animated node [$tag] does not exist") + + if (node is AnimatedNodeWithUpdateableConfig) { + stopAnimationsForNode(node) + (node as AnimatedNodeWithUpdateableConfig).onUpdateConfig(config) + updatedNodes.put(tag, node) + } + } + + @UiThread + public fun dropAnimatedNode(tag: Int): Unit { + animatedNodes.remove(tag) + updatedNodes.remove(tag) + } + + @UiThread + public fun startListeningToAnimatedNodeValue( + tag: Int, + listener: AnimatedNodeValueListener? + ): Unit { + val node = animatedNodes[tag] + if (node == null || node !is ValueAnimatedNode) { + throw JSApplicationIllegalArgumentException( + ("startListeningToAnimatedNodeValue: Animated node [${tag}] does not exist, or is not a 'value' node")) + } + node.setValueListener(listener) + } + + @UiThread + public fun stopListeningToAnimatedNodeValue(tag: Int): Unit { + val node = animatedNodes.get(tag) + if (node == null || node !is ValueAnimatedNode) { + throw JSApplicationIllegalArgumentException( + ("startListeningToAnimatedNodeValue: Animated node [${tag}] does not exist, or is not a 'value' node")) + } + node.setValueListener(null) + } + + @UiThread + public fun setAnimatedNodeValue(tag: Int, value: Double): Unit { + val node = animatedNodes.get(tag) + if (node == null || node !is ValueAnimatedNode) { + throw JSApplicationIllegalArgumentException( + ("setAnimatedNodeValue: Animated node [${tag}] does not exist, or is not a 'value' node")) + } + stopAnimationsForNode(node) + node.nodeValue = value + updatedNodes.put(tag, node) + } + + @UiThread + public fun setAnimatedNodeOffset(tag: Int, offset: Double): Unit { + val node = animatedNodes.get(tag) + if (node == null || node !is ValueAnimatedNode) { + throw JSApplicationIllegalArgumentException( + ("setAnimatedNodeOffset: Animated node [${tag}] does not exist, or is not a 'value' node")) + } + node.offset = offset + updatedNodes.put(tag, node) + } + + @UiThread + public fun flattenAnimatedNodeOffset(tag: Int): Unit { + val node = animatedNodes.get(tag) + if (node == null || node !is ValueAnimatedNode) { + throw JSApplicationIllegalArgumentException( + ("flattenAnimatedNodeOffset: Animated node [${tag}] does not exist, or is not a 'value' node")) + } + node.flattenOffset() + } + + @UiThread + public fun extractAnimatedNodeOffset(tag: Int): Unit { + val node = animatedNodes.get(tag) + if (node == null || node !is ValueAnimatedNode) { + throw JSApplicationIllegalArgumentException( + ("extractAnimatedNodeOffset: Animated node [${tag}] does not exist, or is not a 'value' node")) + } + node.extractOffset() + } + + @UiThread + public fun startAnimatingNode( + animationId: Int, + animatedNodeTag: Int, + animationConfig: ReadableMap, + endCallback: Callback? + ): Unit { + val node = + animatedNodes.get(animatedNodeTag) + ?: throw JSApplicationIllegalArgumentException( + "startAnimatingNode: Animated node [$animatedNodeTag] does not exist") + if (node !is ValueAnimatedNode) { + throw JSApplicationIllegalArgumentException( + ("startAnimatingNode: Animated node [${animatedNodeTag}] should be of type ${ValueAnimatedNode::class.java.name}")) + } + + val existingDriver = activeAnimations[animationId] + if (existingDriver != null) { + // animation with the given ID is already running, we need to update its configuration instead + // of spawning a new one + existingDriver.resetConfig(animationConfig) + return + } + + val type = animationConfig.getString("type") + val animation = + when (type) { + "frames" -> FrameBasedAnimationDriver(animationConfig) + "spring" -> SpringAnimation(animationConfig) + "decay" -> DecayAnimation(animationConfig) + else -> { + throw JSApplicationIllegalArgumentException( + "startAnimatingNode: Unsupported animation type [$animatedNodeTag]: $type") + } + } + animation.id = animationId + animation.endCallback = endCallback + animation.animatedValue = node + activeAnimations.put(animationId, animation) + } + + @UiThread + private fun stopAnimationsForNode(animatedNode: AnimatedNode) { + // in most of the cases there should never be more than a few active animations running at the + // same time. Therefore it does not make much sense to create an animationId -> animation + // object map that would require additional memory just to support the use-case of stopping + // an animation + var events: WritableArray? = null + var i = 0 + while (i < activeAnimations.size()) { + val animation = activeAnimations.valueAt(i) + if (animatedNode == animation.animatedValue) { + val animatedValueNonnull = checkNotNull(animation.animatedValue) + if (animation.endCallback != null) { + // Invoke animation end callback with {finished: false} + val endCallbackResponse = Arguments.createMap() + endCallbackResponse.putBoolean("finished", false) + endCallbackResponse.putDouble("value", animatedValueNonnull.nodeValue) + animation.endCallback?.invoke(endCallbackResponse) + } else if (reactApplicationContext != null) { + // If no callback is passed in, this /may/ be an animation set up by the single-op + // instruction from JS, meaning that no jsi::functions are passed into native and + // we communicate via RCTDeviceEventEmitter instead of callbacks. + val params = Arguments.createMap() + params.putInt("animationId", animation.id) + params.putBoolean("finished", false) + params.putDouble("value", animatedValueNonnull.nodeValue) + events = events ?: Arguments.createArray() + events.pushMap(params) + } + activeAnimations.removeAt(i) + i-- + } + i++ + } + if (events != null) { + reactApplicationContext?.emitDeviceEvent("onNativeAnimatedModuleAnimationFinished", events) + } + } + + @UiThread + public fun stopAnimation(animationId: Int): Unit { + // in most of the cases there should never be more than a few active animations running at the + // same time. Therefore it does not make much sense to create an animationId -> animation + // object map that would require additional memory just to support the use-case of stopping + // an animation + var events: WritableArray? = null + for (i in 0.. = ArrayList(path.size()) + for (i in 0.. + eventName == driver.eventName && + viewTag == driver.viewTag && + animatedValueTag == driver.valueNode.tag + } + ?.let { driver -> eventDrivers.remove(driver) } + + if (eventName == "topScroll") { + // Handle the custom topScrollEnded event sent by the ScrollViews when the user stops dragging + removeAnimatedEventFromView(viewTag, "topScrollEnded", animatedValueTag) + } + } + + override fun onEventDispatch(event: Event<*>) { + // Events can be dispatched from any thread so we have to make sure handleEvent is run from the + // UI thread. + if (UiThreadUtil.isOnUiThread()) { + handleEvent(event) + } else { + UiThreadUtil.runOnUiThread { handleEvent(event) } + } + } + + @UiThread + private fun handleEvent(event: Event<*>) { + if (eventDrivers.isEmpty()) { + return + } + + var foundAtLeastOneDriver = false + val matchSpec = event.eventAnimationDriverMatchSpec + for (driver in eventDrivers) { + if (matchSpec.match(driver.viewTag, driver.eventName)) { + foundAtLeastOneDriver = true + stopAnimationsForNode(driver.valueNode) + event.dispatchModern(driver) + runUpdateNodeList.add(driver.valueNode) + } + } + + if (foundAtLeastOneDriver) { + updateNodes(runUpdateNodeList) + runUpdateNodeList.clear() + } + } + + /** + * Animation loop performs two BFSes over the graph of animated nodes. We use incremented + * `mAnimatedGraphBFSColor` to mark nodes as visited in each of the BFSes which saves additional + * loops for clearing "visited" states. + * + * First BFS starts with nodes that are in `mUpdatedNodes` (that is, their value have been + * modified from JS in the last batch of JS operations) or directly attached to an active + * animation (hence linked to objects from `mActiveAnimations`). In that step we calculate an + * attribute `activeIncomingNodes`. The second BFS runs in topological order over the sub-graph of + * *active* nodes. This is done by adding node to the BFS queue only if all its "predecessors" + * have already been visited. + */ + @UiThread + public fun runUpdates(frameTimeNanos: Long): Unit { + UiThreadUtil.assertOnUiThread() + var hasFinishedAnimations = false + + for (i in 0.. runUpdateNodeList.add(valueNode) } + if (animation.hasFinished) { + hasFinishedAnimations = true + } + } + + updateNodes(runUpdateNodeList) + runUpdateNodeList.clear() + + // Cleanup finished animations. Iterate over the array of animations and override ones that has + // finished, then resize `mActiveAnimations`. + if (hasFinishedAnimations) { + var events: WritableArray? = null + for (i in activeAnimations.size() - 1 downTo 0) { + val animation = activeAnimations.valueAt(i) + if (animation.hasFinished) { + val animatedValueNonnull = checkNotNull(animation.animatedValue) + if (animation.endCallback != null) { + val endCallbackResponse = Arguments.createMap() + endCallbackResponse.putBoolean("finished", true) + endCallbackResponse.putDouble("value", animatedValueNonnull.nodeValue) + animation.endCallback?.invoke(endCallbackResponse) + } else if (reactApplicationContext != null) { + // If no callback is passed in, this /may/ be an animation set up by the single-op + // instruction from JS, meaning that no jsi::functions are passed into native and + // we communicate via RCTDeviceEventEmitter instead of callbacks. + val params = Arguments.createMap() + params.putInt("animationId", animation.id) + params.putBoolean("finished", true) + params.putDouble("value", animatedValueNonnull.nodeValue) + events = events ?: Arguments.createArray() + events.pushMap(params) + } + activeAnimations.removeAt(i) + } + } + if (events != null) { + reactApplicationContext?.emitDeviceEvent("onNativeAnimatedModuleAnimationFinished", events) + } + } + } + + internal fun getTagsOfConnectedNodes(tag: Int, eventName: String): Set { + val tags: MutableSet = HashSet() + + // Filter only relevant animation drivers + + eventDrivers.forEach { driver -> + if (eventName == driver.eventName && tag == driver.viewTag) { + tags.add(driver.viewTag) + driver.valueNode.children?.forEach { node -> tags.add(node.tag) } + } + } + + return tags + } + + @UiThread + private fun updateNodes(nodes: List) { + var activeNodesCount = 0 + var updatedNodesCount = 0 + + // STEP 1. + // BFS over graph of nodes. Update `mIncomingNodes` attribute for each node during that BFS. + // Store number of visited nodes in `activeNodesCount`. We "execute" active animations as a part + // of this step. + animatedGraphBFSColor++ /* use new color */ + if (animatedGraphBFSColor == AnimatedNode.INITIAL_BFS_COLOR) { + // value "0" is used as an initial color for a new node, using it in BFS may cause some nodes + // to be skipped. + animatedGraphBFSColor++ + } + + val nodesQueue: Queue = ArrayDeque() + for (node in nodes) { + if (node.BFSColor != animatedGraphBFSColor) { + node.BFSColor = animatedGraphBFSColor + activeNodesCount++ + nodesQueue.add(node) + } + } + + while (nodesQueue.isNotEmpty()) { + val nextNode = nodesQueue.poll() + nextNode?.children?.forEach { child -> + child.activeIncomingNodes++ + if (child.BFSColor != animatedGraphBFSColor) { + child.BFSColor = animatedGraphBFSColor + activeNodesCount++ + nodesQueue.add(child) + } + } + } + + // STEP 2 + // BFS over the graph of active nodes in topological order -> visit node only when all its + // "predecessors" in the graph have already been visited. It is important to visit nodes in that + // order as they may often use values of their predecessors in order to calculate "next state" + // of their own. We start by determining the starting set of nodes by looking for nodes with + // `activeIncomingNodes = 0` (those can only be the ones that we start BFS in the previous + // step). We store number of visited nodes in this step in `updatedNodesCount` + animatedGraphBFSColor++ + if (animatedGraphBFSColor == AnimatedNode.INITIAL_BFS_COLOR) { + // see reasoning for this check a few lines above + animatedGraphBFSColor++ + } + + // find nodes with zero "incoming nodes", those can be either nodes from `mUpdatedNodes` or + // ones connected to active animations + for (node in nodes) { + if (node.activeIncomingNodes == 0 && node.BFSColor != animatedGraphBFSColor) { + node.BFSColor = animatedGraphBFSColor + updatedNodesCount++ + nodesQueue.add(node) + } + } + + // Run main "update" loop + var cyclesDetected = 0 + while (!nodesQueue.isEmpty()) { + val nextNode = nodesQueue.poll() + try { + nextNode?.update() + if (nextNode is PropsAnimatedNode) { + // Send property updates to native view manager + nextNode.updateView() + } + } catch (e: JSApplicationCausedNativeException) { + // An exception is thrown if the view hasn't been created yet. This can happen because + // views are created in batches. If this particular view didn't make it into a batch yet, + // the view won't exist and an exception will be thrown when attempting to start an + // animation on it. + // + // Eat the exception rather than crashing. The impact is that we may drop one or more + // frames of the animation. + FLog.e(TAG, "Native animation workaround, frame lost as result of race condition", e) + } + if (nextNode is ValueAnimatedNode) { + // Potentially send events to JS when the node's value is updated + nextNode.onValueUpdate() + } + nextNode?.children?.forEach { child -> + child.activeIncomingNodes-- + if (child.BFSColor != animatedGraphBFSColor && child.activeIncomingNodes == 0) { + child.BFSColor = animatedGraphBFSColor + updatedNodesCount++ + nodesQueue.add(child) + } else if (child.BFSColor == animatedGraphBFSColor) { + cyclesDetected++ + } + } + } + + // Verify that we've visited *all* active nodes. Throw otherwise as this could mean there is a + // cycle in animated node graph, or that the graph is only partially set up. We also take + // advantage of the fact that all active nodes are visited in the step above so that all the + // nodes properties `activeIncomingNodes` are set to zero. + // In Fabric there can be race conditions between the JS thread setting up or tearing down + // animated nodes, and Fabric executing them on the UI thread, leading to temporary inconsistent + // states. + if (activeNodesCount != updatedNodesCount) { + if (warnedAboutGraphTraversal) { + return + } + warnedAboutGraphTraversal = true + + // Before crashing or logging soft exception, log details about current graph setup + FLog.e(TAG, "Detected animation cycle or disconnected graph. ") + for (node in nodes) { + FLog.e(TAG, node.prettyPrintWithChildren()) + } + + // If we're running only in non-Fabric, we still throw an exception. + // In Fabric, it seems that animations enter an inconsistent state fairly often. + // We detect if the inconsistency is due to a cycle (a fatal error for which we must crash) + // or disconnected regions, indicating a partially-set-up animation graph, which is not + // fatal and can stay a warning. + val reason = if (cyclesDetected > 0) ("cycles ($cyclesDetected)") else "disconnected regions" + val ex = + IllegalStateException( + ("Looks like animated nodes graph has ${reason}, there are ${activeNodesCount} but toposort visited only ${updatedNodesCount}")) + if (eventListenerInitializedForFabric && cyclesDetected == 0) { + // TODO T71377544: investigate these SoftExceptions and see if we can remove entirely + // or fix the root cause + ReactSoftExceptionLogger.logSoftException(TAG, ReactNoCrashSoftException(ex)) + } else if (eventListenerInitializedForFabric) { + // TODO T71377544: investigate these SoftExceptions and see if we can remove entirely + // or fix the root cause + ReactSoftExceptionLogger.logSoftException(TAG, ReactNoCrashSoftException(ex)) + } else { + throw ex + } + } else { + warnedAboutGraphTraversal = false + } + } + + private fun normalizeEventName(eventHandlerName: String): String = + // Fabric UIManager also makes this assumption + if (eventHandlerName.startsWith("on")) { + "top${eventHandlerName.substring(2)}" + } else { + eventHandlerName + } + + private companion object { + private const val TAG = "NativeAnimatedNodesManager" + } +}