From 7766a0ae326de0be388f07416063991a210f2541 Mon Sep 17 00:00:00 2001 From: Ruslan Shestopalyuk Date: Tue, 15 Apr 2025 08:44:12 -0700 Subject: [PATCH] Migrate LayoutAnimationController Summary: ## Changelog: [Android] [Internal] - As in the title Differential Revision: D73040384 --- .../ReactAndroid/api/ReactAndroid.api | 12 +- .../LayoutAnimationController.java | 241 ------------------ .../LayoutAnimationController.kt | 225 ++++++++++++++++ 3 files changed, 231 insertions(+), 247 deletions(-) delete mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/LayoutAnimationController.java create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/LayoutAnimationController.kt diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 0d5f89d368f02f..4ef747213d6a37 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -5141,13 +5141,13 @@ public final class com/facebook/react/uimanager/events/TouchEventType$Companion public final fun getJSEventName (Lcom/facebook/react/uimanager/events/TouchEventType;)Ljava/lang/String; } -public class com/facebook/react/uimanager/layoutanimation/LayoutAnimationController { +public final class com/facebook/react/uimanager/layoutanimation/LayoutAnimationController { public fun ()V - public fun applyLayoutUpdate (Landroid/view/View;IIII)V - public fun deleteView (Landroid/view/View;Lcom/facebook/react/uimanager/layoutanimation/LayoutAnimationListener;)V - public fun initializeFromConfig (Lcom/facebook/react/bridge/ReadableMap;Lcom/facebook/react/bridge/Callback;)V - public fun reset ()V - public fun shouldAnimateLayout (Landroid/view/View;)Z + public final fun applyLayoutUpdate (Landroid/view/View;IIII)V + public final fun deleteView (Landroid/view/View;Lcom/facebook/react/uimanager/layoutanimation/LayoutAnimationListener;)V + public final fun initializeFromConfig (Lcom/facebook/react/bridge/ReadableMap;Lcom/facebook/react/bridge/Callback;)V + public final fun reset ()V + public final fun shouldAnimateLayout (Landroid/view/View;)Z } public abstract interface class com/facebook/react/uimanager/layoutanimation/LayoutAnimationListener { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/LayoutAnimationController.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/LayoutAnimationController.java deleted file mode 100644 index 8869d825eb8814..00000000000000 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/LayoutAnimationController.java +++ /dev/null @@ -1,241 +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.uimanager.layoutanimation; - -import android.os.Handler; -import android.util.SparseArray; -import android.view.View; -import android.view.ViewGroup; -import android.view.animation.Animation; -import androidx.annotation.Nullable; -import com.facebook.react.bridge.Callback; -import com.facebook.react.bridge.ReadableMap; -import com.facebook.react.bridge.UiThreadUtil; -import com.facebook.react.common.annotations.internal.LegacyArchitecture; -import com.facebook.react.common.annotations.internal.LegacyArchitectureLogLevel; -import com.facebook.react.common.annotations.internal.LegacyArchitectureLogger; -import javax.annotation.concurrent.NotThreadSafe; - -/** - * Class responsible for animation layout changes, if a valid layout animation config has been - * supplied. If not animation is available, layout change is applied immediately instead of - * performing an animation. - */ -@NotThreadSafe -@LegacyArchitecture -public class LayoutAnimationController { - - static { - LegacyArchitectureLogger.assertLegacyArchitecture( - "LayoutAnimationController", LegacyArchitectureLogLevel.WARNING); - } - - private final AbstractLayoutAnimation mLayoutCreateAnimation = new LayoutCreateAnimation(); - private final AbstractLayoutAnimation mLayoutUpdateAnimation = new LayoutUpdateAnimation(); - private final AbstractLayoutAnimation mLayoutDeleteAnimation = new LayoutDeleteAnimation(); - private final SparseArray mLayoutHandlers = new SparseArray<>(0); - - private boolean mShouldAnimateLayout; - private long mMaxAnimationDuration = -1; - @Nullable private Runnable mCompletionRunnable; - - public void initializeFromConfig( - final @Nullable ReadableMap config, final Callback completionCallback) { - if (config == null) { - reset(); - return; - } - - mShouldAnimateLayout = false; - int globalDuration = config.hasKey("duration") ? config.getInt("duration") : 0; - if (config.hasKey(LayoutAnimationType.toString(LayoutAnimationType.CREATE))) { - mLayoutCreateAnimation.initializeFromConfig( - config.getMap(LayoutAnimationType.toString(LayoutAnimationType.CREATE)), globalDuration); - mShouldAnimateLayout = true; - } - if (config.hasKey(LayoutAnimationType.toString(LayoutAnimationType.UPDATE))) { - mLayoutUpdateAnimation.initializeFromConfig( - config.getMap(LayoutAnimationType.toString(LayoutAnimationType.UPDATE)), globalDuration); - mShouldAnimateLayout = true; - } - if (config.hasKey(LayoutAnimationType.toString(LayoutAnimationType.DELETE))) { - mLayoutDeleteAnimation.initializeFromConfig( - config.getMap(LayoutAnimationType.toString(LayoutAnimationType.DELETE)), globalDuration); - mShouldAnimateLayout = true; - } - - if (mShouldAnimateLayout && completionCallback != null) { - mCompletionRunnable = - new Runnable() { - @Override - public void run() { - completionCallback.invoke(Boolean.TRUE); - } - }; - } - } - - public void reset() { - mLayoutCreateAnimation.reset(); - mLayoutUpdateAnimation.reset(); - mLayoutDeleteAnimation.reset(); - mCompletionRunnable = null; - mShouldAnimateLayout = false; - mMaxAnimationDuration = -1; - for (int i = mLayoutHandlers.size() - 1; i >= 0; i--) { - LayoutHandlingAnimation animation = mLayoutHandlers.valueAt(i); - if (!animation.isValid()) { - mLayoutHandlers.removeAt(i); - } - } - } - - public boolean shouldAnimateLayout(View viewToAnimate) { - // if view parent is null, skip animation: view have been clipped, we don't want animation to - // resume when view is re-attached to parent, which is the standard android animation behavior. - // If there's a layout handling animation going on, it should be animated nonetheless since the - // ongoing animation needs to be updated. - if (viewToAnimate == null) { - return false; - } - return (mShouldAnimateLayout && viewToAnimate.getParent() != null) - || mLayoutHandlers.get(viewToAnimate.getId()) != null; - } - - /** - * Update layout of given view, via immediate update or animation depending on the current batch - * layout animation configuration supplied during initialization. Handles create and update - * animations. - * - * @param view the view to update layout of - * @param x the new X position for the view - * @param y the new Y position for the view - * @param width the new width value for the view - * @param height the new height value for the view - */ - public void applyLayoutUpdate(View view, int x, int y, int width, int height) { - UiThreadUtil.assertOnUiThread(); - - final int reactTag = view.getId(); - - // Update an ongoing animation if possible, otherwise the layout update would be ignored as - // the existing animation would still animate to the old layout. - LayoutHandlingAnimation existingAnimation = mLayoutHandlers.get(reactTag); - if (existingAnimation != null) { - if (!existingAnimation.isValid()) { - mLayoutHandlers.remove(reactTag); - } else { - existingAnimation.onLayoutUpdate(x, y, width, height); - return; - } - } - - // Determine which animation to use : if view is initially invisible, use create animation, - // otherwise use update animation. This approach is easier than maintaining a list of tags - // for recently created views. - AbstractLayoutAnimation layoutAnimation = - (view.getWidth() == 0 || view.getHeight() == 0) - ? mLayoutCreateAnimation - : mLayoutUpdateAnimation; - - Animation animation = layoutAnimation.createAnimation(view, x, y, width, height); - - if (animation instanceof LayoutHandlingAnimation) { - animation.setAnimationListener( - new Animation.AnimationListener() { - @Override - public void onAnimationStart(Animation animation) { - mLayoutHandlers.put(reactTag, (LayoutHandlingAnimation) animation); - } - - @Override - public void onAnimationEnd(Animation animation) { - mLayoutHandlers.remove(reactTag); - } - - @Override - public void onAnimationRepeat(Animation animation) {} - }); - } else { - view.layout(x, y, x + width, y + height); - } - - if (animation != null) { - long animationDuration = animation.getDuration(); - if (animationDuration > mMaxAnimationDuration) { - mMaxAnimationDuration = animationDuration; - scheduleCompletionCallback(animationDuration); - } - - view.startAnimation(animation); - } - } - - /** - * Animate a view deletion using the layout animation configuration supplied during - * initialization. - * - * @param view The view to animate. - * @param listener Called once the animation is finished, should be used to completely remove the - * view. - */ - public void deleteView(final View view, final LayoutAnimationListener listener) { - UiThreadUtil.assertOnUiThread(); - - Animation animation = - mLayoutDeleteAnimation.createAnimation( - view, view.getLeft(), view.getTop(), view.getWidth(), view.getHeight()); - - if (animation != null) { - disableUserInteractions(view); - - animation.setAnimationListener( - new Animation.AnimationListener() { - @Override - public void onAnimationStart(Animation anim) {} - - @Override - public void onAnimationRepeat(Animation anim) {} - - @Override - public void onAnimationEnd(Animation anim) { - listener.onAnimationEnd(); - } - }); - - long animationDuration = animation.getDuration(); - if (animationDuration > mMaxAnimationDuration) { - scheduleCompletionCallback(animationDuration); - mMaxAnimationDuration = animationDuration; - } - - view.startAnimation(animation); - } else { - listener.onAnimationEnd(); - } - } - - /** Disables user interactions for a view and all it's subviews. */ - private void disableUserInteractions(View view) { - view.setClickable(false); - if (view instanceof ViewGroup) { - ViewGroup viewGroup = (ViewGroup) view; - for (int i = 0; i < viewGroup.getChildCount(); i++) { - disableUserInteractions(viewGroup.getChildAt(i)); - } - } - } - - private void scheduleCompletionCallback(long delayMillis) { - if (mCompletionRunnable != null) { - Handler completionHandler = UiThreadUtil.getUiThreadHandler(); - completionHandler.removeCallbacks(mCompletionRunnable); - completionHandler.postDelayed(mCompletionRunnable, delayMillis); - } - } -} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/LayoutAnimationController.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/LayoutAnimationController.kt new file mode 100644 index 00000000000000..6d5a6be0dc9c2c --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/LayoutAnimationController.kt @@ -0,0 +1,225 @@ +/* + * 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.uimanager.layoutanimation + +import android.util.SparseArray +import android.view.View +import android.view.ViewGroup +import android.view.animation.Animation +import android.view.animation.Animation.AnimationListener +import com.facebook.react.bridge.Callback +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.UiThreadUtil.assertOnUiThread +import com.facebook.react.bridge.UiThreadUtil.getUiThreadHandler +import com.facebook.react.common.annotations.internal.LegacyArchitecture +import com.facebook.react.common.annotations.internal.LegacyArchitectureLogLevel +import com.facebook.react.common.annotations.internal.LegacyArchitectureLogger.assertLegacyArchitecture +import com.facebook.react.uimanager.layoutanimation.LayoutAnimationType.Companion.toString +import javax.annotation.concurrent.NotThreadSafe + +/** + * Class responsible for animation layout changes, if a valid layout animation config has been + * supplied. If not animation is available, layout change is applied immediately instead of + * performing an animation. + */ +@NotThreadSafe +@LegacyArchitecture +public class LayoutAnimationController { + private val layoutCreateAnimation: AbstractLayoutAnimation = LayoutCreateAnimation() + private val layoutUpdateAnimation: AbstractLayoutAnimation = LayoutUpdateAnimation() + private val layoutDeleteAnimation: AbstractLayoutAnimation = LayoutDeleteAnimation() + private val layoutHandlers = SparseArray(0) + + private var shouldAnimateLayout = false + private var maxAnimationDuration: Long = -1 + private var completionRunnable: Runnable? = null + + public fun initializeFromConfig(config: ReadableMap?, completionCallback: Callback?) { + if (config == null) { + reset() + return + } + + shouldAnimateLayout = false + val globalDuration = if (config.hasKey("duration")) config.getInt("duration") else 0 + if (config.hasKey(toString(LayoutAnimationType.CREATE))) { + layoutCreateAnimation.initializeFromConfig( + config.getMap(toString(LayoutAnimationType.CREATE))!!, globalDuration) + shouldAnimateLayout = true + } + if (config.hasKey(toString(LayoutAnimationType.UPDATE))) { + layoutUpdateAnimation.initializeFromConfig( + config.getMap(toString(LayoutAnimationType.UPDATE))!!, globalDuration) + shouldAnimateLayout = true + } + if (config.hasKey(toString(LayoutAnimationType.DELETE))) { + layoutDeleteAnimation.initializeFromConfig( + config.getMap(toString(LayoutAnimationType.DELETE))!!, globalDuration) + shouldAnimateLayout = true + } + + if (shouldAnimateLayout && completionCallback != null) { + completionRunnable = Runnable { completionCallback.invoke(java.lang.Boolean.TRUE) } + } + } + + public fun reset() { + layoutCreateAnimation.reset() + layoutUpdateAnimation.reset() + layoutDeleteAnimation.reset() + completionRunnable = null + shouldAnimateLayout = false + maxAnimationDuration = -1 + for (i in layoutHandlers.size() - 1 downTo 0) { + val animation = layoutHandlers.valueAt(i) + if (!animation!!.isValid()) { + layoutHandlers.removeAt(i) + } + } + } + + public fun shouldAnimateLayout(viewToAnimate: View?): Boolean { + // if view parent is null, skip animation: view have been clipped, we don't want animation to + // resume when view is re-attached to parent, which is the standard android animation behavior. + // If there's a layout handling animation going on, it should be animated nonetheless since the + // ongoing animation needs to be updated. + if (viewToAnimate == null) { + return false + } + return ((shouldAnimateLayout && viewToAnimate.parent != null) || + layoutHandlers[viewToAnimate.id] != null) + } + + /** + * Update layout of given view, via immediate update or animation depending on the current batch + * layout animation configuration supplied during initialization. Handles create and update + * animations. + * + * @param view the view to update layout of + * @param x the new X position for the view + * @param y the new Y position for the view + * @param width the new width value for the view + * @param height the new height value for the view + */ + public fun applyLayoutUpdate(view: View, x: Int, y: Int, width: Int, height: Int) { + assertOnUiThread() + + val reactTag = view.id + + // Update an ongoing animation if possible, otherwise the layout update would be ignored as + // the existing animation would still animate to the old layout. + val existingAnimation = layoutHandlers[reactTag] + if (existingAnimation != null) { + if (!existingAnimation.isValid()) { + layoutHandlers.remove(reactTag) + } else { + existingAnimation.onLayoutUpdate(x, y, width, height) + return + } + } + + // Determine which animation to use : if view is initially invisible, use create animation, + // otherwise use update animation. This approach is easier than maintaining a list of tags + // for recently created views. + val layoutAnimation = + if ((view.width == 0 || view.height == 0)) layoutCreateAnimation else layoutUpdateAnimation + + val animation = layoutAnimation.createAnimation(view, x, y, width, height) + + if (animation is LayoutHandlingAnimation) { + animation.setAnimationListener( + object : AnimationListener { + override fun onAnimationStart(animation: Animation) { + layoutHandlers.put(reactTag, animation as LayoutHandlingAnimation) + } + + override fun onAnimationEnd(animation: Animation) { + layoutHandlers.remove(reactTag) + } + + override fun onAnimationRepeat(animation: Animation) = Unit + }) + } else { + view.layout(x, y, x + width, y + height) + } + + if (animation != null) { + val animationDuration = animation.duration + if (animationDuration > maxAnimationDuration) { + maxAnimationDuration = animationDuration + scheduleCompletionCallback(animationDuration) + } + + view.startAnimation(animation) + } + } + + /** + * Animate a view deletion using the layout animation configuration supplied during + * initialization. + * + * @param view The view to animate. + * @param listener Called once the animation is finished, should be used to completely remove the + * view. + */ + public fun deleteView(view: View, listener: LayoutAnimationListener) { + assertOnUiThread() + + val animation = + layoutDeleteAnimation.createAnimation(view, view.left, view.top, view.width, view.height) + + if (animation != null) { + disableUserInteractions(view) + + animation.setAnimationListener( + object : AnimationListener { + override fun onAnimationStart(anim: Animation) = Unit + + override fun onAnimationRepeat(anim: Animation) = Unit + + override fun onAnimationEnd(anim: Animation) { + listener.onAnimationEnd() + } + }) + + val animationDuration = animation.duration + if (animationDuration > maxAnimationDuration) { + scheduleCompletionCallback(animationDuration) + maxAnimationDuration = animationDuration + } + + view.startAnimation(animation) + } else { + listener.onAnimationEnd() + } + } + + /** Disables user interactions for a view and all it's subviews. */ + private fun disableUserInteractions(view: View) { + view.isClickable = false + if (view is ViewGroup) { + for (i in 0 until view.childCount) { + disableUserInteractions(view.getChildAt(i)) + } + } + } + + private fun scheduleCompletionCallback(delayMillis: Long) { + if (completionRunnable != null) { + val completionHandler = getUiThreadHandler() + completionHandler.removeCallbacks(completionRunnable!!) + completionHandler.postDelayed(completionRunnable!!, delayMillis) + } + } + + private companion object { + init { + assertLegacyArchitecture("LayoutAnimationController", LegacyArchitectureLogLevel.WARNING) + } + } +}