diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/OpacityAnimation.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/OpacityAnimation.java deleted file mode 100644 index 739d5103dbb..00000000000 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/OpacityAnimation.java +++ /dev/null @@ -1,81 +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.view.View; -import android.view.animation.Animation; -import android.view.animation.Transformation; -import com.facebook.infer.annotation.Nullsafe; -import com.facebook.react.common.annotations.internal.LegacyArchitecture; -import com.facebook.react.common.annotations.internal.LegacyArchitectureLogLevel; -import com.facebook.react.common.annotations.internal.LegacyArchitectureLogger; - -/** - * Animation responsible for updating opacity of a view. It should ideally use hardware texture to - * optimize rendering performances. - */ -/* package */ @Nullsafe(Nullsafe.Mode.LOCAL) -@LegacyArchitecture -class OpacityAnimation extends Animation { - - static { - LegacyArchitectureLogger.assertWhenLegacyArchitectureMinifyingEnabled( - "OpacityAnimation", LegacyArchitectureLogLevel.WARNING); - } - - static class OpacityAnimationListener implements Animation.AnimationListener { - - private final View mView; - private boolean mLayerTypeChanged = false; - - public OpacityAnimationListener(View view) { - mView = view; - } - - @Override - public void onAnimationStart(Animation animation) { - if (mView.hasOverlappingRendering() && mView.getLayerType() == View.LAYER_TYPE_NONE) { - mLayerTypeChanged = true; - mView.setLayerType(View.LAYER_TYPE_HARDWARE, null); - } - } - - @Override - public void onAnimationEnd(Animation animation) { - if (mLayerTypeChanged) { - mView.setLayerType(View.LAYER_TYPE_NONE, null); - } - } - - @Override - public void onAnimationRepeat(Animation animation) { - // do nothing - } - } - - private final View mView; - private final float mStartOpacity, mDeltaOpacity; - - public OpacityAnimation(View view, float startOpacity, float endOpacity) { - mView = view; - mStartOpacity = startOpacity; - mDeltaOpacity = endOpacity - startOpacity; - - setAnimationListener(new OpacityAnimationListener(view)); - } - - @Override - protected void applyTransformation(float interpolatedTime, Transformation t) { - mView.setAlpha(mStartOpacity + mDeltaOpacity * interpolatedTime); - } - - @Override - public boolean willChangeBounds() { - return false; - } -} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/OpacityAnimation.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/OpacityAnimation.kt new file mode 100644 index 00000000000..b87021cf44a --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/OpacityAnimation.kt @@ -0,0 +1,65 @@ +/* + * 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.view.View +import android.view.animation.Animation +import android.view.animation.Transformation +import com.facebook.react.common.annotations.VisibleForTesting +import com.facebook.react.common.annotations.internal.LegacyArchitecture +import com.facebook.react.common.annotations.internal.LegacyArchitectureLogLevel +import com.facebook.react.common.annotations.internal.LegacyArchitectureLogger + +/** + * Animation responsible for updating opacity of a view. It should ideally use hardware texture to + * optimize rendering performances. + */ +@LegacyArchitecture +internal class OpacityAnimation( + private val view: View, + private val startOpacity: Float, + endOpacity: Float +) : Animation() { + private val deltaOpacity = endOpacity - startOpacity + + init { + setAnimationListener(OpacityAnimationListener(view)) + LegacyArchitectureLogger.assertWhenLegacyArchitectureMinifyingEnabled( + "OpacityAnimation", LegacyArchitectureLogLevel.WARNING) + } + + class OpacityAnimationListener(private val view: View) : Animation.AnimationListener { + private var layerTypeChanged = false + + override fun onAnimationStart(animation: Animation) { + if (view.hasOverlappingRendering() && view.layerType == View.LAYER_TYPE_NONE) { + layerTypeChanged = true + view.setLayerType(View.LAYER_TYPE_HARDWARE, null) + } + } + + override fun onAnimationEnd(animation: Animation) { + if (layerTypeChanged) { + view.setLayerType(View.LAYER_TYPE_NONE, null) + } + } + + override fun onAnimationRepeat(animation: Animation) { + // do nothing + } + } + + @VisibleForTesting + public override fun applyTransformation(interpolatedTime: Float, t: Transformation) { + view.alpha = startOpacity + deltaOpacity * interpolatedTime + } + + override fun willChangeBounds(): Boolean { + return false + } +} diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/layoutanimation/OpacityAnimationTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/layoutanimation/OpacityAnimationTest.kt new file mode 100644 index 00000000000..8c25ee4c86c --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/layoutanimation/OpacityAnimationTest.kt @@ -0,0 +1,103 @@ +/* + * 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.view.View +import android.view.animation.Transformation +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class OpacityAnimationTest { + private lateinit var view: View + private lateinit var animation: OpacityAnimation + + @Before + fun setUp() { + view = View(RuntimeEnvironment.getApplication()) + } + + @Test + fun applyTransformation_updatesAlphaCorrectly() { + val startOpacity = 0.5f + val endOpacity = 1.0f + animation = OpacityAnimation(view, startOpacity, endOpacity) + + val transformation = Transformation() + animation.applyTransformation(0.0f, transformation) + assertThat(view.alpha).isEqualTo(0.5f) + + animation.applyTransformation(0.5f, transformation) + assertThat(view.alpha).isEqualTo(0.75f) + + animation.applyTransformation(1.0f, transformation) + assertThat(view.alpha).isEqualTo(1.0f) + } + + @Test + fun willChangeBounds_returnsFalse() { + animation = OpacityAnimation(view, 0.5f, 1.0f) + assertThat(animation.willChangeBounds()).isFalse() + } + + @Test + fun onAnimationStart_setsLayerTypeHardwareIfOverlappingRendering() { + val spyView: View = mock { + on { hasOverlappingRendering() } doReturn true + on { layerType } doReturn View.LAYER_TYPE_NONE + } + + val listener = OpacityAnimation.OpacityAnimationListener(spyView) + listener.onAnimationStart(mock()) + + verify(spyView).setLayerType(View.LAYER_TYPE_HARDWARE, null) + } + + @Test + fun onAnimationStart_doesNotChangeLayerTypeIfAlreadySet() { + val spyView: View = mock { + on { hasOverlappingRendering() } doReturn true + on { layerType } doReturn View.LAYER_TYPE_HARDWARE + } + + val listener = OpacityAnimation.OpacityAnimationListener(spyView) + listener.onAnimationStart(mock()) + + verify(spyView, never()).setLayerType(View.LAYER_TYPE_HARDWARE, null) + } + + @Test + fun onAnimationEnd_resetsLayerTypeIfChanged() { + val spyView: View = mock { + on { hasOverlappingRendering() } doReturn true + on { layerType } doReturn View.LAYER_TYPE_NONE + } + + val listener = OpacityAnimation.OpacityAnimationListener(spyView) + listener.onAnimationStart(mock()) + listener.onAnimationEnd(mock()) + + verify(spyView).setLayerType(View.LAYER_TYPE_NONE, null) + } + + @Test + fun onAnimationRepeat_doesNothing() { + val listener = OpacityAnimation.OpacityAnimationListener(mock()) + listener.onAnimationRepeat(mock()) + + // No assertions needed, just verifying it runs without errors + } +}