diff --git a/Libraries/Components/ScrollView/ScrollView.js b/Libraries/Components/ScrollView/ScrollView.js index 423d15235..c4ba8a136 100644 --- a/Libraries/Components/ScrollView/ScrollView.js +++ b/Libraries/Components/ScrollView/ScrollView.js @@ -474,6 +474,22 @@ export type Props = $ReadOnly<{| * Overrides less configurable `pagingEnabled` and `snapToInterval` props. */ snapToOffsets?: ?$ReadOnlyArray, + /** + * Use in conjuction with `snapToOffsets`. By default, the beginning + * of the list counts as a snap offset. Set `snapToStart` to false to disable + * this behavior and allow the list to scroll freely between its start and + * the first `snapToOffsets` offset. + * The default value is true. + */ + snapToStart?: ?boolean, + /** + * Use in conjuction with `snapToOffsets`. By default, the end + * of the list counts as a snap offset. Set `snapToEnd` to false to disable + * this behavior and allow the list to scroll freely between its end and + * the last `snapToOffsets` offset. + * The default value is true. + */ + snapToEnd?: ?boolean, /** * Experimental: When true, offscreen child views (whose `overflow` value is * `hidden`) are removed from their native backing superview when offscreen. @@ -921,6 +937,10 @@ const ScrollView = createReactClass({ ? true : false, DEPRECATED_sendUpdatedChildFrames, + // default to true + snapToStart: this.props.snapToStart !== false, + // default to true + snapToEnd: this.props.snapToEnd !== false, // pagingEnabled is overridden by snapToInterval / snapToOffsets pagingEnabled: Platform.select({ // on iOS, pagingEnabled must be set to false to have snapToInterval / snapToOffsets work diff --git a/React/Views/ScrollView/RCTScrollView.h b/React/Views/ScrollView/RCTScrollView.h index 688d8f62e..e4a8ffa71 100644 --- a/React/Views/ScrollView/RCTScrollView.h +++ b/React/Views/ScrollView/RCTScrollView.h @@ -46,6 +46,8 @@ @property (nonatomic, copy) NSDictionary *maintainVisibleContentPosition; @property (nonatomic, assign) int snapToInterval; @property (nonatomic, copy) NSArray *snapToOffsets; +@property (nonatomic, assign) BOOL snapToStart; +@property (nonatomic, assign) BOOL snapToEnd; @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 5d5f947d0..471b480b6 100644 --- a/React/Views/ScrollView/RCTScrollView.m +++ b/React/Views/ScrollView/RCTScrollView.m @@ -736,6 +736,8 @@ RCT_SCROLL_EVENT_HANDLER(scrollViewDidZoom, onScroll) // Find which axis to snap BOOL isHorizontal = [self isHorizontal:scrollView]; + CGFloat velocityAlongAxis = isHorizontal ? velocity.x : velocity.y; + CGFloat offsetAlongAxis = isHorizontal ? _scrollView.contentOffset.x : _scrollView.contentOffset.y; // Calculate maximum content offset CGSize viewportSize = [self _calculateViewportSize]; @@ -769,9 +771,26 @@ RCT_SCROLL_EVENT_HANDLER(scrollViewDidZoom, onScroll) ? smallerOffset : largerOffset; - // Chose the correct snap offset based on velocity - CGFloat velocityAlongAxis = isHorizontal ? velocity.x : velocity.y; - if (velocityAlongAxis > 0.0) { + CGFloat firstOffset = [[self.snapToOffsets firstObject] floatValue]; + CGFloat lastOffset = [[self.snapToOffsets lastObject] floatValue]; + + // if scrolling after the last snap offset and snapping to the + // end of the list is disabled, then we allow free scrolling + if (!self.snapToEnd && targetOffset >= lastOffset) { + if (offsetAlongAxis >= lastOffset) { + // free scrolling + } else { + // snap to end + targetOffset = lastOffset; + } + } else if (!self.snapToStart && targetOffset <= firstOffset) { + if (offsetAlongAxis <= firstOffset) { + // free scrolling + } else { + // snap to beginning + targetOffset = firstOffset; + } + } else if (velocityAlongAxis > 0.0) { targetOffset = largerOffset; } else if (velocityAlongAxis < 0.0) { targetOffset = smallerOffset; diff --git a/React/Views/ScrollView/RCTScrollViewManager.m b/React/Views/ScrollView/RCTScrollViewManager.m index c03f2ccd8..73d2b8f14 100644 --- a/React/Views/ScrollView/RCTScrollViewManager.m +++ b/React/Views/ScrollView/RCTScrollViewManager.m @@ -82,6 +82,8 @@ 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(snapToStart, BOOL) +RCT_EXPORT_VIEW_PROPERTY(snapToEnd, BOOL) 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 f544aafdb..a17d54ffa 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 @@ -68,6 +68,8 @@ public class ReactHorizontalScrollView extends HorizontalScrollView implements private int mSnapInterval = 0; private float mDecelerationRate = 0.985f; private @Nullable List mSnapOffsets; + private boolean mSnapToStart = true; + private boolean mSnapToEnd = true; private ReactViewBackgroundManager mReactBackgroundManager; public ReactHorizontalScrollView(Context context) { @@ -167,6 +169,14 @@ public class ReactHorizontalScrollView extends HorizontalScrollView implements mSnapOffsets = snapOffsets; } + public void setSnapToStart(boolean snapToStart) { + mSnapToStart = snapToStart; + } + + public void setSnapToEnd(boolean snapToEnd) { + mSnapToEnd = snapToEnd; + } + public void flashScrollIndicators() { awakenScrollBars(); } @@ -473,6 +483,8 @@ public class ReactHorizontalScrollView extends HorizontalScrollView implements int targetOffset = 0; int smallerOffset = 0; int largerOffset = maximumOffset; + int firstOffset = 0; + int lastOffset = 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 @@ -505,6 +517,9 @@ public class ReactHorizontalScrollView extends HorizontalScrollView implements // get the nearest snap points to the target offset if (mSnapOffsets != null) { + firstOffset = mSnapOffsets.get(0); + lastOffset = mSnapOffsets.get(mSnapOffsets.size() - 1); + for (int i = 0; i < mSnapOffsets.size(); i ++) { int offset = mSnapOffsets.get(i); @@ -532,10 +547,35 @@ public class ReactHorizontalScrollView extends HorizontalScrollView implements ? smallerOffset : largerOffset; - // Chose the correct snap offset based on velocity - if (velocityX > 0) { + // if scrolling after the last snap offset and snapping to the + // end of the list is disabled, then we allow free scrolling + int currentOffset = getScrollX(); + if (isRTL) { + currentOffset = maximumOffset - currentOffset; + } + if (!mSnapToEnd && targetOffset >= lastOffset) { + if (currentOffset >= lastOffset) { + // free scrolling + } else { + // snap to end + targetOffset = lastOffset; + } + } else if (!mSnapToStart && targetOffset <= firstOffset) { + if (currentOffset <= firstOffset) { + // free scrolling + } else { + // snap to beginning + targetOffset = firstOffset; + } + } else if (velocityX > 0) { + // when snapping velocity can feel sluggish for slow swipes + velocityX += (int) ((largerOffset - targetOffset) * 10.0); + targetOffset = largerOffset; } else if (velocityX < 0) { + // when snapping velocity can feel sluggish for slow swipes + velocityX -= (int) ((targetOffset - smallerOffset) * 10.0); + targetOffset = smallerOffset; } else { targetOffset = nearestOffset; 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 2f65a7c89..37121f7f2 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 @@ -97,6 +97,16 @@ public class ReactHorizontalScrollViewManager view.setSnapOffsets(offsets); } + @ReactProp(name = "snapToStart") + public void setSnapToStart(ReactHorizontalScrollView view, boolean snapToStart) { + view.setSnapToStart(snapToStart); + } + + @ReactProp(name = "snapToEnd") + public void setSnapToEnd(ReactHorizontalScrollView view, boolean snapToEnd) { + view.setSnapToEnd(snapToEnd); + } + @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 9df87e57e..1ede57949 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 @@ -67,6 +67,8 @@ public class ReactScrollView extends ScrollView implements ReactClippingViewGrou private int mSnapInterval = 0; private float mDecelerationRate = 0.985f; private @Nullable List mSnapOffsets; + private boolean mSnapToStart = true; + private boolean mSnapToEnd = true; private View mContentView; private ReactViewBackgroundManager mReactBackgroundManager; @@ -155,6 +157,14 @@ public class ReactScrollView extends ScrollView implements ReactClippingViewGrou mSnapOffsets = snapOffsets; } + public void setSnapToStart(boolean snapToStart) { + mSnapToStart = snapToStart; + } + + public void setSnapToEnd(boolean snapToEnd) { + mSnapToEnd = snapToEnd; + } + public void flashScrollIndicators() { awakenScrollBars(); } @@ -441,6 +451,8 @@ public class ReactScrollView extends ScrollView implements ReactClippingViewGrou int targetOffset = 0; int smallerOffset = 0; int largerOffset = maximumOffset; + int firstOffset = 0; + int lastOffset = 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 @@ -466,6 +478,9 @@ public class ReactScrollView extends ScrollView implements ReactClippingViewGrou // get the nearest snap points to the target offset if (mSnapOffsets != null) { + firstOffset = mSnapOffsets.get(0); + lastOffset = mSnapOffsets.get(mSnapOffsets.size() - 1); + for (int i = 0; i < mSnapOffsets.size(); i ++) { int offset = mSnapOffsets.get(i); @@ -493,10 +508,31 @@ public class ReactScrollView extends ScrollView implements ReactClippingViewGrou ? smallerOffset : largerOffset; - // Chose the correct snap offset based on velocity - if (velocityY > 0) { + // if scrolling after the last snap offset and snapping to the + // end of the list is disabled, then we allow free scrolling + if (!mSnapToEnd && targetOffset >= lastOffset) { + if (getScrollY() >= lastOffset) { + // free scrolling + } else { + // snap to end + targetOffset = lastOffset; + } + } else if (!mSnapToStart && targetOffset <= firstOffset) { + if (getScrollY() <= firstOffset) { + // free scrolling + } else { + // snap to beginning + targetOffset = firstOffset; + } + } else if (velocityY > 0) { + // when snapping velocity can feel sluggish for slow swipes + velocityY += (int) ((largerOffset - targetOffset) * 10.0); + targetOffset = largerOffset; } else if (velocityY < 0) { + // when snapping velocity can feel sluggish for slow swipes + velocityY -= (int) ((targetOffset - smallerOffset) * 10.0); + targetOffset = smallerOffset; } else { targetOffset = nearestOffset; 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 413b825ec..29196410d 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 @@ -101,6 +101,16 @@ public class ReactScrollViewManager view.setSnapOffsets(offsets); } + @ReactProp(name = "snapToStart") + public void setSnapToStart(ReactScrollView view, boolean snapToStart) { + view.setSnapToStart(snapToStart); + } + + @ReactProp(name = "snapToEnd") + public void setSnapToEnd(ReactScrollView view, boolean snapToEnd) { + view.setSnapToEnd(snapToEnd); + } + @ReactProp(name = ReactClippingViewGroupHelper.PROP_REMOVE_CLIPPED_SUBVIEWS) public void setRemoveClippedSubviews(ReactScrollView view, boolean removeClippedSubviews) { view.setRemoveClippedSubviews(removeClippedSubviews);