Make fling animator customizable

Summary:
This diff makes the fling animator cuztomizable, so that the subclasses can have their own animation for fling behavior.

Before the diff, we rely on the `OverScroller` to `fling`, which has some issues with Spline interpolation being messed up due to the clamped fling distance (See more details in T105464095). This may not be a big issue for mobile when user touches screen, but in VR environment this shows up very clearly with joystick events. To fix that properly without affecting mobile behavior, I added a new interface `HasFlingAnimator` from the helper, and implemented with default fling animator in the OSS ScrollView.

We should consider adopt a suitable animator for mobile platform, as the non-smooth fling effect is also happening in mobile.

- Add interface `HasFlingAnimator` to `ReactScrollView` and `ReactHorizontalScrollView`
- Add default fling animator
- Depend on if the default animator is used, customize the flingAndSnap behavior

Changelog:
[Internal]

Reviewed By: JoshuaGross

Differential Revision: D32382806

fbshipit-source-id: 08f03350f6a9b9fc03414b4dcb9977b9f33603ba
This commit is contained in:
Xin Chen
2021-11-29 14:54:44 -08:00
committed by Facebook GitHub Bot
parent 0ff02f9a41
commit 3352b57a6f
3 changed files with 191 additions and 76 deletions
@@ -13,6 +13,8 @@ import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNME
import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_END;
import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_START;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
@@ -44,7 +46,9 @@ import com.facebook.react.uimanager.ReactClippingViewGroupHelper;
import com.facebook.react.uimanager.ReactOverflowView;
import com.facebook.react.uimanager.ViewProps;
import com.facebook.react.uimanager.events.NativeGestureUtil;
import com.facebook.react.views.scroll.ReactScrollViewHelper.HasFlingAnimator;
import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollState;
import com.facebook.react.views.scroll.ReactScrollViewHelper.ReactScrollViewScrollDirection;
import com.facebook.react.views.scroll.ReactScrollViewHelper.ReactScrollViewScrollState;
import com.facebook.react.views.view.ReactViewBackgroundManager;
import java.lang.reflect.Field;
@@ -56,7 +60,8 @@ public class ReactHorizontalScrollView extends HorizontalScrollView
implements ReactClippingViewGroup,
FabricViewStateManager.HasFabricViewStateManager,
ReactOverflowView,
HasScrollState {
HasScrollState,
HasFlingAnimator {
private static boolean DEBUG_MODE = false && ReactBuildConfig.DEBUG;
private static String TAG = ReactHorizontalScrollView.class.getSimpleName();
@@ -101,6 +106,7 @@ public class ReactHorizontalScrollView extends HorizontalScrollView
private int pendingContentOffsetY = UNSET_CONTENT_OFFSET;
private final FabricViewStateManager mFabricViewStateManager = new FabricViewStateManager();
private final ReactScrollViewScrollState mReactScrollViewScrollState;
private final ValueAnimator DEFAULT_FLING_ANIMATOR = ObjectAnimator.ofInt(this, "scrollX", 0, 0);
private final Rect mTempRect = new Rect();
@@ -135,7 +141,8 @@ public class ReactHorizontalScrollView extends HorizontalScrollView
new ReactScrollViewScrollState(
I18nUtil.getInstance().isRTL(context)
? ViewCompat.LAYOUT_DIRECTION_RTL
: ViewCompat.LAYOUT_DIRECTION_LTR);
: ViewCompat.LAYOUT_DIRECTION_LTR,
ReactScrollViewScrollDirection.HORIZONTAL);
}
@Nullable
@@ -899,6 +906,7 @@ public class ReactHorizontalScrollView extends HorizontalScrollView
return;
}
boolean hasCustomizedFlingAnimator = getFlingAnimator() != DEFAULT_FLING_ANIMATOR;
int maximumOffset = Math.max(0, computeHorizontalScrollRange() - getWidth());
int targetOffset = predictFinalScrollPosition(velocityX);
if (mDisableIntervalMomentum) {
@@ -1008,13 +1016,19 @@ public class ReactHorizontalScrollView extends HorizontalScrollView
targetOffset = firstOffset;
}
} else if (velocityX > 0) {
// when snapping velocity can feel sluggish for slow swipes
velocityX += (int) ((largerOffset - targetOffset) * 10.0);
if (!hasCustomizedFlingAnimator) {
// The default animator requires boost on initial velocity as when snapping velocity can
// feel sluggish for slow swipes
velocityX += (int) ((largerOffset - targetOffset) * 10.0);
}
targetOffset = largerOffset;
} else if (velocityX < 0) {
// when snapping velocity can feel sluggish for slow swipes
velocityX -= (int) ((targetOffset - smallerOffset) * 10.0);
if (!hasCustomizedFlingAnimator) {
// The default animator requires boost on initial velocity as when snapping velocity can
// feel sluggish for slow swipes
velocityX -= (int) ((targetOffset - smallerOffset) * 10.0);
}
targetOffset = smallerOffset;
} else {
@@ -1029,11 +1043,13 @@ public class ReactHorizontalScrollView extends HorizontalScrollView
velocityX = -velocityX;
}
// smoothScrollTo will always scroll over 250ms which is often *waaay*
// too short and will cause the scrolling to feel almost instant
// try to manually interact with OverScroller instead
// if velocity is 0 however, fling() won't work, so we want to use smoothScrollTo
if (mScroller != null) {
if (hasCustomizedFlingAnimator || mScroller == null) {
reactSmoothScrollTo(targetOffset, getScrollY());
} else {
// smoothScrollTo will always scroll over 250ms which is often *waaay*
// too short and will cause the scrolling to feel almost instant
// try to manually interact with OverScroller instead
// if velocity is 0 however, fling() won't work, so we want to use smoothScrollTo
mActivelyScrolling = true;
mScroller.fling(
@@ -1055,8 +1071,6 @@ public class ReactHorizontalScrollView extends HorizontalScrollView
);
postInvalidateOnAnimation();
} else {
reactSmoothScrollTo(targetOffset, getScrollY());
}
}
@@ -1196,4 +1210,28 @@ public class ReactHorizontalScrollView extends HorizontalScrollView
public ReactScrollViewScrollState getReactScrollViewScrollState() {
return mReactScrollViewScrollState;
}
@Override
public void startFlingAnimator(int start, int end) {
// Always cancel existing animator before starting the new one. `smoothScrollTo` contains some
// logic that, if called multiple times in a short amount of time, will treat all calls as part
// of the same animation and will not lengthen the duration of the animation. This means that,
// for example, if the user is scrolling rapidly, multiple pages could be considered part of one
// animation, causing some page animations to be animated very rapidly - looking like they're
// not animated at all.
DEFAULT_FLING_ANIMATOR.cancel();
// Update the fling animator with new values
DEFAULT_FLING_ANIMATOR
.setDuration(ReactScrollViewHelper.getDefaultScrollAnimationDuration(getContext()))
.setIntValues(start, end);
// Start the animator
DEFAULT_FLING_ANIMATOR.start();
}
@Override
public ValueAnimator getFlingAnimator() {
return DEFAULT_FLING_ANIMATOR;
}
}
@@ -13,6 +13,8 @@ import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNME
import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_END;
import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_START;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
@@ -40,7 +42,9 @@ import com.facebook.react.uimanager.ReactClippingViewGroupHelper;
import com.facebook.react.uimanager.ReactOverflowView;
import com.facebook.react.uimanager.ViewProps;
import com.facebook.react.uimanager.events.NativeGestureUtil;
import com.facebook.react.views.scroll.ReactScrollViewHelper.HasFlingAnimator;
import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollState;
import com.facebook.react.views.scroll.ReactScrollViewHelper.ReactScrollViewScrollDirection;
import com.facebook.react.views.scroll.ReactScrollViewHelper.ReactScrollViewScrollState;
import com.facebook.react.views.view.ReactViewBackgroundManager;
import java.lang.reflect.Field;
@@ -59,7 +63,8 @@ public class ReactScrollView extends ScrollView
View.OnLayoutChangeListener,
FabricViewStateManager.HasFabricViewStateManager,
ReactOverflowView,
HasScrollState {
HasScrollState,
HasFlingAnimator {
private static @Nullable Field sScrollerField;
private static boolean sTriedToGetScrollerField = false;
@@ -97,7 +102,9 @@ public class ReactScrollView extends ScrollView
private int pendingContentOffsetY = UNSET_CONTENT_OFFSET;
private final FabricViewStateManager mFabricViewStateManager = new FabricViewStateManager();
private final ReactScrollViewScrollState mReactScrollViewScrollState =
new ReactScrollViewScrollState(ViewCompat.LAYOUT_DIRECTION_LTR);
new ReactScrollViewScrollState(
ViewCompat.LAYOUT_DIRECTION_LTR, ReactScrollViewScrollDirection.VERTICAL);
private final ValueAnimator DEFAULT_FLING_ANIMATOR = ObjectAnimator.ofInt(this, "scrollY", 0, 0);
public ReactScrollView(Context context) {
this(context, null);
@@ -684,6 +691,7 @@ public class ReactScrollView extends ScrollView
return;
}
boolean hasCustomizedFlingAnimator = getFlingAnimator() != DEFAULT_FLING_ANIMATOR;
int maximumOffset = getMaxScrollY();
int targetOffset = predictFinalScrollPosition(velocityY);
if (mDisableIntervalMomentum) {
@@ -795,13 +803,19 @@ public class ReactScrollView extends ScrollView
targetOffset = firstOffset;
}
} else if (velocityY > 0) {
// when snapping velocity can feel sluggish for slow swipes
velocityY += (int) ((largerOffset - targetOffset) * 10.0);
if (!hasCustomizedFlingAnimator) {
// The default animator requires boost on initial velocity as when snapping velocity can
// feel sluggish for slow swipes
velocityY += (int) ((largerOffset - targetOffset) * 10.0);
}
targetOffset = largerOffset;
} else if (velocityY < 0) {
// when snapping velocity can feel sluggish for slow swipes
velocityY -= (int) ((targetOffset - smallerOffset) * 10.0);
if (!hasCustomizedFlingAnimator) {
// The default animator requires boost on initial velocity as when snapping velocity can
// feel sluggish for slow swipes
velocityY -= (int) ((targetOffset - smallerOffset) * 10.0);
}
targetOffset = smallerOffset;
} else {
@@ -811,11 +825,13 @@ public class ReactScrollView extends ScrollView
// Make sure the new offset isn't out of bounds
targetOffset = Math.min(Math.max(0, targetOffset), maximumOffset);
// smoothScrollTo will always scroll over 250ms which is often *waaay*
// too short and will cause the scrolling to feel almost instant
// try to manually interact with OverScroller instead
// if velocity is 0 however, fling() won't work, so we want to use smoothScrollTo
if (mScroller != null) {
if (hasCustomizedFlingAnimator || mScroller == null) {
reactSmoothScrollTo(getScrollX(), targetOffset);
} else {
// smoothScrollTo will always scroll over 250ms which is often *waaay*
// too short and will cause the scrolling to feel almost instant
// try to manually interact with OverScroller instead
// if velocity is 0 however, fling() won't work, so we want to use smoothScrollTo
mActivelyScrolling = true;
mScroller.fling(
@@ -837,8 +853,6 @@ public class ReactScrollView extends ScrollView
);
postInvalidateOnAnimation();
} else {
reactSmoothScrollTo(getScrollX(), targetOffset);
}
}
@@ -1060,4 +1074,28 @@ public class ReactScrollView extends ScrollView
public ReactScrollViewScrollState getReactScrollViewScrollState() {
return mReactScrollViewScrollState;
}
@Override
public void startFlingAnimator(int start, int end) {
// Always cancel existing animator before starting the new one. `smoothScrollTo` contains some
// logic that, if called multiple times in a short amount of time, will treat all calls as part
// of the same animation and will not lengthen the duration of the animation. This means that,
// for example, if the user is scrolling rapidly, multiple pages could be considered part of one
// animation, causing some page animations to be animated very rapidly - looking like they're
// not animated at all.
DEFAULT_FLING_ANIMATOR.cancel();
// Update the fling animator with new values
DEFAULT_FLING_ANIMATOR
.setDuration(ReactScrollViewHelper.getDefaultScrollAnimationDuration(getContext()))
.setIntValues(start, end);
// Start the animator
DEFAULT_FLING_ANIMATOR.start();
}
@Override
public ValueAnimator getFlingAnimator() {
return DEFAULT_FLING_ANIMATOR;
}
}
@@ -8,8 +8,6 @@
package com.facebook.react.views.scroll;
import android.animation.Animator;
import android.animation.ObjectAnimator;
import android.animation.PropertyValuesHolder;
import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Point;
@@ -50,8 +48,6 @@ public class ReactScrollViewHelper {
public static final int SNAP_ALIGNMENT_CENTER = 2;
public static final int SNAP_ALIGNMENT_END = 3;
private static @Nullable ValueAnimator sScrollAnimator;
public interface ScrollListener {
void onScroll(
ViewGroup scrollView, ScrollEventType scrollEventType, float xVelocity, float yVelocity);
@@ -219,14 +215,22 @@ public class ReactScrollViewHelper {
sScrollListeners.remove(listener);
}
public enum ReactScrollViewScrollDirection {
HORIZONTAL,
VERTICAL,
}
public static class ReactScrollViewScrollState {
private final int mLayoutDirection;
private final ReactScrollViewScrollDirection mScrollDirection;
private final Point mFinalAnimatedPositionScroll = new Point();
private int mScrollAwayPaddingTop = 0;
private final Point mLastStateUpdateScroll = new Point(-1, -1);
public ReactScrollViewScrollState(final int layoutDirection) {
public ReactScrollViewScrollState(
final int layoutDirection, final ReactScrollViewScrollDirection scrollDirection) {
mLayoutDirection = layoutDirection;
mScrollDirection = scrollDirection;
}
/**
@@ -237,6 +241,14 @@ public class ReactScrollViewHelper {
return mLayoutDirection;
}
/**
* Get the scroll direction. Can be either ReactScrollViewScrollDirection.HORIZONTAL or
* ReactScrollViewScrollDirection.VERTICAL.
*/
public ReactScrollViewScrollDirection getScrollDirection() {
return mScrollDirection;
}
/** Get the position after current animation is finished */
public Point getFinalAnimatedPositionScroll() {
return mFinalAnimatedPositionScroll;
@@ -273,72 +285,58 @@ public class ReactScrollViewHelper {
}
}
/**
* Scroll the given view to the location (x, y), with provided initial velocity. This method works
* by calculate the "would be" initial velocity with internal friction to move to the point (x,
* y), then apply that to the animator.
*/
public static <
T extends ViewGroup & FabricViewStateManager.HasFabricViewStateManager & HasScrollState>
T extends
ViewGroup & FabricViewStateManager.HasFabricViewStateManager & HasScrollState
& HasFlingAnimator>
void smoothScrollTo(final T scrollView, final int x, final int y) {
if (DEBUG_MODE) {
FLog.i(TAG, "smoothScrollTo[%d] x %d y %d", scrollView.getId(), x, y);
}
// `smoothScrollTo` contains some logic that, if called multiple times in a short amount of
// time, will treat all calls as part of the same animation and will not lengthen the duration
// of the animation. This means that, for example, if the user is scrolling rapidly, multiple
// pages could be considered part of one animation, causing some page animations to be animated
// very rapidly - looking like they're not animated at all.
if (sScrollAnimator != null) {
sScrollAnimator.cancel();
// Register the listeners for the fling animator if there isn't any
final ValueAnimator flingAnimator = scrollView.getFlingAnimator();
if (flingAnimator.getListeners() == null || flingAnimator.getListeners().size() == 0) {
registerFlingAnimator(scrollView);
}
final ReactScrollViewScrollState scrollState = scrollView.getReactScrollViewScrollState();
scrollState.setFinalAnimatedPositionScroll(x, y);
PropertyValuesHolder scrollX =
PropertyValuesHolder.ofInt("scrollX", scrollView.getScrollX(), x);
PropertyValuesHolder scrollY =
PropertyValuesHolder.ofInt("scrollY", scrollView.getScrollY(), y);
sScrollAnimator = ObjectAnimator.ofPropertyValuesHolder(scrollX, scrollY);
sScrollAnimator.setDuration(getDefaultScrollAnimationDuration(scrollView.getContext()));
sScrollAnimator.addUpdateListener(
new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
int scrollValueX = (Integer) valueAnimator.getAnimatedValue("scrollX");
int scrollValueY = (Integer) valueAnimator.getAnimatedValue("scrollY");
scrollView.scrollTo(scrollValueX, scrollValueY);
}
});
sScrollAnimator.addListener(
new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animator) {}
@Override
public void onAnimationEnd(Animator animator) {
scrollState.setFinalAnimatedPositionScroll(-1, -1);
sScrollAnimator = null;
updateStateOnScroll(scrollView);
}
final int scrollX = scrollView.getScrollX();
final int scrollY = scrollView.getScrollY();
final ReactScrollViewScrollDirection scrollDirection = scrollState.getScrollDirection();
if (scrollDirection == ReactScrollViewScrollDirection.HORIZONTAL && scrollX != x) {
scrollView.startFlingAnimator(scrollX, x);
}
if (scrollDirection == ReactScrollViewScrollDirection.VERTICAL && scrollY != y) {
scrollView.startFlingAnimator(scrollY, y);
}
@Override
public void onAnimationCancel(Animator animator) {}
@Override
public void onAnimationRepeat(Animator animator) {}
});
sScrollAnimator.start();
updateStateOnScroll(scrollView, x, y);
}
/** Get current (x, y) position or position after current animation finishes, if any. */
public static <
T extends ViewGroup & FabricViewStateManager.HasFabricViewStateManager & HasScrollState>
T extends
ViewGroup & FabricViewStateManager.HasFabricViewStateManager & HasScrollState
& HasFlingAnimator>
Point getPostAnimationScroll(final T scrollView) {
return sScrollAnimator != null && sScrollAnimator.isRunning()
final ValueAnimator flingAnimator = scrollView.getFlingAnimator();
return flingAnimator != null && flingAnimator.isRunning()
? scrollView.getReactScrollViewScrollState().getFinalAnimatedPositionScroll()
: new Point(scrollView.getScrollX(), scrollView.getScrollY());
}
public static <
T extends ViewGroup & FabricViewStateManager.HasFabricViewStateManager & HasScrollState>
T extends
ViewGroup & FabricViewStateManager.HasFabricViewStateManager & HasScrollState
& HasFlingAnimator>
boolean updateStateOnScroll(final T scrollView) {
return updateStateOnScroll(scrollView, scrollView.getScrollX(), scrollView.getScrollY());
}
@@ -347,7 +345,9 @@ public class ReactScrollViewHelper {
* Called on any stabilized onScroll change to propagate content offset value to a Shadow Node.
*/
public static <
T extends ViewGroup & FabricViewStateManager.HasFabricViewStateManager & HasScrollState>
T extends
ViewGroup & FabricViewStateManager.HasFabricViewStateManager & HasScrollState
& HasFlingAnimator>
boolean updateStateOnScroll(final T scrollView, final int scrollX, final int scrollY) {
if (DEBUG_MODE) {
FLog.i(
@@ -374,7 +374,9 @@ public class ReactScrollViewHelper {
}
public static <
T extends ViewGroup & FabricViewStateManager.HasFabricViewStateManager & HasScrollState>
T extends
ViewGroup & FabricViewStateManager.HasFabricViewStateManager & HasScrollState
& HasFlingAnimator>
void forceUpdateState(final T scrollView) {
final ReactScrollViewScrollState scrollState = scrollView.getReactScrollViewScrollState();
final int scrollAwayPaddingTop = scrollState.getScrollAwayPaddingTop();
@@ -420,8 +422,45 @@ public class ReactScrollViewHelper {
});
}
public static <
T extends
ViewGroup & FabricViewStateManager.HasFabricViewStateManager & HasScrollState
& HasFlingAnimator>
void registerFlingAnimator(final T scrollView) {
scrollView
.getFlingAnimator()
.addListener(
new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animator) {}
@Override
public void onAnimationEnd(Animator animator) {
scrollView.getReactScrollViewScrollState().setFinalAnimatedPositionScroll(-1, -1);
ReactScrollViewHelper.updateStateOnScroll(scrollView);
}
@Override
public void onAnimationCancel(Animator animator) {}
@Override
public void onAnimationRepeat(Animator animator) {}
});
}
public interface HasScrollState {
/** Get the scroll state for the current ScrollView */
ReactScrollViewScrollState getReactScrollViewScrollState();
}
public interface HasFlingAnimator {
/**
* Start the fling animator that the ScrollView has to go from the start position to end
* position.
*/
void startFlingAnimator(int start, int end);
/** Get the fling animator that is reused for the ScrollView to handle fling animation. */
ValueAnimator getFlingAnimator();
}
}