/** * Copyright (c) 2015-present, Facebook, Inc. * All rights reserved. * * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. An additional grant * of patent rights can be found in the PATENTS file in the same directory. */ package com.facebook.react.animated; import android.util.SparseArray; import com.facebook.infer.annotation.Assertions; import com.facebook.common.logging.FLog; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.Callback; import com.facebook.react.bridge.JSApplicationIllegalArgumentException; import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.UiThreadUtil; import com.facebook.react.bridge.WritableMap; import com.facebook.react.common.ReactConstants; import com.facebook.react.uimanager.IllegalViewOperationException; import com.facebook.react.uimanager.UIImplementation; import com.facebook.react.uimanager.UIManagerModule; 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.HashMap; import java.util.List; import java.util.Map; import java.util.Queue; import javax.annotation.Nullable; /** * 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 */ /*package*/ class NativeAnimatedNodesManager implements EventDispatcherListener { private final SparseArray<AnimatedNode> mAnimatedNodes = new SparseArray<>(); private final SparseArray<AnimationDriver> mActiveAnimations = new SparseArray<>(); private final SparseArray<AnimatedNode> mUpdatedNodes = new SparseArray<>(); private final Map<String, EventAnimationDriver> mEventDrivers = new HashMap<>(); private final Map<String, Map<String, String>> mCustomEventTypes; private final UIImplementation mUIImplementation; private int mAnimatedGraphBFSColor = 0; public NativeAnimatedNodesManager(UIManagerModule uiManager) { mUIImplementation = uiManager.getUIImplementation(); uiManager.getEventDispatcher().addListener(this); Object customEventTypes = Assertions.assertNotNull(uiManager.getConstants()).get("customDirectEventTypes"); mCustomEventTypes = (Map<String, Map<String, String>>) customEventTypes; } /*package*/ @Nullable AnimatedNode getNodeById(int id) { return mAnimatedNodes.get(id); } public boolean hasActiveAnimations() { return mActiveAnimations.size() > 0 || mUpdatedNodes.size() > 0; } public void createAnimatedNode(int tag, ReadableMap config) { if (mAnimatedNodes.get(tag) != null) { throw new JSApplicationIllegalArgumentException("Animated node with tag " + 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 ("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 ("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 { throw new JSApplicationIllegalArgumentException("Unsupported node type: " + type); } node.mTag = tag; mAnimatedNodes.put(tag, node); mUpdatedNodes.put(tag, node); } public void dropAnimatedNode(int tag) { mAnimatedNodes.remove(tag); mUpdatedNodes.remove(tag); } public void startListeningToAnimatedNodeValue(int tag, AnimatedNodeValueListener listener) { AnimatedNode node = mAnimatedNodes.get(tag); if (node == null || !(node instanceof ValueAnimatedNode)) { throw new JSApplicationIllegalArgumentException("Animated node with tag " + tag + " does not exists or is not a 'value' node"); } ((ValueAnimatedNode) node).setValueListener(listener); } public void stopListeningToAnimatedNodeValue(int tag) { AnimatedNode node = mAnimatedNodes.get(tag); if (node == null || !(node instanceof ValueAnimatedNode)) { throw new JSApplicationIllegalArgumentException("Animated node with tag " + tag + " does not exists or is not a 'value' node"); } ((ValueAnimatedNode) node).setValueListener(null); } public void setAnimatedNodeValue(int tag, double value) { AnimatedNode node = mAnimatedNodes.get(tag); if (node == null || !(node instanceof ValueAnimatedNode)) { throw new JSApplicationIllegalArgumentException("Animated node with tag " + tag + " does not exists or is not a 'value' node"); } ((ValueAnimatedNode) node).mValue = value; mUpdatedNodes.put(tag, node); } public void setAnimatedNodeOffset(int tag, double offset) { AnimatedNode node = mAnimatedNodes.get(tag); if (node == null || !(node instanceof ValueAnimatedNode)) { throw new JSApplicationIllegalArgumentException("Animated node with tag " + tag + " does not exists or is not a 'value' node"); } ((ValueAnimatedNode) node).mOffset = offset; mUpdatedNodes.put(tag, node); } public void flattenAnimatedNodeOffset(int tag) { AnimatedNode node = mAnimatedNodes.get(tag); if (node == null || !(node instanceof ValueAnimatedNode)) { throw new JSApplicationIllegalArgumentException("Animated node with tag " + tag + " does not exists or is not a 'value' node"); } ((ValueAnimatedNode) node).flattenOffset(); } public void extractAnimatedNodeOffset(int tag) { AnimatedNode node = mAnimatedNodes.get(tag); if (node == null || !(node instanceof ValueAnimatedNode)) { throw new JSApplicationIllegalArgumentException("Animated node with tag " + tag + " does not exists or is not a 'value' node"); } ((ValueAnimatedNode) node).extractOffset(); } public void startAnimatingNode( int animationId, int animatedNodeTag, ReadableMap animationConfig, Callback endCallback) { AnimatedNode node = mAnimatedNodes.get(animatedNodeTag); if (node == null) { throw new JSApplicationIllegalArgumentException("Animated node with tag " + animatedNodeTag + " does not exists"); } if (!(node instanceof ValueAnimatedNode)) { throw new JSApplicationIllegalArgumentException("Animated node should be of type " + ValueAnimatedNode.class.getName()); } 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("Unsupported animation type: " + type); } animation.mId = animationId; animation.mEndCallback = endCallback; animation.mAnimatedValue = (ValueAnimatedNode) node; mActiveAnimations.put(animationId, animation); } 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 for (int i = 0; i < mActiveAnimations.size(); i++) { AnimationDriver animation = mActiveAnimations.valueAt(i); if (animation.mId == animationId) { // Invoke animation end callback with {finished: false} WritableMap endCallbackResponse = Arguments.createMap(); endCallbackResponse.putBoolean("finished", false); animation.mEndCallback.invoke(endCallbackResponse); mActiveAnimations.removeAt(i); return; } } // 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. } public void connectAnimatedNodes(int parentNodeTag, int childNodeTag) { AnimatedNode parentNode = mAnimatedNodes.get(parentNodeTag); if (parentNode == null) { throw new JSApplicationIllegalArgumentException("Animated node with tag " + parentNodeTag + " does not exists"); } AnimatedNode childNode = mAnimatedNodes.get(childNodeTag); if (childNode == null) { throw new JSApplicationIllegalArgumentException("Animated node with tag " + childNodeTag + " does not exists"); } 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("Animated node with tag " + parentNodeTag + " does not exists"); } AnimatedNode childNode = mAnimatedNodes.get(childNodeTag); if (childNode == null) { throw new JSApplicationIllegalArgumentException("Animated node with tag " + childNodeTag + " does not exists"); } parentNode.removeChild(childNode); mUpdatedNodes.put(childNodeTag, childNode); } public void connectAnimatedNodeToView(int animatedNodeTag, int viewTag) { AnimatedNode node = mAnimatedNodes.get(animatedNodeTag); if (node == null) { throw new JSApplicationIllegalArgumentException("Animated node with tag " + animatedNodeTag + " does not exists"); } if (!(node instanceof PropsAnimatedNode)) { throw new JSApplicationIllegalArgumentException("Animated node connected to view should be" + "of type " + PropsAnimatedNode.class.getName()); } PropsAnimatedNode propsAnimatedNode = (PropsAnimatedNode) node; if (propsAnimatedNode.mConnectedViewTag != -1) { throw new JSApplicationIllegalArgumentException("Animated node " + animatedNodeTag + " is " + "already attached to a view"); } propsAnimatedNode.mConnectedViewTag = viewTag; mUpdatedNodes.put(animatedNodeTag, node); } public void disconnectAnimatedNodeFromView(int animatedNodeTag, int viewTag) { AnimatedNode node = mAnimatedNodes.get(animatedNodeTag); if (node == null) { throw new JSApplicationIllegalArgumentException("Animated node with tag " + animatedNodeTag + " does not exists"); } if (!(node instanceof PropsAnimatedNode)) { throw new JSApplicationIllegalArgumentException("Animated node connected to view should be" + "of type " + PropsAnimatedNode.class.getName()); } PropsAnimatedNode propsAnimatedNode = (PropsAnimatedNode) node; if (propsAnimatedNode.mConnectedViewTag != viewTag) { throw new JSApplicationIllegalArgumentException("Attempting to disconnect view that has " + "not been connected with the given animated node"); } propsAnimatedNode.mConnectedViewTag = -1; } public void addAnimatedEventToView(int viewTag, String eventName, ReadableMap eventMapping) { int nodeTag = eventMapping.getInt("animatedValueTag"); AnimatedNode node = mAnimatedNodes.get(nodeTag); if (node == null) { throw new JSApplicationIllegalArgumentException("Animated node with tag " + nodeTag + " does not exists"); } if (!(node instanceof ValueAnimatedNode)) { throw new JSApplicationIllegalArgumentException("Animated node connected to event should be" + "of type " + ValueAnimatedNode.class.getName()); } ReadableArray path = eventMapping.getArray("nativeEventPath"); List<String> pathList = new ArrayList<>(path.size()); for (int i = 0; i < path.size(); i++) { pathList.add(path.getString(i)); } EventAnimationDriver event = new EventAnimationDriver(pathList, (ValueAnimatedNode) node); mEventDrivers.put(viewTag + eventName, event); } public void removeAnimatedEventFromView(int viewTag, String eventName) { mEventDrivers.remove(viewTag + eventName); } @Override public void onEventDispatch(Event event) { // Only support events dispatched from the UI thread. if (!UiThreadUtil.isOnUiThread()) { return; } if (!mEventDrivers.isEmpty()) { // If the event has a different name in native convert it to it's JS name. String eventName = event.getEventName(); Map<String, String> customEventType = mCustomEventTypes.get(eventName); if (customEventType != null) { eventName = customEventType.get("registrationName"); } EventAnimationDriver eventDriver = mEventDrivers.get(event.getViewTag() + eventName); if (eventDriver != null) { event.dispatch(eventDriver); mUpdatedNodes.put(eventDriver.mValueNode.mTag, eventDriver.mValueNode); } } } /** * 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 mActiveIncomingNodes}. 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. */ public void runUpdates(long frameTimeNanos) { UiThreadUtil.assertOnUiThread(); int activeNodesCount = 0; int updatedNodesCount = 0; boolean hasFinishedAnimations = false; // STEP 1. // BFS over graph of nodes starting from ones from `mUpdatedNodes` and ones that are attached to // active animations (from `mActiveAnimations)`. 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<AnimatedNode> nodesQueue = new ArrayDeque<>(); for (int i = 0; i < mUpdatedNodes.size(); i++) { AnimatedNode node = mUpdatedNodes.valueAt(i); if (node.mBFSColor != mAnimatedGraphBFSColor) { node.mBFSColor = mAnimatedGraphBFSColor; activeNodesCount++; nodesQueue.add(node); } } for (int i = 0; i < mActiveAnimations.size(); i++) { AnimationDriver animation = mActiveAnimations.valueAt(i); animation.runAnimationStep(frameTimeNanos); AnimatedNode valueNode = animation.mAnimatedValue; if (valueNode.mBFSColor != mAnimatedGraphBFSColor) { valueNode.mBFSColor = mAnimatedGraphBFSColor; activeNodesCount++; nodesQueue.add(valueNode); } if (animation.mHasFinished) { hasFinishedAnimations = true; } } while (!nodesQueue.isEmpty()) { AnimatedNode nextNode = nodesQueue.poll(); if (nextNode.mChildren != null) { for (int i = 0; i < nextNode.mChildren.size(); i++) { AnimatedNode child = nextNode.mChildren.get(i); child.mActiveIncomingNodes++; if (child.mBFSColor != mAnimatedGraphBFSColor) { child.mBFSColor = 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 // `mActiveIncomingNodes = 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 (int i = 0; i < mUpdatedNodes.size(); i++) { AnimatedNode node = mUpdatedNodes.valueAt(i); if (node.mActiveIncomingNodes == 0 && node.mBFSColor != mAnimatedGraphBFSColor) { node.mBFSColor = mAnimatedGraphBFSColor; updatedNodesCount++; nodesQueue.add(node); } } for (int i = 0; i < mActiveAnimations.size(); i++) { AnimationDriver animation = mActiveAnimations.valueAt(i); AnimatedNode valueNode = animation.mAnimatedValue; if (valueNode.mActiveIncomingNodes == 0 && valueNode.mBFSColor != mAnimatedGraphBFSColor) { valueNode.mBFSColor = mAnimatedGraphBFSColor; updatedNodesCount++; nodesQueue.add(valueNode); } } // Run main "update" loop while (!nodesQueue.isEmpty()) { AnimatedNode nextNode = nodesQueue.poll(); nextNode.update(); if (nextNode instanceof PropsAnimatedNode) { // Send property updates to native view manager try { ((PropsAnimatedNode) nextNode).updateView(mUIImplementation); } catch (IllegalViewOperationException 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(ReactConstants.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(); } if (nextNode.mChildren != null) { for (int i = 0; i < nextNode.mChildren.size(); i++) { AnimatedNode child = nextNode.mChildren.get(i); child.mActiveIncomingNodes--; if (child.mBFSColor != mAnimatedGraphBFSColor && child.mActiveIncomingNodes == 0) { child.mBFSColor = mAnimatedGraphBFSColor; updatedNodesCount++; nodesQueue.add(child); } } } } // Verify that we've visited *all* active nodes. Throw otherwise as this would mean there is a // cycle in animated node graph. We also take advantage of the fact that all active nodes are // visited in the step above so that all the nodes properties `mActiveIncomingNodes` are set to // zero if (activeNodesCount != updatedNodesCount) { throw new IllegalStateException("Looks like animated nodes graph has cycles, there are " + activeNodesCount + " but toposort visited only " + updatedNodesCount); } // Clean mUpdatedNodes queue mUpdatedNodes.clear(); // Cleanup finished animations. Iterate over the array of animations and override ones that has // finished, then resize `mActiveAnimations`. if (hasFinishedAnimations) { for (int i = mActiveAnimations.size() - 1; i >= 0; i--) { AnimationDriver animation = mActiveAnimations.valueAt(i); if (animation.mHasFinished) { WritableMap endCallbackResponse = Arguments.createMap(); endCallbackResponse.putBoolean("finished", true); animation.mEndCallback.invoke(endCallbackResponse); mActiveAnimations.removeAt(i); } } } } }