ScrollView snapToOffsets

Summary:
* Added snapToOffsets prop to ScrollView. Allows snapping at arbitrary points.

* Fixed pagingEnabled not being overridden by snapToInterval on iOS.

* Fixed Android *requiring* pagingEnabled to be defined alongside snapToInterval.
* Added support for decelerationRate on Android.

* Fixed snapping implementation. It was not calculating end position correctly at all (velocity is not a linear offset).
  * Resolves https://github.com/facebook/react-native/issues/20155
* Added support for new content being added during scroll (mirrors existing functionality in vertical ScrollView).

* Added support for snapToInterval.
  * Resolves https://github.com/facebook/react-native/issues/19552

Reviewed By: yungsters

Differential Revision: D9405703

fbshipit-source-id: b3c367b8079e6810794b0165dfdbcff4abff2eda
This commit is contained in:
Oleg Lokhvitsky
2018-08-30 12:59:33 -07:00
committed by Facebook Github Bot
parent 087e2a89fc
commit fd744dd56c
9 changed files with 634 additions and 92 deletions

View File

@@ -11,15 +11,20 @@ import android.annotation.TargetApi;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Rect;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.support.v4.view.ViewCompat;
import android.graphics.drawable.LayerDrawable;
import android.graphics.Rect;
import android.hardware.SensorManager;
import android.support.v4.view.ViewCompat;
import android.support.v4.text.TextUtilsCompat;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.widget.HorizontalScrollView;
import android.widget.OverScroller;
import com.facebook.infer.annotation.Assertions;
import com.facebook.react.common.ReactConstants;
import com.facebook.react.uimanager.MeasureSpecAssertions;
@@ -27,6 +32,10 @@ import com.facebook.react.uimanager.ReactClippingViewGroup;
import com.facebook.react.uimanager.ReactClippingViewGroupHelper;
import com.facebook.react.uimanager.events.NativeGestureUtil;
import com.facebook.react.views.view.ReactViewBackgroundManager;
import java.lang.reflect.Field;
import java.util.List;
import java.util.Locale;
import javax.annotation.Nullable;
/**
@@ -36,7 +45,11 @@ import javax.annotation.Nullable;
public class ReactHorizontalScrollView extends HorizontalScrollView implements
ReactClippingViewGroup {
private static @Nullable Field sScrollerField;
private static boolean sTriedToGetScrollerField = false;
private final OnScrollDispatchHelper mOnScrollDispatchHelper = new OnScrollDispatchHelper();
private final @Nullable OverScroller mScroller;
private final VelocityHelper mVelocityHelper = new VelocityHelper();
private final Rect mRect = new Rect();
@@ -53,6 +66,8 @@ public class ReactHorizontalScrollView extends HorizontalScrollView implements
private @Nullable Drawable mEndBackground;
private int mEndFillColor = Color.TRANSPARENT;
private int mSnapInterval = 0;
private float mDecelerationRate = 0.985f;
private @Nullable List<Integer> mSnapOffsets;
private ReactViewBackgroundManager mReactBackgroundManager;
public ReactHorizontalScrollView(Context context) {
@@ -63,6 +78,47 @@ public class ReactHorizontalScrollView extends HorizontalScrollView implements
super(context);
mReactBackgroundManager = new ReactViewBackgroundManager(this);
mFpsListener = fpsListener;
mScroller = getOverScrollerFromParent();
}
@Nullable
private OverScroller getOverScrollerFromParent() {
OverScroller scroller;
if (!sTriedToGetScrollerField) {
sTriedToGetScrollerField = true;
try {
sScrollerField = HorizontalScrollView.class.getDeclaredField("mScroller");
sScrollerField.setAccessible(true);
} catch (NoSuchFieldException e) {
Log.w(
ReactConstants.TAG,
"Failed to get mScroller field for HorizontalScrollView! " +
"This app will exhibit the bounce-back scrolling bug :(");
}
}
if (sScrollerField != null) {
try {
Object scrollerValue = sScrollerField.get(this);
if (scrollerValue instanceof OverScroller) {
scroller = (OverScroller) scrollerValue;
} else {
Log.w(
ReactConstants.TAG,
"Failed to cast mScroller field in HorizontalScrollView (probably due to OEM changes to AOSP)! " +
"This app will exhibit the bounce-back scrolling bug :(");
scroller = null;
}
} catch (IllegalAccessException e) {
throw new RuntimeException("Failed to get mScroller from HorizontalScrollView!", e);
}
} else {
scroller = null;
}
return scroller;
}
public void setScrollPerfTag(@Nullable String scrollPerfTag) {
@@ -95,10 +151,22 @@ public class ReactHorizontalScrollView extends HorizontalScrollView implements
mPagingEnabled = pagingEnabled;
}
public void setDecelerationRate(float decelerationRate) {
mDecelerationRate = decelerationRate;
if (mScroller != null) {
mScroller.setFriction(1.0f - mDecelerationRate);
}
}
public void setSnapInterval(int snapInterval) {
mSnapInterval = snapInterval;
}
public void setSnapOffsets(List<Integer> snapOffsets) {
mSnapOffsets = snapOffsets;
}
public void flashScrollIndicators() {
awakenScrollBars();
}
@@ -194,7 +262,34 @@ public class ReactHorizontalScrollView extends HorizontalScrollView implements
@Override
public void fling(int velocityX) {
if (mPagingEnabled) {
smoothScrollToPage(velocityX);
smoothScrollAndSnap(velocityX);
} else if (mScroller != null) {
// FB SCROLLVIEW CHANGE
// We provide our own version of fling that uses a different call to the standard OverScroller
// which takes into account the possibility of adding new content while the ScrollView is
// animating. Because we give essentially no max X for the fling, the fling will continue as long
// as there is content. See #onOverScrolled() to see the second part of this change which properly
// aborts the scroller animation when we get to the bottom of the ScrollView content.
int scrollWindowWidth = getWidth() - getPaddingStart() - getPaddingEnd();
mScroller.fling(
getScrollX(), // startX
getScrollY(), // startY
velocityX, // velocityX
0, // velocityY
0, // minX
Integer.MAX_VALUE, // maxX
0, // minY
0, // maxY
scrollWindowWidth / 2, // overX
0 // overY
);
ViewCompat.postInvalidateOnAnimation(this);
// END FB SCROLLVIEW CHANGE
} else {
super.fling(velocityX);
}
@@ -251,6 +346,28 @@ public class ReactHorizontalScrollView extends HorizontalScrollView implements
}
}
@Override
protected void onOverScrolled(int scrollX, int scrollY, boolean clampedX, boolean clampedY) {
if (mScroller != null) {
// FB SCROLLVIEW CHANGE
// This is part two of the reimplementation of fling to fix the bounce-back bug. See #fling() for
// more information.
if (!mScroller.isFinished() && mScroller.getCurrX() != mScroller.getFinalX()) {
int scrollRange = computeHorizontalScrollRange() - getWidth();
if (scrollX >= scrollRange) {
mScroller.abortAnimation();
scrollX = scrollRange;
}
}
// END FB SCROLLVIEW CHANGE
}
super.onOverScrolled(scrollX, scrollY, clampedX, clampedY);
}
private void enableFpsListener() {
if (isScrollPerfLoggingEnabled()) {
Assertions.assertNotNull(mFpsListener);
@@ -290,7 +407,7 @@ public class ReactHorizontalScrollView extends HorizontalScrollView implements
* runnable that checks if we scrolled in the last frame and if so assumes we are still scrolling.
*/
private void handlePostTouchScrolling(int velocityX, int velocityY) {
// If we aren't going to do anything (send events or snap to page), we can early out.
// If we aren't going to do anything (send events or snap to page), we can early exit out.
if (!mSendMomentumEvents && !mPagingEnabled && !isScrollPerfLoggingEnabled()) {
return;
}
@@ -323,7 +440,7 @@ public class ReactHorizontalScrollView extends HorizontalScrollView implements
// Only if we have pagingEnabled and we have not snapped to the page do we
// need to continue checking for the scroll. And we cause that scroll by asking for it
mSnappingToPage = true;
smoothScrollToPage(0);
smoothScrollAndSnap(0);
ViewCompat.postOnAnimationDelayed(ReactHorizontalScrollView.this,
this,
ReactScrollViewHelper.MOMENTUM_DELAY);
@@ -343,21 +460,124 @@ public class ReactHorizontalScrollView extends HorizontalScrollView implements
}
/**
* This will smooth scroll us to the nearest page boundary
* It currently just looks at where the content is relative to the page and slides to the nearest
* page. It is intended to be run after we are done scrolling, and handling any momentum
* scrolling.
* This will smooth scroll us to the nearest snap offset point
* It currently just looks at where the content is and slides to the nearest point.
* It is intended to be run after we are done scrolling, and handling any momentum scrolling.
*/
private void smoothScrollToPage(int velocity) {
int width = getSnapInterval();
int currentX = getScrollX();
// TODO (t11123799) - Should we do anything beyond linear accounting of the velocity
int predictedX = currentX + velocity;
int page = currentX / width;
if (predictedX > page * width + width / 2) {
page = page + 1;
private void smoothScrollAndSnap(int velocityX) {
if (getChildCount() <= 0) {
return;
}
int maximumOffset = Math.max(0, computeHorizontalScrollRange() - getWidth());
int targetOffset = 0;
int smallerOffset = 0;
int largerOffset = maximumOffset;
// ScrollView can *only* scroll for 250ms when using smoothScrollTo and there's
// no way to customize the scroll duration. So, we create a temporary OverScroller
// so we can predict where a fling would land and snap to nearby that point.
OverScroller scroller = new OverScroller(getContext());
scroller.setFriction(1.0f - mDecelerationRate);
// predict where a fling would end up so we can scroll to the nearest snap offset
int width = getWidth() - getPaddingStart() - getPaddingEnd();
scroller.fling(
getScrollX(), // startX
getScrollY(), // startY
velocityX, // velocityX
0, // velocityY
0, // minX
maximumOffset, // maxX
0, // minY
0, // maxY
width/2, // overX
0 // overY
);
targetOffset = scroller.getFinalX();
// offsets are from the right edge in RTL layouts
boolean isRTL = TextUtilsCompat.getLayoutDirectionFromLocale(Locale.getDefault()) == ViewCompat.LAYOUT_DIRECTION_RTL;
if (isRTL) {
targetOffset = maximumOffset - targetOffset;
velocityX = -velocityX;
}
// get the nearest snap points to the target offset
if (mSnapOffsets != null) {
for (int i = 0; i < mSnapOffsets.size(); i ++) {
int offset = mSnapOffsets.get(i);
if (offset <= targetOffset) {
if (targetOffset - offset < targetOffset - smallerOffset) {
smallerOffset = offset;
}
}
if (offset >= targetOffset) {
if (offset - targetOffset < largerOffset - targetOffset) {
largerOffset = offset;
}
}
}
} else {
double interval = (double) getSnapInterval();
double ratio = (double) targetOffset / interval;
smallerOffset = (int) (Math.floor(ratio) * interval);
largerOffset = (int) (Math.ceil(ratio) * interval);
}
// Calculate the nearest offset
int nearestOffset = targetOffset - smallerOffset < largerOffset - targetOffset
? smallerOffset
: largerOffset;
// Chose the correct snap offset based on velocity
if (velocityX > 0) {
targetOffset = largerOffset;
} else if (velocityX < 0) {
targetOffset = smallerOffset;
} else {
targetOffset = nearestOffset;
}
// Make sure the new offset isn't out of bounds
targetOffset = Math.min(Math.max(0, targetOffset), maximumOffset);
if (isRTL) {
targetOffset = maximumOffset - targetOffset;
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) {
mActivelyScrolling = true;
mScroller.fling(
getScrollX(), // startX
getScrollY(), // startY
// velocity = 0 doesn't work with fling() so we pretend there's a reasonable
// initial velocity going on when a touch is released without any movement
velocityX != 0 ? velocityX : targetOffset - getScrollX(), // velocityX
0, // velocityY
// setting both minX and maxX to the same value will guarantee that we scroll to it
// but using the standard fling-style easing rather than smoothScrollTo's 250ms animation
targetOffset, // minX
targetOffset, // maxX
0, // minY
0, // maxY
// we only want to allow overscrolling if the final offset is at the very edge of the view
(targetOffset == 0 || targetOffset == maximumOffset) ? width / 2 : 0, // overX
0 // overY
);
postInvalidateOnAnimation();
} else {
smoothScrollTo(targetOffset, getScrollY());
}
smoothScrollTo(page * width, getScrollY());
}
@Override

View File

@@ -24,6 +24,8 @@ import com.facebook.react.uimanager.annotations.ReactProp;
import com.facebook.react.uimanager.annotations.ReactPropGroup;
import com.facebook.yoga.YogaConstants;
import java.util.ArrayList;
import java.util.List;
import javax.annotation.Nullable;
/**
@@ -73,6 +75,11 @@ public class ReactHorizontalScrollViewManager
view.setHorizontalScrollBarEnabled(value);
}
@ReactProp(name = "decelerationRate")
public void setDecelerationRate(ReactHorizontalScrollView view, float decelerationRate) {
view.setDecelerationRate(decelerationRate);
}
@ReactProp(name = "snapToInterval")
public void setSnapToInterval(ReactHorizontalScrollView view, float snapToInterval) {
// snapToInterval needs to be exposed as a float because of the Javascript interface.
@@ -80,6 +87,16 @@ public class ReactHorizontalScrollViewManager
view.setSnapInterval((int) (snapToInterval * screenDisplayMetrics.density));
}
@ReactProp(name = "snapToOffsets")
public void setSnapToOffsets(ReactHorizontalScrollView view, @Nullable ReadableArray snapToOffsets) {
DisplayMetrics screenDisplayMetrics = DisplayMetricsHolder.getScreenDisplayMetrics();
List<Integer> offsets = new ArrayList<Integer>();
for (int i = 0; i < snapToOffsets.size(); i++) {
offsets.add((int) (snapToOffsets.getDouble(i) * screenDisplayMetrics.density));
}
view.setSnapOffsets(offsets);
}
@ReactProp(name = ReactClippingViewGroupHelper.PROP_REMOVE_CLIPPED_SUBVIEWS)
public void setRemoveClippedSubviews(ReactHorizontalScrollView view, boolean removeClippedSubviews) {
view.setRemoveClippedSubviews(removeClippedSubviews);

View File

@@ -20,6 +20,7 @@ import android.view.View;
import android.view.ViewGroup;
import android.widget.OverScroller;
import android.widget.ScrollView;
import com.facebook.infer.annotation.Assertions;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.common.ReactConstants;
@@ -28,7 +29,9 @@ import com.facebook.react.uimanager.ReactClippingViewGroup;
import com.facebook.react.uimanager.ReactClippingViewGroupHelper;
import com.facebook.react.uimanager.events.NativeGestureUtil;
import com.facebook.react.views.view.ReactViewBackgroundManager;
import java.lang.reflect.Field;
import java.util.List;
import javax.annotation.Nullable;
/**
@@ -49,10 +52,11 @@ public class ReactScrollView extends ScrollView implements ReactClippingViewGrou
private final VelocityHelper mVelocityHelper = new VelocityHelper();
private final Rect mRect = new Rect(); // for reuse to avoid allocation
private boolean mActivelyScrolling;
private @Nullable Rect mClippingRect;
private boolean mDoneFlinging;
private boolean mDragging;
private boolean mFlinging;
private boolean mPagingEnabled = false;
private @Nullable Runnable mPostTouchRunnable;
private boolean mRemoveClippedSubviews;
private boolean mScrollEnabled = true;
private boolean mSendMomentumEvents;
@@ -60,6 +64,9 @@ public class ReactScrollView extends ScrollView implements ReactClippingViewGrou
private @Nullable String mScrollPerfTag;
private @Nullable Drawable mEndBackground;
private int mEndFillColor = Color.TRANSPARENT;
private int mSnapInterval = 0;
private float mDecelerationRate = 0.985f;
private @Nullable List<Integer> mSnapOffsets;
private View mContentView;
private ReactViewBackgroundManager mReactBackgroundManager;
@@ -128,6 +135,26 @@ public class ReactScrollView extends ScrollView implements ReactClippingViewGrou
mScrollEnabled = scrollEnabled;
}
public void setPagingEnabled(boolean pagingEnabled) {
mPagingEnabled = pagingEnabled;
}
public void setDecelerationRate(float decelerationRate) {
mDecelerationRate = decelerationRate;
if (mScroller != null) {
mScroller.setFriction(1.0f - mDecelerationRate);
}
}
public void setSnapInterval(int snapInterval) {
mSnapInterval = snapInterval;
}
public void setSnapOffsets(List<Integer> snapOffsets) {
mSnapOffsets = snapOffsets;
}
public void flashScrollIndicators() {
awakenScrollBars();
}
@@ -167,15 +194,13 @@ public class ReactScrollView extends ScrollView implements ReactClippingViewGrou
protected void onScrollChanged(int x, int y, int oldX, int oldY) {
super.onScrollChanged(x, y, oldX, oldY);
mActivelyScrolling = true;
if (mOnScrollDispatchHelper.onScrollChanged(x, y)) {
if (mRemoveClippedSubviews) {
updateClippingRect();
}
if (mFlinging) {
mDoneFlinging = false;
}
ReactScrollViewHelper.emitScrollEvent(
this,
mOnScrollDispatchHelper.getXFlingVelocity(),
@@ -216,12 +241,16 @@ public class ReactScrollView extends ScrollView implements ReactClippingViewGrou
mVelocityHelper.calculateVelocity(ev);
int action = ev.getAction() & MotionEvent.ACTION_MASK;
if (action == MotionEvent.ACTION_UP && mDragging) {
float velocityX = mVelocityHelper.getXVelocity();
float velocityY = mVelocityHelper.getYVelocity();
ReactScrollViewHelper.emitScrollEndDragEvent(
this,
mVelocityHelper.getXVelocity(),
mVelocityHelper.getYVelocity());
velocityX,
velocityY);
mDragging = false;
disableFpsListener();
// After the touch finishes, we may need to do some scrolling afterwards either as a result
// of a fling or because we need to page align the content
handlePostTouchScrolling(Math.round(velocityX), Math.round(velocityY));
}
return super.onTouchEvent(ev);
@@ -263,7 +292,9 @@ public class ReactScrollView extends ScrollView implements ReactClippingViewGrou
@Override
public void fling(int velocityY) {
if (mScroller != null) {
if (mPagingEnabled) {
smoothScrollAndSnap(velocityY);
} else if (mScroller != null) {
// FB SCROLLVIEW CHANGE
// We provide our own version of fling that uses a different call to the standard OverScroller
@@ -275,16 +306,17 @@ public class ReactScrollView extends ScrollView implements ReactClippingViewGrou
int scrollWindowHeight = getHeight() - getPaddingBottom() - getPaddingTop();
mScroller.fling(
getScrollX(),
getScrollY(),
0,
velocityY,
0,
0,
0,
Integer.MAX_VALUE,
0,
scrollWindowHeight / 2);
getScrollX(), // startX
getScrollY(), // startY
0, // velocityX
velocityY, // velocityY
0, // minX
0, // maxX
0, // minY
Integer.MAX_VALUE, // maxY
0, // overX
scrollWindowHeight / 2 // overY
);
ViewCompat.postInvalidateOnAnimation(this);
@@ -292,29 +324,7 @@ public class ReactScrollView extends ScrollView implements ReactClippingViewGrou
} else {
super.fling(velocityY);
}
if (mSendMomentumEvents || isScrollPerfLoggingEnabled()) {
mFlinging = true;
enableFpsListener();
ReactScrollViewHelper.emitScrollMomentumBeginEvent(this, 0, velocityY);
Runnable r = new Runnable() {
@Override
public void run() {
if (mDoneFlinging) {
mFlinging = false;
disableFpsListener();
ReactScrollViewHelper.emitScrollMomentumEndEvent(ReactScrollView.this);
} else {
mDoneFlinging = true;
ViewCompat.postOnAnimationDelayed(
ReactScrollView.this,
this,
ReactScrollViewHelper.MOMENTUM_DELAY);
}
}
};
ViewCompat.postOnAnimationDelayed(this, r, ReactScrollViewHelper.MOMENTUM_DELAY);
}
handlePostTouchScrolling(0, velocityY);
}
private void enableFpsListener() {
@@ -357,6 +367,182 @@ public class ReactScrollView extends ScrollView implements ReactClippingViewGrou
super.draw(canvas);
}
/**
* This handles any sort of scrolling that may occur after a touch is finished. This may be
* momentum scrolling (fling) or because you have pagingEnabled on the scroll view. Because we
* don't get any events from Android about this lifecycle, we do all our detection by creating a
* runnable that checks if we scrolled in the last frame and if so assumes we are still scrolling.
*/
private void handlePostTouchScrolling(int velocityX, int velocityY) {
// If we aren't going to do anything (send events or snap to page), we can early exit out.
if (!mSendMomentumEvents && !mPagingEnabled && !isScrollPerfLoggingEnabled()) {
return;
}
// Check if we are already handling this which may occur if this is called by both the touch up
// and a fling call
if (mPostTouchRunnable != null) {
return;
}
if (mSendMomentumEvents) {
enableFpsListener();
ReactScrollViewHelper.emitScrollMomentumBeginEvent(this, velocityX, velocityY);
}
mActivelyScrolling = false;
mPostTouchRunnable = new Runnable() {
private boolean mSnappingToPage = false;
@Override
public void run() {
if (mActivelyScrolling) {
// We are still scrolling so we just post to check again a frame later
mActivelyScrolling = false;
ViewCompat.postOnAnimationDelayed(ReactScrollView.this,
this,
ReactScrollViewHelper.MOMENTUM_DELAY);
} else {
if (mPagingEnabled && !mSnappingToPage) {
// Only if we have pagingEnabled and we have not snapped to the page do we
// need to continue checking for the scroll. And we cause that scroll by asking for it
mSnappingToPage = true;
smoothScrollAndSnap(0);
ViewCompat.postOnAnimationDelayed(ReactScrollView.this,
this,
ReactScrollViewHelper.MOMENTUM_DELAY);
} else {
if (mSendMomentumEvents) {
ReactScrollViewHelper.emitScrollMomentumEndEvent(ReactScrollView.this);
}
ReactScrollView.this.mPostTouchRunnable = null;
disableFpsListener();
}
}
}
};
ViewCompat.postOnAnimationDelayed(ReactScrollView.this,
mPostTouchRunnable,
ReactScrollViewHelper.MOMENTUM_DELAY);
}
/**
* This will smooth scroll us to the nearest snap offset point
* It currently just looks at where the content is and slides to the nearest point.
* It is intended to be run after we are done scrolling, and handling any momentum scrolling.
*/
private void smoothScrollAndSnap(int velocityY) {
if (getChildCount() <= 0) {
return;
}
int maximumOffset = getMaxScrollY();
int targetOffset = 0;
int smallerOffset = 0;
int largerOffset = maximumOffset;
// ScrollView can *only* scroll for 250ms when using smoothScrollTo and there's
// no way to customize the scroll duration. So, we create a temporary OverScroller
// so we can predict where a fling would land and snap to nearby that point.
OverScroller scroller = new OverScroller(getContext());
scroller.setFriction(1.0f - mDecelerationRate);
// predict where a fling would end up so we can scroll to the nearest snap offset
int height = getHeight() - getPaddingBottom() - getPaddingTop();
scroller.fling(
getScrollX(), // startX
getScrollY(), // startY
0, // velocityX
velocityY, // velocityY
0, // minX
0, // maxX
0, // minY
maximumOffset, // maxY
0, // overX
height/2 // overY
);
targetOffset = scroller.getFinalY();
// get the nearest snap points to the target offset
if (mSnapOffsets != null) {
for (int i = 0; i < mSnapOffsets.size(); i ++) {
int offset = mSnapOffsets.get(i);
if (offset <= targetOffset) {
if (targetOffset - offset < targetOffset - smallerOffset) {
smallerOffset = offset;
}
}
if (offset >= targetOffset) {
if (offset - targetOffset < largerOffset - targetOffset) {
largerOffset = offset;
}
}
}
} else {
double interval = (double) getSnapInterval();
double ratio = (double) targetOffset / interval;
smallerOffset = (int) (Math.floor(ratio) * interval);
largerOffset = (int) (Math.ceil(ratio) * interval);
}
// Calculate the nearest offset
int nearestOffset = targetOffset - smallerOffset < largerOffset - targetOffset
? smallerOffset
: largerOffset;
// Chose the correct snap offset based on velocity
if (velocityY > 0) {
targetOffset = largerOffset;
} else if (velocityY < 0) {
targetOffset = smallerOffset;
} else {
targetOffset = nearestOffset;
}
// 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) {
mActivelyScrolling = true;
mScroller.fling(
getScrollX(), // startX
getScrollY(), // startY
// velocity = 0 doesn't work with fling() so we pretend there's a reasonable
// initial velocity going on when a touch is released without any movement
0, // velocityX
velocityY != 0 ? velocityY : targetOffset - getScrollY(), // velocityY
0, // minX
0, // maxX
// setting both minY and maxY to the same value will guarantee that we scroll to it
// but using the standard fling-style easing rather than smoothScrollTo's 250ms animation
targetOffset, // minY
targetOffset, // maxY
0, // overX
// we only want to allow overscrolling if the final offset is at the very edge of the view
(targetOffset == 0 || targetOffset == maximumOffset) ? height / 2 : 0 // overY
);
postInvalidateOnAnimation();
} else {
smoothScrollTo(getScrollX(), targetOffset);
}
}
private int getSnapInterval() {
if (mSnapInterval != 0) {
return mSnapInterval;
}
return getHeight();
}
public void setEndFillColor(int color) {
if (color != mEndFillColor) {
mEndFillColor = color;

View File

@@ -10,10 +10,12 @@ package com.facebook.react.views.scroll;
import android.annotation.TargetApi;
import android.graphics.Color;
import android.support.v4.view.ViewCompat;
import android.util.DisplayMetrics;
import com.facebook.react.bridge.ReadableArray;
import com.facebook.react.common.MapBuilder;
import com.facebook.react.module.annotations.ReactModule;
import com.facebook.react.uimanager.DisplayMetricsHolder;
import com.facebook.react.uimanager.PixelUtil;
import com.facebook.react.uimanager.ReactClippingViewGroupHelper;
import com.facebook.react.uimanager.Spacing;
@@ -24,13 +26,15 @@ import com.facebook.react.uimanager.annotations.ReactProp;
import com.facebook.react.uimanager.annotations.ReactPropGroup;
import com.facebook.yoga.YogaConstants;
import javax.annotation.Nullable;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import javax.annotation.Nullable;
/**
* View manager for {@link ReactScrollView} components.
*
* <p>Note that {@link ReactScrollView} and {@link ReactHorizontalScrollView} are exposed to JS
* <p>Note that {@link ReactScrollView} and {@link ReactScrollView} are exposed to JS
* as a single ScrollView component, configured via the {@code horizontal} boolean property.
*/
@TargetApi(11)
@@ -75,6 +79,28 @@ public class ReactScrollViewManager
view.setVerticalScrollBarEnabled(value);
}
@ReactProp(name = "decelerationRate")
public void setDecelerationRate(ReactScrollView view, float decelerationRate) {
view.setDecelerationRate(decelerationRate);
}
@ReactProp(name = "snapToInterval")
public void setSnapToInterval(ReactScrollView view, float snapToInterval) {
// snapToInterval needs to be exposed as a float because of the Javascript interface.
DisplayMetrics screenDisplayMetrics = DisplayMetricsHolder.getScreenDisplayMetrics();
view.setSnapInterval((int) (snapToInterval * screenDisplayMetrics.density));
}
@ReactProp(name = "snapToOffsets")
public void setSnapToOffsets(ReactScrollView view, @Nullable ReadableArray snapToOffsets) {
DisplayMetrics screenDisplayMetrics = DisplayMetricsHolder.getScreenDisplayMetrics();
List<Integer> offsets = new ArrayList<Integer>();
for (int i = 0; i < snapToOffsets.size(); i++) {
offsets.add((int) (snapToOffsets.getDouble(i) * screenDisplayMetrics.density));
}
view.setSnapOffsets(offsets);
}
@ReactProp(name = ReactClippingViewGroupHelper.PROP_REMOVE_CLIPPED_SUBVIEWS)
public void setRemoveClippedSubviews(ReactScrollView view, boolean removeClippedSubviews) {
view.setRemoveClippedSubviews(removeClippedSubviews);
@@ -105,6 +131,11 @@ public class ReactScrollViewManager
view.setScrollPerfTag(scrollPerfTag);
}
@ReactProp(name = "pagingEnabled")
public void setPagingEnabled(ReactScrollView view, boolean pagingEnabled) {
view.setPagingEnabled(pagingEnabled);
}
/**
* When set, fills the rest of the scrollview with a color to avoid setting a background and
* creating unnecessary overdraw.