diff --git a/Libraries/Components/ScrollView/ScrollView.js b/Libraries/Components/ScrollView/ScrollView.js index 7c1fc9dca..5848527e6 100644 --- a/Libraries/Components/ScrollView/ScrollView.js +++ b/Libraries/Components/ScrollView/ScrollView.js @@ -246,6 +246,27 @@ var ScrollView = React.createClass({ */ stickyHeaderIndices: PropTypes.arrayOf(PropTypes.number), style: StyleSheetPropType(ViewStylePropTypes), + /** + * 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. Used in combination + * with `snapToAlignment`. + * @platform ios + */ + snapToInterval: PropTypes.number, + /** + * When `snapToInterval` is set, `snapToAlignment` will define the relationship + * of the the snapping to the scroll view. + * - `start` (the default) will align the snap at the left (horizontal) or top (vertical) + * - `center` will align the snap in the center + * - `end` will align the snap at the right (horizontal) or bottom (vertical) + * @platform ios + */ + snapToAlignment: PropTypes.oneOf([ + 'start', // default + 'center', + 'end', + ]), /** * Experimental: When true, offscreen child views (whose `overflow` value is * `hidden`) are removed from their native backing superview when offscreen. @@ -430,6 +451,8 @@ var validAttributes = { scrollsToTop: true, showsHorizontalScrollIndicator: true, showsVerticalScrollIndicator: true, + snapToInterval: true, + snapToAlignment: true, stickyHeaderIndices: {diff: deepDiffer}, scrollEventThrottle: true, zoomScale: true, diff --git a/React/Views/RCTScrollView.h b/React/Views/RCTScrollView.h index e5c1d550a..d44be6faf 100644 --- a/React/Views/RCTScrollView.h +++ b/React/Views/RCTScrollView.h @@ -44,6 +44,8 @@ @property (nonatomic, assign) BOOL automaticallyAdjustContentInsets; @property (nonatomic, assign) NSTimeInterval scrollEventThrottle; @property (nonatomic, assign) BOOL centerContent; +@property (nonatomic, assign) int snapToInterval; +@property (nonatomic, copy) NSString *snapToAlignment; @property (nonatomic, copy) NSIndexSet *stickyHeaderIndices; @end diff --git a/React/Views/RCTScrollView.m b/React/Views/RCTScrollView.m index b502203f0..a2b6f5915 100644 --- a/React/Views/RCTScrollView.m +++ b/React/Views/RCTScrollView.m @@ -620,6 +620,48 @@ RCT_SCROLL_EVENT_HANDLER(scrollViewDidZoom, RCTScrollEventTypeMove) - (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) { + CGFloat snapToIntervalF = (CGFloat)self.snapToInterval; + + // Find which axis to snap + BOOL isHorizontal = (scrollView.contentSize.width > self.frame.size.width); + + // What is the current offset? + CGFloat targetContentOffsetAlongAxis = isHorizontal ? targetContentOffset->x : targetContentOffset->y; + + // Which direction is the scroll travelling? + CGPoint translation = [scrollView.panGestureRecognizer translationInView:scrollView]; + CGFloat translationAlongAxis = isHorizontal ? translation.x : translation.y; + + // Offset based on desired alignment + CGFloat frameLength = isHorizontal ? self.frame.size.width : self.frame.size.height; + CGFloat alignmentOffset = 0.0f; + if ([self.snapToAlignment isEqualToString: @"center"]) { + alignmentOffset = (frameLength * 0.5f) + (snapToIntervalF * 0.5f); + } else if ([self.snapToAlignment isEqualToString: @"end"]) { + alignmentOffset = frameLength; + } + + // Pick snap point based on direction and proximity + NSInteger snapIndex = floor((targetContentOffsetAlongAxis + alignmentOffset) / snapToIntervalF); + snapIndex = (translationAlongAxis < 0) ? snapIndex + 1 : snapIndex; + CGFloat newTargetContentOffset = ( snapIndex * snapToIntervalF ) - alignmentOffset; + + // Set new targetContentOffset + if (isHorizontal) { + targetContentOffset->x = newTargetContentOffset; + } else { + targetContentOffset->y = newTargetContentOffset; + } + } + NSDictionary *userData = @{ @"velocity": @{ @"x": @(velocity.x), @@ -631,6 +673,7 @@ RCT_SCROLL_EVENT_HANDLER(scrollViewDidZoom, RCTScrollEventTypeMove) } }; [_eventDispatcher sendScrollEventWithType:RCTScrollEventTypeEnd reactTag:self.reactTag scrollView:scrollView userData:userData]; + RCT_FORWARD_SCROLL_EVENT(scrollViewWillEndDragging:scrollView withVelocity:velocity targetContentOffset:targetContentOffset); } diff --git a/React/Views/RCTScrollViewManager.m b/React/Views/RCTScrollViewManager.m index 71736a344..18f5eaf1d 100644 --- a/React/Views/RCTScrollViewManager.m +++ b/React/Views/RCTScrollViewManager.m @@ -63,6 +63,8 @@ RCT_EXPORT_VIEW_PROPERTY(scrollEventThrottle, NSTimeInterval) 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(snapToAlignment, NSString) RCT_REMAP_VIEW_PROPERTY(contentOffset, scrollView.contentOffset, CGPoint) - (NSDictionary *)constantsToExport