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

@ -125,19 +125,6 @@ type IOSProps = $ReadOnly<{|
* @platform ios
*/
centerContent?: ?boolean,
/**
* A floating-point number that determines how quickly the scroll view
* decelerates after the user lifts their finger. You may also use string
* shortcuts `"normal"` and `"fast"` which match the underlying iOS settings
* for `UIScrollViewDecelerationRateNormal` and
* `UIScrollViewDecelerationRateFast` respectively.
*
* - `'normal'`: 0.998 (the default)
* - `'fast'`: 0.99
*
* @platform ios
*/
decelerationRate?: ?('fast' | 'normal' | number),
/**
* The style of the scroll indicators.
*
@ -353,6 +340,17 @@ export type Props = $ReadOnly<{|
* ```
*/
contentContainerStyle?: ?ViewStyleProp,
/**
* A floating-point number that determines how quickly the scroll view
* decelerates after the user lifts their finger. You may also use string
* shortcuts `"normal"` and `"fast"` which match the underlying iOS settings
* for `UIScrollViewDecelerationRateNormal` and
* `UIScrollViewDecelerationRateFast` respectively.
*
* - `'normal'`: 0.998 on iOS, 0.985 on Android (the default)
* - `'fast'`: 0.99 on iOS, 0.9 on Android
*/
decelerationRate?: ?('fast' | 'normal' | number),
/**
* When true, the scroll view's children are arranged horizontally in a row
* instead of vertically in a column. The default value is false.
@ -462,12 +460,20 @@ export type Props = $ReadOnly<{|
* When set, causes the scroll view to stop at multiples of the value of
* `snapToInterval`. This can be used for paginating through children
* that have lengths smaller than the scroll view. Typically used in
* combination with `snapToAlignment` and `decelerationRate="fast"` on ios.
* Overrides less configurable `pagingEnabled` prop.
* combination with `snapToAlignment` and `decelerationRate="fast"`.
*
* Supported for horizontal scrollview on android.
* Overrides less configurable `pagingEnabled` prop.
*/
snapToInterval?: ?number,
/**
* When set, causes the scroll view to stop at the defined offsets.
* This can be used for paginating through variously sized children
* that have lengths smaller than the scroll view. Typically used in
* combination with `decelerationRate="fast"`.
*
* Overrides less configurable `pagingEnabled` and `snapToInterval` props.
*/
snapToOffsets?: ?$ReadOnlyArray<number>,
/**
* Experimental: When true, offscreen child views (whose `overflow` value is
* `hidden`) are removed from their native backing superview when offscreen.
@ -772,10 +778,6 @@ const ScrollView = createReactClass({
} else {
ScrollViewClass = RCTScrollView;
ScrollContentContainerViewClass = RCTScrollContentView;
warning(
this.props.snapToInterval == null || !this.props.pagingEnabled,
'snapToInterval is currently ignored when pagingEnabled is true.',
);
}
invariant(
@ -919,6 +921,19 @@ const ScrollView = createReactClass({
? true
: false,
DEPRECATED_sendUpdatedChildFrames,
// pagingEnabled is overridden by snapToInterval / snapToOffsets
pagingEnabled: Platform.select({
// on iOS, pagingEnabled must be set to false to have snapToInterval / snapToOffsets work
ios:
this.props.pagingEnabled &&
this.props.snapToInterval == null &&
this.props.snapToOffsets == null,
// on Android, pagingEnabled must be set to true to have snapToInterval / snapToOffsets work
android:
this.props.pagingEnabled ||
this.props.snapToInterval != null ||
this.props.snapToOffsets != null,
}),
};
const {decelerationRate} = this.props;

View File

@ -5,15 +5,26 @@
* LICENSE file in the root directory of this source tree.
*
* @format
* @flow
*/
'use strict';
function processDecelerationRate(decelerationRate) {
const Platform = require('Platform');
function processDecelerationRate(
decelerationRate: number | 'normal' | 'fast',
): number {
if (decelerationRate === 'normal') {
decelerationRate = 0.998;
return Platform.select({
ios: 0.998,
android: 0.985,
});
} else if (decelerationRate === 'fast') {
decelerationRate = 0.99;
return Platform.select({
ios: 0.99,
android: 0.9,
});
}
return decelerationRate;
}

View File

@ -45,6 +45,7 @@
@property (nonatomic, assign) BOOL centerContent;
@property (nonatomic, copy) NSDictionary *maintainVisibleContentPosition;
@property (nonatomic, assign) int snapToInterval;
@property (nonatomic, copy) NSArray<NSNumber *> *snapToOffsets;
@property (nonatomic, copy) NSString *snapToAlignment;
// NOTE: currently these event props are only declared so we can export the

View File

@ -727,12 +727,72 @@ RCT_SCROLL_EVENT_HANDLER(scrollViewDidZoom, onScroll)
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset
{
// snapToInterval
if (self.snapToOffsets) {
// An alternative to enablePaging and snapToInterval which allows setting custom
// stopping points that don't have to be the same distance apart. Often seen in
// apps which feature horizonally scrolling items. snapToInterval does not enforce
// scrolling one interval at a time but guarantees that the scroll will stop at
// a snap offset point.
// Find which axis to snap
BOOL isHorizontal = [self isHorizontal:scrollView];
// Calculate maximum content offset
CGSize viewportSize = [self _calculateViewportSize];
CGFloat maximumOffset = isHorizontal
? MAX(0, _scrollView.contentSize.width - viewportSize.width)
: MAX(0, _scrollView.contentSize.height - viewportSize.height);
// Calculate the snap offsets adjacent to the initial offset target
CGFloat targetOffset = isHorizontal ? targetContentOffset->x : targetContentOffset->y;
CGFloat smallerOffset = 0.0;
CGFloat largerOffset = maximumOffset;
for (int i = 0; i < self.snapToOffsets.count; i++) {
CGFloat offset = [[self.snapToOffsets objectAtIndex:i] floatValue];
if (offset <= targetOffset) {
if (targetOffset - offset < targetOffset - smallerOffset) {
smallerOffset = offset;
}
}
if (offset >= targetOffset) {
if (offset - targetOffset < largerOffset - targetOffset) {
largerOffset = offset;
}
}
}
// Calculate the nearest offset
CGFloat nearestOffset = targetOffset - smallerOffset < largerOffset - targetOffset
? smallerOffset
: largerOffset;
// Chose the correct snap offset based on velocity
CGFloat velocityAlongAxis = isHorizontal ? velocity.x : velocity.y;
if (velocityAlongAxis > 0.0) {
targetOffset = largerOffset;
} else if (velocityAlongAxis < 0.0) {
targetOffset = smallerOffset;
} else {
targetOffset = nearestOffset;
}
// Make sure the new offset isn't out of bounds
targetOffset = MIN(MAX(0, targetOffset), maximumOffset);
// Set new targetContentOffset
if (isHorizontal) {
targetContentOffset->x = targetOffset;
} else {
targetContentOffset->y = targetOffset;
}
} else if (self.snapToInterval) {
// An alternative to enablePaging which allows setting custom stopping intervals,
// smaller than a full page size. Often seen in apps which feature horizonally
// scrolling items. snapToInterval does not enforce scrolling one interval at a time
// but guarantees that the scroll will stop at an interval point.
if (self.snapToInterval) {
CGFloat snapToIntervalF = (CGFloat)self.snapToInterval;
// Find which axis to snap

View File

@ -81,6 +81,7 @@ RCT_EXPORT_VIEW_PROPERTY(zoomScale, CGFloat)
RCT_EXPORT_VIEW_PROPERTY(contentInset, UIEdgeInsets)
RCT_EXPORT_VIEW_PROPERTY(scrollIndicatorInsets, UIEdgeInsets)
RCT_EXPORT_VIEW_PROPERTY(snapToInterval, int)
RCT_EXPORT_VIEW_PROPERTY(snapToOffsets, NSArray<NSNumber *>)
RCT_EXPORT_VIEW_PROPERTY(snapToAlignment, NSString)
RCT_REMAP_VIEW_PROPERTY(contentOffset, scrollView.contentOffset, CGPoint)
RCT_EXPORT_VIEW_PROPERTY(onScrollBeginDrag, RCTDirectEventBlock)

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.