From fd744dd56ca183933a67e8398e1d20da14a31aab Mon Sep 17 00:00:00 2001 From: Oleg Lokhvitsky Date: Thu, 30 Aug 2018 12:59:33 -0700 Subject: [PATCH] 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 --- Libraries/Components/ScrollView/ScrollView.js | 55 ++-- .../ScrollView/processDecelerationRate.js | 17 +- React/Views/ScrollView/RCTScrollView.h | 1 + React/Views/ScrollView/RCTScrollView.m | 72 ++++- React/Views/ScrollView/RCTScrollViewManager.m | 1 + .../scroll/ReactHorizontalScrollView.java | 256 +++++++++++++++-- .../ReactHorizontalScrollViewManager.java | 17 ++ .../react/views/scroll/ReactScrollView.java | 272 +++++++++++++++--- .../views/scroll/ReactScrollViewManager.java | 35 ++- 9 files changed, 634 insertions(+), 92 deletions(-) diff --git a/Libraries/Components/ScrollView/ScrollView.js b/Libraries/Components/ScrollView/ScrollView.js index aef48e3e1b4026..423d1523532c51 100644 --- a/Libraries/Components/ScrollView/ScrollView.js +++ b/Libraries/Components/ScrollView/ScrollView.js @@ -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, /** * 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; diff --git a/Libraries/Components/ScrollView/processDecelerationRate.js b/Libraries/Components/ScrollView/processDecelerationRate.js index 18b903ffa3831c..fc50c6766f92e9 100644 --- a/Libraries/Components/ScrollView/processDecelerationRate.js +++ b/Libraries/Components/ScrollView/processDecelerationRate.js @@ -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; } diff --git a/React/Views/ScrollView/RCTScrollView.h b/React/Views/ScrollView/RCTScrollView.h index e1853e5d9d3558..688d8f62e771d3 100644 --- a/React/Views/ScrollView/RCTScrollView.h +++ b/React/Views/ScrollView/RCTScrollView.h @@ -45,6 +45,7 @@ @property (nonatomic, assign) BOOL centerContent; @property (nonatomic, copy) NSDictionary *maintainVisibleContentPosition; @property (nonatomic, assign) int snapToInterval; +@property (nonatomic, copy) NSArray *snapToOffsets; @property (nonatomic, copy) NSString *snapToAlignment; // NOTE: currently these event props are only declared so we can export the diff --git a/React/Views/ScrollView/RCTScrollView.m b/React/Views/ScrollView/RCTScrollView.m index 4afdb379d4a0de..5d5f947d0e6f62 100644 --- a/React/Views/ScrollView/RCTScrollView.m +++ b/React/Views/ScrollView/RCTScrollView.m @@ -727,12 +727,72 @@ - (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset { - // 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) { + 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. CGFloat snapToIntervalF = (CGFloat)self.snapToInterval; // Find which axis to snap diff --git a/React/Views/ScrollView/RCTScrollViewManager.m b/React/Views/ScrollView/RCTScrollViewManager.m index e5824afd3f386d..c03f2ccd8c45c7 100644 --- a/React/Views/ScrollView/RCTScrollViewManager.m +++ b/React/Views/ScrollView/RCTScrollViewManager.m @@ -81,6 +81,7 @@ - (UIView *)view RCT_EXPORT_VIEW_PROPERTY(contentInset, UIEdgeInsets) RCT_EXPORT_VIEW_PROPERTY(scrollIndicatorInsets, UIEdgeInsets) RCT_EXPORT_VIEW_PROPERTY(snapToInterval, int) +RCT_EXPORT_VIEW_PROPERTY(snapToOffsets, NSArray) RCT_EXPORT_VIEW_PROPERTY(snapToAlignment, NSString) RCT_REMAP_VIEW_PROPERTY(contentOffset, scrollView.contentOffset, CGPoint) RCT_EXPORT_VIEW_PROPERTY(onScrollBeginDrag, RCTDirectEventBlock) diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java index ba6ff876c2b7dd..f544aafdb66db6 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java @@ -11,15 +11,20 @@ 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.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 @@ 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 mSnapOffsets; private ReactViewBackgroundManager mReactBackgroundManager; public ReactHorizontalScrollView(Context context) { @@ -63,6 +78,47 @@ public ReactHorizontalScrollView(Context context, @Nullable FpsListener fpsListe 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 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 snapOffsets) { + mSnapOffsets = snapOffsets; + } + public void flashScrollIndicators() { awakenScrollBars(); } @@ -194,7 +262,34 @@ public boolean onTouchEvent(MotionEvent ev) { @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 void setEndFillColor(int color) { } } + @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 void draw(Canvas canvas) { * 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 void run() { // 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 void run() { } /** - * 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 diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.java index e6536a6e53eb76..2f65a7c8922579 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.java @@ -24,6 +24,8 @@ 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 void setShowsHorizontalScrollIndicator(ReactHorizontalScrollView view, bo 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 void setSnapToInterval(ReactHorizontalScrollView view, float snapToInterv view.setSnapInterval((int) (snapToInterval * screenDisplayMetrics.density)); } + @ReactProp(name = "snapToOffsets") + public void setSnapToOffsets(ReactHorizontalScrollView view, @Nullable ReadableArray snapToOffsets) { + DisplayMetrics screenDisplayMetrics = DisplayMetricsHolder.getScreenDisplayMetrics(); + List offsets = new ArrayList(); + 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); diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java index 57b401cd6fbf5c..9df87e57e7404f 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java @@ -20,6 +20,7 @@ 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.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 mSnapOffsets; private View mContentView; private ReactViewBackgroundManager mReactBackgroundManager; @@ -128,6 +135,26 @@ public void setScrollEnabled(boolean scrollEnabled) { 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 snapOffsets) { + mSnapOffsets = snapOffsets; + } + public void flashScrollIndicators() { awakenScrollBars(); } @@ -167,15 +194,13 @@ protected void onAttachedToWindow() { 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 boolean onTouchEvent(MotionEvent ev) { 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 void getClippingRect(Rect outClippingRect) { @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 void fling(int velocityY) { 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 void fling(int velocityY) { } 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 void draw(Canvas canvas) { 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; diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java index df520b5e4669f4..413b825ec9f5ec 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java @@ -10,10 +10,12 @@ 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.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. * - *

Note that {@link ReactScrollView} and {@link ReactHorizontalScrollView} are exposed to JS + *

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 void setShowsVerticalScrollIndicator(ReactScrollView view, boolean value) 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 offsets = new ArrayList(); + 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 void setScrollPerfTag(ReactScrollView view, @Nullable String scrollPerfTa 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.