diff --git a/Examples/UIExplorer/ListViewPagingExample.js b/Examples/UIExplorer/ListViewPagingExample.js index b82f272c9dbbe6..7d0ac98f2bfdcb 100644 --- a/Examples/UIExplorer/ListViewPagingExample.js +++ b/Examples/UIExplorer/ListViewPagingExample.js @@ -11,6 +11,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * + * @provides ListViewPagingExample * @flow */ 'use strict'; @@ -26,6 +27,11 @@ var { View, } = React; +var NativeModules = require('NativeModules'); +var { + UIManager, +} = NativeModules; + var PAGE_SIZE = 4; var THUMB_URLS = [ 'Thumbnails/like.png', @@ -48,6 +54,10 @@ var Thumb = React.createClass({ getInitialState: function() { return {thumbIndex: this._getThumbIdx(), dir: 'row'}; }, + componentWillMount: function() { + UIManager.setLayoutAnimationEnabledExperimental && + UIManager.setLayoutAnimationEnabledExperimental(true); + }, _getThumbIdx: function() { return Math.floor(Math.random() * THUMB_URLS.length); }, diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyManager.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyManager.java index ac6ae4e46d8c7f..752ffaf291bc8b 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyManager.java @@ -29,9 +29,11 @@ import com.facebook.react.bridge.JSApplicationIllegalArgumentException; import com.facebook.react.bridge.ReactContext; import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.SoftAssertions; import com.facebook.react.bridge.UiThreadUtil; import com.facebook.react.touch.JSResponderHandler; +import com.facebook.react.uimanager.layoutanimation.LayoutAnimationController; /** * Delegate of {@link UIManagerModule} that owns the native view hierarchy and mapping between @@ -65,6 +67,9 @@ public class NativeViewHierarchyManager { private final ViewManagerRegistry mViewManagers; private final JSResponderHandler mJSResponderHandler = new JSResponderHandler(); private final RootViewManager mRootViewManager = new RootViewManager(); + private final LayoutAnimationController mLayoutAnimator = new LayoutAnimationController(); + + private boolean mLayoutAnimationEnabled; public NativeViewHierarchyManager(ViewManagerRegistry viewManagers) { mAnimationRegistry = new AnimationRegistry(); @@ -95,6 +100,10 @@ public AnimationRegistry getAnimationRegistry() { return mAnimationRegistry; } + public void setLayoutAnimationEnabled(boolean enabled) { + mLayoutAnimationEnabled = enabled; + } + public void updateProperties(int tag, CatalystStylesDiffMap props) { UiThreadUtil.assertOnUiThread(); @@ -149,8 +158,17 @@ public void updateLayout( } if (parentViewGroupManager != null && !parentViewGroupManager.needsCustomLayoutForChildren()) { - viewToUpdate.layout(x, y, x + width, y + height); + updateLayout(viewToUpdate, x, y, width, height); } + } else { + updateLayout(viewToUpdate, x, y, width, height); + } + } + + private void updateLayout(View viewToUpdate, int x, int y, int width, int height) { + if (mLayoutAnimationEnabled && + mLayoutAnimator.shouldAnimateLayout(viewToUpdate)) { + mLayoutAnimator.applyLayoutUpdate(viewToUpdate, x, y, width, height); } else { viewToUpdate.layout(x, y, x + width, y + height); } @@ -466,6 +484,14 @@ public void clearJSResponder() { mJSResponderHandler.clearJSResponder(); } + void configureLayoutAnimation(final ReadableMap config) { + mLayoutAnimator.initializeFromConfig(config); + } + + void clearLayoutAnimation() { + mLayoutAnimator.reset(); + } + /* package */ void startAnimationForNativeView( int reactTag, Animation animation, diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIImplementation.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIImplementation.java index 608e3379219892..aef2ebc2931746 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIImplementation.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIImplementation.java @@ -469,6 +469,41 @@ public void removeAnimation(int reactTag, int animationID) { mOperationsQueue.enqueueRemoveAnimation(animationID); } + /** + * LayoutAnimation API on Android is currently experimental. Therefore, it needs to be enabled + * explicitly in order to avoid regression in existing application written for iOS using this API. + * + * Warning : This method will be removed in future version of React Native, and layout animation + * will be enabled by default, so always check for its existence before invoking it. + * + * TODO(9139831) : remove this method once layout animation is fully stable. + * + * @param enabled whether layout animation is enabled or not + */ + public void setLayoutAnimationEnabledExperimental(boolean enabled) { + mOperationsQueue.enqueueSetLayoutAnimationEnabled(enabled); + } + + /** + * Configure an animation to be used for the native layout changes, and native views + * creation. The animation will only apply during the current batch operations. + * + * TODO(7728153) : animating view deletion is currently not supported. + * TODO(7613721) : callbacks are not supported, this feature will likely be killed. + * + * @param config the configuration of the animation for view addition/removal/update. + * @param success will be called when the animation completes, or when the animation get + * interrupted. In this case, callback parameter will be false. + * @param error will be called if there was an error processing the animation + */ + public void configureNextLayoutAnimation( + ReadableMap config, + Callback success, + Callback error) { + mOperationsQueue.enqueueConfigureLayoutAnimation(config, success, error); + } + + public void setJSResponder(int reactTag, boolean blockNativeResponder) { assertViewExists(reactTag, "setJSResponder"); ReactShadowNode node = mShadowNodeRegistry.getNode(reactTag); diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModule.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModule.java index b60598569bf0be..b352f4d53e61bf 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModule.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModule.java @@ -374,12 +374,40 @@ public void showPopupMenu(int reactTag, ReadableArray items, Callback error, Cal mUIImplementation.showPopupMenu(reactTag, items, error, success); } + /** + * LayoutAnimation API on Android is currently experimental. Therefore, it needs to be enabled + * explicitly in order to avoid regression in existing application written for iOS using this API. + * + * Warning : This method will be removed in future version of React Native, and layout animation + * will be enabled by default, so always check for its existence before invoking it. + * + * TODO(9139831) : remove this method once layout animation is fully stable. + * + * @param enabled whether layout animation is enabled or not + */ + @ReactMethod + public void setLayoutAnimationEnabledExperimental(boolean enabled) { + mUIImplementation.setLayoutAnimationEnabledExperimental(enabled); + } + + /** + * Configure an animation to be used for the native layout changes, and native views + * creation. The animation will only apply during the current batch operations. + * + * TODO(7728153) : animating view deletion is currently not supported. + * TODO(7613721) : callbacks are not supported, this feature will likely be killed. + * + * @param config the configuration of the animation for view addition/removal/update. + * @param success will be called when the animation completes, or when the animation get + * interrupted. In this case, callback parameter will be false. + * @param error will be called if there was an error processing the animation + */ @ReactMethod public void configureNextLayoutAnimation( ReadableMap config, - Callback successCallback, - Callback errorCallback) { - // TODO(6588266): Implement if required + Callback success, + Callback error) { + mUIImplementation.configureNextLayoutAnimation(config, success, error); } /** diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIViewOperationQueue.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIViewOperationQueue.java index 8f79a72081f38c..5fc65cd64a2394 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIViewOperationQueue.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIViewOperationQueue.java @@ -23,6 +23,7 @@ import com.facebook.react.bridge.SoftAssertions; import com.facebook.react.bridge.ReactContext; import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.UiThreadUtil; import com.facebook.react.uimanager.debug.NotThreadSafeViewHierarchyUpdateDebugListener; import com.facebook.systrace.Systrace; @@ -322,6 +323,32 @@ public void execute() { } } + private class SetLayoutAnimationEnabledOperation implements UIOperation { + private final boolean mEnabled; + + private SetLayoutAnimationEnabledOperation(final boolean enabled) { + mEnabled = enabled; + } + + @Override + public void execute() { + mNativeViewHierarchyManager.setLayoutAnimationEnabled(mEnabled); + } + } + + private class ConfigureLayoutAnimationOperation implements UIOperation { + private final ReadableMap mConfig; + + private ConfigureLayoutAnimationOperation(final ReadableMap config) { + mConfig = config; + } + + @Override + public void execute() { + mNativeViewHierarchyManager.configureLayoutAnimation(mConfig); + } + } + private final class MeasureOperation implements UIOperation { private final int mReactTag; @@ -584,6 +611,18 @@ public void enqueueRemoveAnimation(int animationID) { mOperations.add(new RemoveAnimationOperation(animationID)); } + public void enqueueSetLayoutAnimationEnabled( + final boolean enabled) { + mOperations.add(new SetLayoutAnimationEnabledOperation(enabled)); + } + + public void enqueueConfigureLayoutAnimation( + final ReadableMap config, + final Callback onSuccess, + final Callback onError) { + mOperations.add(new ConfigureLayoutAnimationOperation(config)); + } + public void enqueueMeasure( final int reactTag, final Callback callback) { @@ -680,6 +719,9 @@ public void doFrameGuarded(long frameTimeNanos) { mDispatchUIRunnables.get(i).run(); } mDispatchUIRunnables.clear(); + + // Clear layout animation, as animation only apply to current UI operations batch. + mNativeViewHierarchyManager.clearLayoutAnimation(); } ReactChoreographer.getInstance().postFrameCallback( diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/AbstractLayoutAnimation.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/AbstractLayoutAnimation.java new file mode 100644 index 00000000000000..c29d7ae0bef963 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/AbstractLayoutAnimation.java @@ -0,0 +1,108 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +package com.facebook.react.uimanager.layoutanimation; + +import javax.annotation.Nullable; + +import java.util.Map; + +import android.view.View; +import android.view.animation.AccelerateDecelerateInterpolator; +import android.view.animation.AccelerateInterpolator; +import android.view.animation.Animation; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.Interpolator; +import android.view.animation.LinearInterpolator; + +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.common.MapBuilder; +import com.facebook.react.uimanager.IllegalViewOperationException; + +/** + * Class responsible for parsing and converting layout animation data into native {@link Animation} + * in order to animate layout when a valid configuration has been supplied by the application. + */ +/* package */ abstract class AbstractLayoutAnimation { + + // Forces animation to be playing 10x slower, used for debug purposes. + private static final boolean SLOWDOWN_ANIMATION_MODE = false; + + abstract boolean isValid(); + + /** + * Create an animation object for the current animation type, based on the view and final screen + * coordinates. If the application-supplied configuraiton does not specify an animation definition + * for this types, or if the animation definition is invalid, returns null. + */ + abstract @Nullable Animation createAnimationImpl(View view, int x, int y, int width, int height); + + private static final Map INTERPOLATOR = MapBuilder.of( + InterpolatorType.LINEAR, new LinearInterpolator(), + InterpolatorType.EASE_IN, new AccelerateInterpolator(), + InterpolatorType.EASE_OUT, new DecelerateInterpolator(), + InterpolatorType.EASE_IN_EASE_OUT, new AccelerateDecelerateInterpolator(), + InterpolatorType.SPRING, new SimpleSpringInterpolator()); + + private @Nullable Interpolator mInterpolator; + private int mDelayMs; + + protected @Nullable AnimatedPropertyType mAnimatedProperty; + protected int mDurationMs; + + public void reset() { + mAnimatedProperty = null; + mDurationMs = 0; + mDelayMs = 0; + mInterpolator = null; + } + + public void initializeFromConfig(ReadableMap data, int globalDuration) { + mAnimatedProperty = data.hasKey("property") ? + AnimatedPropertyType.fromString(data.getString("property")) : null; + mDurationMs = data.hasKey("duration") ? data.getInt("duration") : globalDuration; + mDelayMs = data.hasKey("delay") ? data.getInt("delay") : 0; + mInterpolator = data.hasKey("type") ? + getInterpolator(InterpolatorType.fromString(data.getString("type"))) : null; + + if (!isValid()) { + throw new IllegalViewOperationException("Invalid layout animation : " + data); + } + } + + /** + * Create an animation object to be used to animate the view, based on the animation config + * supplied at initialization time and the new view position and size. + * + * @param view the view to create the animation for + * @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 final @Nullable Animation createAnimation( + View view, + int x, + int y, + int width, + int height) { + if (!isValid()) { + return null; + } + Animation animation = createAnimationImpl(view, x, y, width, height); + if (animation != null) { + int slowdownFactor = SLOWDOWN_ANIMATION_MODE ? 10 : 1; + animation.setDuration(mDurationMs * slowdownFactor); + animation.setStartOffset(mDelayMs * slowdownFactor); + animation.setInterpolator(mInterpolator); + } + return animation; + } + + private static Interpolator getInterpolator(InterpolatorType type) { + Interpolator interpolator = INTERPOLATOR.get(type); + if (interpolator == null) { + throw new IllegalArgumentException("Missing interpolator for type : " + type); + } + return interpolator; + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/AnimatedPropertyType.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/AnimatedPropertyType.java new file mode 100644 index 00000000000000..51a9d246c061bb --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/AnimatedPropertyType.java @@ -0,0 +1,32 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +package com.facebook.react.uimanager.layoutanimation; + +/** + * Enum representing the different view properties that can be used when animating layout for + * view creation. + */ +/* package */ enum AnimatedPropertyType { + OPACITY("opacity"), + SCALE_XY("scaleXY"); + + private final String mName; + + private AnimatedPropertyType(String name) { + mName = name; + } + + public static AnimatedPropertyType fromString(String name) { + for (AnimatedPropertyType property : AnimatedPropertyType.values()) { + if (property.toString().equalsIgnoreCase(name)) { + return property; + } + } + throw new IllegalArgumentException("Unsupported animated property : " + name); + } + + @Override + public String toString() { + return mName; + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/BaseLayoutAnimation.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/BaseLayoutAnimation.java new file mode 100644 index 00000000000000..9d3d0f45aa797f --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/BaseLayoutAnimation.java @@ -0,0 +1,48 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +package com.facebook.react.uimanager.layoutanimation; + +import android.view.View; +import android.view.animation.Animation; +import android.view.animation.ScaleAnimation; + +import com.facebook.react.uimanager.IllegalViewOperationException; + +/** + * Class responsible for default layout animation, i.e animation of view creation and deletion. + */ +/* package */ abstract class BaseLayoutAnimation extends AbstractLayoutAnimation { + + abstract boolean isReverse(); + + @Override + boolean isValid() { + return mDurationMs > 0 && mAnimatedProperty != null; + } + + @Override + Animation createAnimationImpl(View view, int x, int y, int width, int height) { + float fromValue = isReverse() ? 1.0f : 0.0f; + float toValue = isReverse() ? 0.0f : 1.0f; + if (mAnimatedProperty != null) { + switch (mAnimatedProperty) { + case OPACITY: + return new OpacityAnimation(view, fromValue, toValue); + case SCALE_XY: + return new ScaleAnimation( + fromValue, + toValue, + fromValue, + toValue, + Animation.RELATIVE_TO_PARENT, + .5f, + Animation.RELATIVE_TO_PARENT, + .5f); + default: + throw new IllegalViewOperationException( + "Missing animation for property : " + mAnimatedProperty); + } + } + throw new IllegalViewOperationException("Missing animated property from animation config"); + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/HandlesLayout.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/HandlesLayout.java new file mode 100644 index 00000000000000..9f3c907e97d079 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/HandlesLayout.java @@ -0,0 +1,10 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +package com.facebook.react.uimanager.layoutanimation; + +/** + * Marker interface to indicate a given animation type takes care of updating the view layout. + */ +/* package */ interface HandleLayout { + +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/InterpolatorType.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/InterpolatorType.java new file mode 100644 index 00000000000000..f59675a8fbe128 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/InterpolatorType.java @@ -0,0 +1,34 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +package com.facebook.react.uimanager.layoutanimation; + +/** + * Enum representing the different interpolators that can be used in layout animation configuration. + */ +/* package */ enum InterpolatorType { + LINEAR("linear"), + EASE_IN("easeIn"), + EASE_OUT("easeOut"), + EASE_IN_EASE_OUT("easeInEaseOut"), + SPRING("spring"); + + private final String mName; + + private InterpolatorType(String name) { + mName = name; + } + + public static InterpolatorType fromString(String name) { + for (InterpolatorType type : InterpolatorType.values()) { + if (type.toString().equalsIgnoreCase(name)) { + return type; + } + } + throw new IllegalArgumentException("Unsupported interpolation type : " + name); + } + + @Override + public String toString() { + return mName; + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/LayoutAnimationController.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/LayoutAnimationController.java new file mode 100644 index 00000000000000..d3522947fd36e1 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/LayoutAnimationController.java @@ -0,0 +1,94 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +package com.facebook.react.uimanager.layoutanimation; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.NotThreadSafe; + +import android.view.View; +import android.view.animation.Animation; + +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.UiThreadUtil; + +/** + * 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. + * + * TODO(7613721): Invoke success callback at the end of animation and when animation gets cancelled. + */ +@NotThreadSafe +public class LayoutAnimationController { + + private static final boolean ENABLED = true; + + private final AbstractLayoutAnimation mLayoutCreateAnimation = new LayoutCreateAnimation(); + private final AbstractLayoutAnimation mLayoutUpdateAnimation = new LayoutUpdateAnimation(); + private boolean mShouldAnimateLayout; + + public void initializeFromConfig(final @Nullable ReadableMap config) { + if (!ENABLED) { + return; + } + + if (config == null) { + reset(); + return; + } + + mShouldAnimateLayout = false; + int globalDuration = config.hasKey("duration") ? config.getInt("duration") : 0; + if (config.hasKey(LayoutAnimationType.CREATE.toString())) { + mLayoutCreateAnimation.initializeFromConfig( + config.getMap(LayoutAnimationType.CREATE.toString()), globalDuration); + mShouldAnimateLayout = true; + } + if (config.hasKey(LayoutAnimationType.UPDATE.toString())) { + mLayoutUpdateAnimation.initializeFromConfig( + config.getMap(LayoutAnimationType.UPDATE.toString()), globalDuration); + mShouldAnimateLayout = true; + } + } + + public void reset() { + mLayoutCreateAnimation.reset(); + mLayoutUpdateAnimation.reset(); + mShouldAnimateLayout = false; + } + + 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. + return mShouldAnimateLayout && viewToAnimate.getParent() != null; + } + + /** + * Update layout of given view, via immediate update or animation depending on the current batch + * layout animation configuration supplied during initialization. + * + * @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(); + + // Determine which animation to use : if view is initially invisible, use create animation. + // If view is becoming invisible, use delete animation. Otherwise, use update animation. + // This approach is easier than maintaining a list of tags for recently created/deleted views. + AbstractLayoutAnimation layoutAnimation = (view.getWidth() == 0 || view.getHeight() == 0) ? + mLayoutCreateAnimation : + mLayoutUpdateAnimation; + + Animation animation = layoutAnimation.createAnimation(view, x, y, width, height); + if (animation == null || !(animation instanceof HandleLayout)) { + view.layout(x, y, x + width, y + height); + } + if (animation != null) { + view.startAnimation(animation); + } + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/LayoutAnimationType.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/LayoutAnimationType.java new file mode 100644 index 00000000000000..0f3d9d7fdb2b99 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/LayoutAnimationType.java @@ -0,0 +1,22 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +package com.facebook.react.uimanager.layoutanimation; + +/** + * Enum representing the different animation type that can be specified in layout animation config. + */ +/* package */ enum LayoutAnimationType { + CREATE("create"), + UPDATE("update"); + + private final String mName; + + private LayoutAnimationType(String name) { + mName = name; + } + + @Override + public String toString() { + return mName; + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/LayoutCreateAnimation.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/LayoutCreateAnimation.java new file mode 100644 index 00000000000000..a989e625c675cb --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/LayoutCreateAnimation.java @@ -0,0 +1,15 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +package com.facebook.react.uimanager.layoutanimation; + +/** + * Class responsible for handling layout view creation animation, applied to view whenever a + * valid config was supplied for the layout animation of CREATE type. + */ +/* package */ class LayoutCreateAnimation extends BaseLayoutAnimation { + + @Override + boolean isReverse() { + return false; + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/LayoutUpdateAnimation.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/LayoutUpdateAnimation.java new file mode 100644 index 00000000000000..8189716f729da5 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/LayoutUpdateAnimation.java @@ -0,0 +1,42 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +package com.facebook.react.uimanager.layoutanimation; + +import javax.annotation.Nullable; + +import android.view.View; +import android.view.animation.Animation; +import android.view.animation.TranslateAnimation; + +/** + * Class responsible for handling layout update animation, applied to view whenever a valid config + * was supplied for the layout animation of UPDATE type. + */ +/* package */ class LayoutUpdateAnimation extends AbstractLayoutAnimation { + + // We are currently not enabling translation GPU-accelerated animated, as it creates odd + // artifacts with native react scrollview. This needs to be investigated. + private static final boolean USE_TRANSLATE_ANIMATION = false; + + @Override + boolean isValid() { + return mDurationMs > 0; + } + + @Override + @Nullable Animation createAnimationImpl(View view, int x, int y, int width, int height) { + boolean animateLocation = view.getX() != x || view.getY() != y; + boolean animateSize = view.getWidth() != width || view.getHeight() != height; + if (!animateLocation && !animateSize) { + return null; + } else if (animateLocation && !animateSize && USE_TRANSLATE_ANIMATION) { + // Use GPU-accelerated animation, however we loose the ability to resume interrupted + // animation where it was left off. We may be able to listen to animation interruption + // and set the layout manually in this case, so that next animation kicks off smoothly. + return new TranslateAnimation(view.getX() - x, 0, view.getY() - y, 0); + } else { + // Animation is sub-optimal for perf, but scale transformation can't be use in this case. + return new PositionAndSizeAnimation(view, x, y, width, height); + } + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/OpacityAnimation.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/OpacityAnimation.java new file mode 100644 index 00000000000000..a7ad690023c500 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/OpacityAnimation.java @@ -0,0 +1,66 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +package com.facebook.react.uimanager.layoutanimation; + +import android.view.View; +import android.view.animation.Animation; +import android.view.animation.Transformation; + +/** + * Animation responsible for updating opacity of a view. It should ideally use hardware texture + * to optimize rendering performances. + */ +/* package */ class OpacityAnimation extends Animation { + + static class OpacityAnimationListener implements 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/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/PositionAndSizeAnimation.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/PositionAndSizeAnimation.java new file mode 100644 index 00000000000000..2adf81a6bb6c6e --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/PositionAndSizeAnimation.java @@ -0,0 +1,52 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +package com.facebook.react.uimanager.layoutanimation; + +import android.view.View; +import android.view.animation.Animation; +import android.view.animation.Transformation; + +/** + * Animation responsible for updating size and position of a view. We can't use scaling as view + * content may not necessarily stretch. As a result, this approach is inefficient because of + * layout passes occurring on every frame. + * What we might want to try to do instead is use a combined ScaleAnimation and TranslateAnimation. + */ +/* package */ class PositionAndSizeAnimation extends Animation implements HandleLayout { + + private final View mView; + private final float mStartX, mStartY, mDeltaX, mDeltaY; + private final int mStartWidth, mStartHeight, mDeltaWidth, mDeltaHeight; + + public PositionAndSizeAnimation(View view, int x, int y, int width, int height) { + mView = view; + + mStartX = view.getX(); + mStartY = view.getY(); + mStartWidth = view.getWidth(); + mStartHeight = view.getHeight(); + + mDeltaX = x - mStartX; + mDeltaY = y - mStartY; + mDeltaWidth = width - mStartWidth; + mDeltaHeight = height - mStartHeight; + } + + @Override + protected void applyTransformation(float interpolatedTime, Transformation t) { + float newX = mStartX + mDeltaX * interpolatedTime; + float newY = mStartY + mDeltaY * interpolatedTime; + float newWidth = mStartWidth + mDeltaWidth * interpolatedTime; + float newHeight = mStartHeight + mDeltaHeight * interpolatedTime; + mView.layout( + Math.round(newX), + Math.round(newY), + Math.round(newX + newWidth), + Math.round(newY + newHeight)); + } + + @Override + public boolean willChangeBounds() { + return true; + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/SimpleSpringInterpolator.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/SimpleSpringInterpolator.java new file mode 100644 index 00000000000000..9696fe90a1a3ad --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/layoutanimation/SimpleSpringInterpolator.java @@ -0,0 +1,20 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +package com.facebook.react.uimanager.layoutanimation; + +import android.view.animation.Interpolator; + +/** + * Simple spring interpolator + */ +//TODO(7613736): Improve spring interpolator with friction and damping variable support +/* package */ class SimpleSpringInterpolator implements Interpolator { + + private static final float FACTOR = 0.5f; + + @Override + public float getInterpolation(float input) { + return (float) + (1 + Math.pow(2, -10 * input) * Math.sin((input - FACTOR / 4) * Math.PI * 2 / FACTOR)); + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java b/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java index 134b6492b9bf0f..9e2728db4e1ed8 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java @@ -16,6 +16,7 @@ import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.graphics.drawable.LayerDrawable; +import android.view.animation.Animation; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; @@ -286,7 +287,15 @@ private void updateSubviewClipStatus(Rect clippingRect, int idx, int clippedSoFa boolean intersects = clippingRect .intersects(sHelperRect.left, sHelperRect.top, sHelperRect.right, sHelperRect.bottom); boolean needUpdateClippingRecursive = false; - if (!intersects && child.getParent() != null) { + // We never want to clip children that are being animated, as this can easily break layout : + // when layout animation changes size and/or position of views contained inside a listview that + // clips offscreen children, we need to ensure that, when view exits the viewport, final size + // and position is set prior to removing the view from its listview parent. + // Otherwise, when view gets re-attached again, i.e when it re-enters the viewport after scroll, + // it won't be size and located properly. + Animation animation = child.getAnimation(); + boolean isAnimating = animation != null && !animation.hasEnded(); + if (!intersects && child.getParent() != null && !isAnimating) { // We can try saving on invalidate call here as the view that we remove is out of visible area // therefore invalidation is not necessary. super.removeViewsInLayout(idx - clippedSoFar, 1);