Handle layout updates during LayoutAnimation animations on Android

Summary:
On Android, LayoutAnimation directly updates the layout since a generic
scaling animation is more difficult to implement. This causes a problem
if the layout is updated during an animation, as the previous layout is
stored with the animation and is not updated. As a result the view gets
the old layout instead once the animation completes.

This commit fixes this issue by storing the layout handling animations
while those animations are active, and updating the animations on the
fly if one of the views receives a new layout. The resulting behaviour
mirrors what iOS currently does.

This bug has real world consequences, for example if a LayoutAnimation
happens right after a VirtualizedList has mounted, it’s possible that
some list rows are mounted while the animation is active, making the
list content view bigger. If the content view is being animated, the
new size will not take effect and it becomes impossible to scroll to
the end of the list.

I wrote a minimal test case to verify the bug, which I’ve also added to
RNTester. You can find the standalone app here:

<https://gist.github.com/lnikkila/18096c15b2fb99b232795ef59f8fb0cd>

The app creates a 100x300 view that gets animated to 200x300 using
LayoutAnimation. In the middle of that animation, the view’s dimensions
are updated to 300x300.

The expected result (which is currently exhibited by iOS) is that the
view’s dimensions after the animation would be 300x300. On Android the
view keeps the 200x300 dimensions since the animation overrides the
layout update.

The test app could probably be turned into an integration test by
measuring the view through UIManager after the animation, however I
don’t have time to do that right now...

Here are some GIFs to compare, click to expand:

<details>
  <summary><b>Current master (iOS vs Android)</b></summary>
  <p></p>
  <img src="https://user-images.githubusercontent.com/1291143/38191325-f1aeb3d4-3670-11e8-8aca-14e7b24e2946.gif" height="400" /><img src="https://user-images.githubusercontent.com/1291143/38191337-f643fd8c-3670-11e8-9aac-531a32cc0a67.gif" height="400" />
</details><p></p>

<details>
  <summary><b>With this patch (iOS vs Android, fixed)</b></summary>
  <p></p>
  <img src="https://user-images.githubusercontent.com/1291143/38191325-f1aeb3d4-3670-11e8-8aca-14e7b24e2946.gif" height="400" /><img src="https://user-images.githubusercontent.com/1291143/38191355-07f6e972-3671-11e8-8ad2-130d06d0d64d.gif" height="400" />
</details><p></p>

No documentation changes needed.

[ANDROID] [BUGFIX] [LayoutAnimation] - View layout is updated correctly during an ongoing LayoutAnimation, mirroring iOS behaviour.
Closes https://github.com/facebook/react-native/pull/18651

Differential Revision: D7604698

Pulled By: hramos

fbshipit-source-id: 4d114682fd540419b7447e999910e05726f42b39
This commit is contained in:
Leo Nikkilä 2018-04-12 13:44:45 -07:00 committed by Facebook Github Bot
parent 4fbd244b9a
commit f6e2f13f4b
5 changed files with 133 additions and 25 deletions

View File

@ -102,6 +102,57 @@ class CrossFadeExample extends React.Component<{}, $FlowFixMeState> {
}
}
class LayoutUpdateExample extends React.Component<{}, $FlowFixMeState> {
state = {
width: 200,
height: 100,
};
timeout = null;
componentWillUnmount() {
this._clearTimeout();
}
_clearTimeout = () => {
if (this.timeout !== null) {
clearTimeout(this.timeout);
this.timeout = null;
}
}
_onPressToggle = () => {
this._clearTimeout();
this.setState({width: 150});
LayoutAnimation.configureNext({
duration: 1000,
update: {
type: LayoutAnimation.Types.linear,
},
});
this.timeout = setTimeout(() => this.setState({width: 100}), 500);
};
render() {
const {width, height} = this.state;
return (
<View style={styles.container}>
<TouchableOpacity onPress={this._onPressToggle}>
<View style={styles.button}>
<Text>Make box square</Text>
</View>
</TouchableOpacity>
<View style={[styles.view, {width, height}]}>
<Text>{width}x{height}</Text>
</View>
</View>
);
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
@ -156,4 +207,9 @@ exports.examples = [{
render(): React.Element<any> {
return <CrossFadeExample />;
},
}, {
title: 'Layout update during animation',
render(): React.Element<any> {
return <LayoutUpdateExample />;
},
}];

View File

@ -1,10 +0,0 @@
// 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 {
}

View File

@ -5,6 +5,7 @@ package com.facebook.react.uimanager.layoutanimation;
import javax.annotation.Nullable;
import javax.annotation.concurrent.NotThreadSafe;
import android.util.SparseArray;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.Animation;
@ -27,6 +28,7 @@ public class LayoutAnimationController {
private final AbstractLayoutAnimation mLayoutCreateAnimation = new LayoutCreateAnimation();
private final AbstractLayoutAnimation mLayoutUpdateAnimation = new LayoutUpdateAnimation();
private final AbstractLayoutAnimation mLayoutDeleteAnimation = new LayoutDeleteAnimation();
private final SparseArray<LayoutHandlingAnimation> mLayoutHandlers = new SparseArray<>(0);
private boolean mShouldAnimateLayout;
public void initializeFromConfig(final @Nullable ReadableMap config) {
@ -68,7 +70,10 @@ public class LayoutAnimationController {
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;
// If there's a layout handling animation going on, it should be animated nonetheless since the
// ongoing animation needs to be updated.
return (mShouldAnimateLayout && viewToAnimate.getParent() != null)
|| mLayoutHandlers.get(viewToAnimate.getId()) != null;
}
/**
@ -85,6 +90,16 @@ public class LayoutAnimationController {
public void applyLayoutUpdate(View view, int x, int y, int width, int height) {
UiThreadUtil.assertOnUiThread();
final int reactTag = view.getId();
LayoutHandlingAnimation existingAnimation = mLayoutHandlers.get(reactTag);
// Update an ongoing animation if possible, otherwise the layout update would be ignored as
// the existing animation would still animate to the old layout.
if (existingAnimation != null) {
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.
@ -93,9 +108,26 @@ public class LayoutAnimationController {
mLayoutUpdateAnimation;
Animation animation = layoutAnimation.createAnimation(view, x, y, width, height);
if (animation == null || !(animation instanceof HandleLayout)) {
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) {
view.startAnimation(animation);
}

View File

@ -0,0 +1,20 @@
// Copyright 2004-present Facebook. All Rights Reserved.
package com.facebook.react.uimanager.layoutanimation;
/**
* Interface for an animation type that takes care of updating the view layout.
*/
/* package */ interface LayoutHandlingAnimation {
/**
* Notifies the animation of a layout update in case one occurs during the animation. This
* avoids animating the view to the old layout since it's no longer correct; instead the
* animation should update and do whatever it can so that the final layout is correct.
*
* @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
*/
void onLayoutUpdate(int x, int y, int width, int height);
}

View File

@ -12,24 +12,15 @@ import android.view.animation.Transformation;
* 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 {
/* package */ class PositionAndSizeAnimation extends Animation implements LayoutHandlingAnimation {
private final View mView;
private final float mStartX, mStartY, mDeltaX, mDeltaY;
private final int mStartWidth, mStartHeight, mDeltaWidth, mDeltaHeight;
private float mStartX, mStartY, mDeltaX, mDeltaY;
private int mStartWidth, mStartHeight, mDeltaWidth, mDeltaHeight;
public PositionAndSizeAnimation(View view, int x, int y, int width, int height) {
mView = view;
mStartX = view.getX() - view.getTranslationX();
mStartY = view.getY() - view.getTranslationY();
mStartWidth = view.getWidth();
mStartHeight = view.getHeight();
mDeltaX = x - mStartX;
mDeltaY = y - mStartY;
mDeltaWidth = width - mStartWidth;
mDeltaHeight = height - mStartHeight;
calculateAnimation(x, y, width, height);
}
@Override
@ -45,8 +36,27 @@ import android.view.animation.Transformation;
Math.round(newY + newHeight));
}
@Override
public void onLayoutUpdate(int x, int y, int width, int height) {
// Layout changed during the animation, we should update our values so that the final layout
// is correct.
calculateAnimation(x, y, width, height);
}
@Override
public boolean willChangeBounds() {
return true;
}
private void calculateAnimation(int x, int y, int width, int height) {
mStartX = mView.getX() - mView.getTranslationX();
mStartY = mView.getY() - mView.getTranslationY();
mStartWidth = mView.getWidth();
mStartHeight = mView.getHeight();
mDeltaX = x - mStartX;
mDeltaY = y - mStartY;
mDeltaWidth = width - mStartWidth;
mDeltaHeight = height - mStartHeight;
}
}