ScrollView snapToStart/snapToEnd

Summary: Added `snapToStart` and `snapToEnd` props to ScrollView which work together with `snapToOffsets` and determine whether the beginning and end of the list automatically count as snap offsets or not. If not, the list is allowed to free-scroll between its start/end and the first/last snap offset.

Reviewed By: sahrens

Differential Revision: D9442386

fbshipit-source-id: 47a5fdb20f884542434b01b1f0a486ed2b478c6e
This commit is contained in:
Oleg Lokhvitsky 2018-08-30 12:59:37 -07:00 committed by Facebook Github Bot
parent fd744dd56c
commit 5f48d28119
8 changed files with 146 additions and 7 deletions

View File

@ -474,6 +474,22 @@ export type Props = $ReadOnly<{|
* Overrides less configurable `pagingEnabled` and `snapToInterval` props. * Overrides less configurable `pagingEnabled` and `snapToInterval` props.
*/ */
snapToOffsets?: ?$ReadOnlyArray<number>, snapToOffsets?: ?$ReadOnlyArray<number>,
/**
* 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 * Experimental: When true, offscreen child views (whose `overflow` value is
* `hidden`) are removed from their native backing superview when offscreen. * `hidden`) are removed from their native backing superview when offscreen.
@ -921,6 +937,10 @@ const ScrollView = createReactClass({
? true ? true
: false, : false,
DEPRECATED_sendUpdatedChildFrames, 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 is overridden by snapToInterval / snapToOffsets
pagingEnabled: Platform.select({ pagingEnabled: Platform.select({
// on iOS, pagingEnabled must be set to false to have snapToInterval / snapToOffsets work // on iOS, pagingEnabled must be set to false to have snapToInterval / snapToOffsets work

View File

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

View File

@ -736,6 +736,8 @@ RCT_SCROLL_EVENT_HANDLER(scrollViewDidZoom, onScroll)
// Find which axis to snap // Find which axis to snap
BOOL isHorizontal = [self isHorizontal:scrollView]; 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 // Calculate maximum content offset
CGSize viewportSize = [self _calculateViewportSize]; CGSize viewportSize = [self _calculateViewportSize];
@ -769,9 +771,26 @@ RCT_SCROLL_EVENT_HANDLER(scrollViewDidZoom, onScroll)
? smallerOffset ? smallerOffset
: largerOffset; : largerOffset;
// Chose the correct snap offset based on velocity CGFloat firstOffset = [[self.snapToOffsets firstObject] floatValue];
CGFloat velocityAlongAxis = isHorizontal ? velocity.x : velocity.y; CGFloat lastOffset = [[self.snapToOffsets lastObject] floatValue];
if (velocityAlongAxis > 0.0) {
// 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; targetOffset = largerOffset;
} else if (velocityAlongAxis < 0.0) { } else if (velocityAlongAxis < 0.0) {
targetOffset = smallerOffset; targetOffset = smallerOffset;

View File

@ -82,6 +82,8 @@ RCT_EXPORT_VIEW_PROPERTY(contentInset, UIEdgeInsets)
RCT_EXPORT_VIEW_PROPERTY(scrollIndicatorInsets, UIEdgeInsets) RCT_EXPORT_VIEW_PROPERTY(scrollIndicatorInsets, UIEdgeInsets)
RCT_EXPORT_VIEW_PROPERTY(snapToInterval, int) RCT_EXPORT_VIEW_PROPERTY(snapToInterval, int)
RCT_EXPORT_VIEW_PROPERTY(snapToOffsets, NSArray<NSNumber *>) RCT_EXPORT_VIEW_PROPERTY(snapToOffsets, NSArray<NSNumber *>)
RCT_EXPORT_VIEW_PROPERTY(snapToStart, BOOL)
RCT_EXPORT_VIEW_PROPERTY(snapToEnd, BOOL)
RCT_EXPORT_VIEW_PROPERTY(snapToAlignment, NSString) RCT_EXPORT_VIEW_PROPERTY(snapToAlignment, NSString)
RCT_REMAP_VIEW_PROPERTY(contentOffset, scrollView.contentOffset, CGPoint) RCT_REMAP_VIEW_PROPERTY(contentOffset, scrollView.contentOffset, CGPoint)
RCT_EXPORT_VIEW_PROPERTY(onScrollBeginDrag, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(onScrollBeginDrag, RCTDirectEventBlock)

View File

@ -68,6 +68,8 @@ public class ReactHorizontalScrollView extends HorizontalScrollView implements
private int mSnapInterval = 0; private int mSnapInterval = 0;
private float mDecelerationRate = 0.985f; private float mDecelerationRate = 0.985f;
private @Nullable List<Integer> mSnapOffsets; private @Nullable List<Integer> mSnapOffsets;
private boolean mSnapToStart = true;
private boolean mSnapToEnd = true;
private ReactViewBackgroundManager mReactBackgroundManager; private ReactViewBackgroundManager mReactBackgroundManager;
public ReactHorizontalScrollView(Context context) { public ReactHorizontalScrollView(Context context) {
@ -167,6 +169,14 @@ public class ReactHorizontalScrollView extends HorizontalScrollView implements
mSnapOffsets = snapOffsets; mSnapOffsets = snapOffsets;
} }
public void setSnapToStart(boolean snapToStart) {
mSnapToStart = snapToStart;
}
public void setSnapToEnd(boolean snapToEnd) {
mSnapToEnd = snapToEnd;
}
public void flashScrollIndicators() { public void flashScrollIndicators() {
awakenScrollBars(); awakenScrollBars();
} }
@ -473,6 +483,8 @@ public class ReactHorizontalScrollView extends HorizontalScrollView implements
int targetOffset = 0; int targetOffset = 0;
int smallerOffset = 0; int smallerOffset = 0;
int largerOffset = maximumOffset; int largerOffset = maximumOffset;
int firstOffset = 0;
int lastOffset = maximumOffset;
// ScrollView can *only* scroll for 250ms when using smoothScrollTo and there's // 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 // 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 // get the nearest snap points to the target offset
if (mSnapOffsets != null) { if (mSnapOffsets != null) {
firstOffset = mSnapOffsets.get(0);
lastOffset = mSnapOffsets.get(mSnapOffsets.size() - 1);
for (int i = 0; i < mSnapOffsets.size(); i ++) { for (int i = 0; i < mSnapOffsets.size(); i ++) {
int offset = mSnapOffsets.get(i); int offset = mSnapOffsets.get(i);
@ -532,10 +547,35 @@ public class ReactHorizontalScrollView extends HorizontalScrollView implements
? smallerOffset ? smallerOffset
: largerOffset; : largerOffset;
// Chose the correct snap offset based on velocity // if scrolling after the last snap offset and snapping to the
if (velocityX > 0) { // 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; targetOffset = largerOffset;
} else if (velocityX < 0) { } else if (velocityX < 0) {
// when snapping velocity can feel sluggish for slow swipes
velocityX -= (int) ((targetOffset - smallerOffset) * 10.0);
targetOffset = smallerOffset; targetOffset = smallerOffset;
} else { } else {
targetOffset = nearestOffset; targetOffset = nearestOffset;

View File

@ -97,6 +97,16 @@ public class ReactHorizontalScrollViewManager
view.setSnapOffsets(offsets); 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) @ReactProp(name = ReactClippingViewGroupHelper.PROP_REMOVE_CLIPPED_SUBVIEWS)
public void setRemoveClippedSubviews(ReactHorizontalScrollView view, boolean removeClippedSubviews) { public void setRemoveClippedSubviews(ReactHorizontalScrollView view, boolean removeClippedSubviews) {
view.setRemoveClippedSubviews(removeClippedSubviews); view.setRemoveClippedSubviews(removeClippedSubviews);

View File

@ -67,6 +67,8 @@ public class ReactScrollView extends ScrollView implements ReactClippingViewGrou
private int mSnapInterval = 0; private int mSnapInterval = 0;
private float mDecelerationRate = 0.985f; private float mDecelerationRate = 0.985f;
private @Nullable List<Integer> mSnapOffsets; private @Nullable List<Integer> mSnapOffsets;
private boolean mSnapToStart = true;
private boolean mSnapToEnd = true;
private View mContentView; private View mContentView;
private ReactViewBackgroundManager mReactBackgroundManager; private ReactViewBackgroundManager mReactBackgroundManager;
@ -155,6 +157,14 @@ public class ReactScrollView extends ScrollView implements ReactClippingViewGrou
mSnapOffsets = snapOffsets; mSnapOffsets = snapOffsets;
} }
public void setSnapToStart(boolean snapToStart) {
mSnapToStart = snapToStart;
}
public void setSnapToEnd(boolean snapToEnd) {
mSnapToEnd = snapToEnd;
}
public void flashScrollIndicators() { public void flashScrollIndicators() {
awakenScrollBars(); awakenScrollBars();
} }
@ -441,6 +451,8 @@ public class ReactScrollView extends ScrollView implements ReactClippingViewGrou
int targetOffset = 0; int targetOffset = 0;
int smallerOffset = 0; int smallerOffset = 0;
int largerOffset = maximumOffset; int largerOffset = maximumOffset;
int firstOffset = 0;
int lastOffset = maximumOffset;
// ScrollView can *only* scroll for 250ms when using smoothScrollTo and there's // 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 // 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 // get the nearest snap points to the target offset
if (mSnapOffsets != null) { if (mSnapOffsets != null) {
firstOffset = mSnapOffsets.get(0);
lastOffset = mSnapOffsets.get(mSnapOffsets.size() - 1);
for (int i = 0; i < mSnapOffsets.size(); i ++) { for (int i = 0; i < mSnapOffsets.size(); i ++) {
int offset = mSnapOffsets.get(i); int offset = mSnapOffsets.get(i);
@ -493,10 +508,31 @@ public class ReactScrollView extends ScrollView implements ReactClippingViewGrou
? smallerOffset ? smallerOffset
: largerOffset; : largerOffset;
// Chose the correct snap offset based on velocity // if scrolling after the last snap offset and snapping to the
if (velocityY > 0) { // 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; targetOffset = largerOffset;
} else if (velocityY < 0) { } else if (velocityY < 0) {
// when snapping velocity can feel sluggish for slow swipes
velocityY -= (int) ((targetOffset - smallerOffset) * 10.0);
targetOffset = smallerOffset; targetOffset = smallerOffset;
} else { } else {
targetOffset = nearestOffset; targetOffset = nearestOffset;

View File

@ -101,6 +101,16 @@ public class ReactScrollViewManager
view.setSnapOffsets(offsets); 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) @ReactProp(name = ReactClippingViewGroupHelper.PROP_REMOVE_CLIPPED_SUBVIEWS)
public void setRemoveClippedSubviews(ReactScrollView view, boolean removeClippedSubviews) { public void setRemoveClippedSubviews(ReactScrollView view, boolean removeClippedSubviews) {
view.setRemoveClippedSubviews(removeClippedSubviews); view.setRemoveClippedSubviews(removeClippedSubviews);