Skip to content

Commit 5e7f79f

Browse files
committed
feat: add scroll snapping when focusing
1 parent d75aaf8 commit 5e7f79f

32 files changed

Lines changed: 762 additions & 11 deletions

packages/react-native/Libraries/Components/ScrollView/AndroidHorizontalScrollViewNativeComponent.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = {
5858
borderLeftColor: {
5959
process: require('../../StyleSheet/processColor').default,
6060
},
61+
scrollSnapType: true,
62+
scrollPadding: true,
6163
pointerEvents: true,
6264
},
6365
};

packages/react-native/Libraries/Components/ScrollView/ScrollView.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,8 @@ type ScrollViewBaseProps = $ReadOnly<{
654654
* This can improve scrolling performance on long lists. The default value is
655655
* true.
656656
*/
657+
scrollSnapType?: ?('mandatory' | 'none'),
658+
scrollPadding?: ?number,
657659
removeClippedSubviews?: ?boolean,
658660
/**
659661
* A RefreshControl component, used to provide pull-to-refresh
@@ -789,6 +791,8 @@ class ScrollView extends React.Component<ScrollViewProps, ScrollViewState> {
789791
);
790792
}
791793

794+
this._warnIfScrollSnapConflict();
795+
792796
this._keyboardMetrics = Keyboard.metrics();
793797
this._additionalScrollOffset = 0;
794798

@@ -823,6 +827,8 @@ class ScrollView extends React.Component<ScrollViewProps, ScrollViewState> {
823827
this._scrollAnimatedValue.setOffset(newContentInsetTop || 0);
824828
}
825829

830+
this._warnIfScrollSnapConflict(prevProps);
831+
826832
this._updateAnimatedNodeAttachment();
827833
}
828834

@@ -1018,6 +1024,31 @@ class ScrollView extends React.Component<ScrollViewProps, ScrollViewState> {
10181024
Commands.zoomToRect(component, rect, animated !== false);
10191025
};
10201026

1027+
_warnIfScrollSnapConflict(prevProps?: ScrollViewProps) {
1028+
const hasConflict =
1029+
this.props.scrollSnapType != null &&
1030+
(this.props.snapToInterval != null ||
1031+
this.props.snapToAlignment != null);
1032+
if (!hasConflict) {
1033+
return;
1034+
}
1035+
// On update, only warn when the conflict is newly introduced.
1036+
if (
1037+
prevProps != null &&
1038+
prevProps.scrollSnapType != null &&
1039+
(prevProps.snapToInterval != null || prevProps.snapToAlignment != null)
1040+
) {
1041+
return;
1042+
}
1043+
console.warn(
1044+
'ScrollView: `scrollSnapType` should not be used together with ' +
1045+
'`snapToInterval` or `snapToAlignment`. `scrollSnapType` provides ' +
1046+
'focus-driven snapping for TV, while `snapToInterval`/' +
1047+
'`snapToAlignment` provide momentum-based snapping. Using both ' +
1048+
'may cause unexpected scrolling behavior.',
1049+
);
1050+
}
1051+
10211052
_textInputFocusError() {
10221053
console.warn('Error measuring text field.');
10231054
}

packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponent.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig =
8686
borderLeftColor: {
8787
process: require('../../StyleSheet/processColor').default,
8888
},
89+
scrollSnapType: true,
90+
scrollPadding: true,
8991
pointerEvents: true,
9092
isInvertedVirtualizedList: true,
9193
},
@@ -152,6 +154,8 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig =
152154
showsHorizontalScrollIndicator: true,
153155
showsVerticalScrollIndicator: true,
154156
showsScrollIndex: true,
157+
scrollSnapType: true,
158+
scrollPadding: true,
155159
snapToAlignment: true,
156160
snapToEnd: true,
157161
snapToInterval: true,

packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponentType.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ export type ScrollViewNativeProps = $ReadOnly<{
6969
scrollPerfTag?: ?string,
7070
scrollToOverflowEnabled?: ?boolean,
7171
scrollsToTop?: ?boolean,
72+
scrollSnapType?: ?('mandatory' | 'none'),
73+
scrollPadding?: ?number,
7274
sendMomentumEvents?: ?boolean,
7375
showsHorizontalScrollIndicator?: ?boolean,
7476
showsScrollIndex?: ?boolean,

packages/react-native/Libraries/Components/TV/TVViewPropTypes.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,4 +124,11 @@ export type TVViewProps = $ReadOnly<{|
124124
*
125125
*/
126126
nextFocusUp?: ?number,
127+
128+
/**
129+
* Scroll snap alignment (used with scrollSnapType in ScrollView).
130+
*
131+
*/
132+
scrollSnapAlign?: ?('start' | 'center' | 'end'),
133+
127134
|}>;

packages/react-native/Libraries/Components/View/View.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,12 @@ component View(
153153
delete processedProps.isTVSelectable;
154154
}
155155

156+
// Views with scrollSnapAlign must not be flattened by Fabric, otherwise
157+
// the prop never reaches the native view and scroll snapping breaks.
158+
if (processedProps.scrollSnapAlign != null) {
159+
processedProps.collapsable = false;
160+
}
161+
156162
const actualView =
157163
ref == null ? (
158164
<ViewNativeComponent {...processedProps} />

packages/react-native/Libraries/NativeComponent/TVViewConfig.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,6 @@ export const validAttributesForTVProps = {
2525
trapFocusRight: true,
2626
trapFocusDown: true,
2727
trapFocusUp: true,
28+
scrollSnapAlign: true,
2829
};
2930

packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ NS_ASSUME_NONNULL_BEGIN
5151
@property (nonatomic, assign) BOOL snapToStart;
5252
@property (nonatomic, assign) BOOL snapToEnd;
5353
@property (nonatomic, copy) NSArray<NSNumber *> *snapToOffsets;
54+
@property (nonatomic, assign) BOOL scrollSnapEnabled;
5455

5556
/*
5657
* Makes `setContentOffset:` method no-op when given `block` is executed.

packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,9 @@ - (void)setPrivateDelegate:(id<UIScrollViewDelegate>)delegate
132132

133133
- (id<UIScrollViewDelegate>)delegate
134134
{
135+
if (_scrollSnapEnabled) {
136+
return [super delegate];
137+
}
135138
return _publicDelegate;
136139
}
137140

packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm

Lines changed: 124 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
#import <React/RCTTVRemoteHandler.h>
3131
#import <React/RCTTVNavigationEventNotification.h>
3232
#import "React/RCTI18nUtil.h"
33+
#import "RCTViewComponentView.h"
3334
#endif
3435

3536
using namespace facebook::react;
@@ -40,6 +41,7 @@
4041

4142
static const CGFloat kClippingLeeway = 44.0;
4243
static const float TV_DEFAULT_SWIPE_DURATION = 0.3;
44+
static const CGPoint NO_PREFERRED_CONTENT_OFFSET = CGPointMake(CGFLOAT_MIN, CGFLOAT_MIN);
4345

4446
static UIScrollViewKeyboardDismissMode RCTUIKeyboardDismissModeFromProps(const ScrollViewProps &props)
4547
{
@@ -97,6 +99,8 @@ @interface RCTScrollViewComponentView () <
9799
RCTScrollableProtocol,
98100
RCTEnhancedScrollViewOverridingDelegate>
99101

102+
@property (nonatomic, assign) CGPoint preferredContentOffset;
103+
100104
@end
101105

102106
@implementation RCTScrollViewComponentView {
@@ -172,6 +176,7 @@ - (instancetype)initWithFrame:(CGRect)frame
172176
_endDraggingSensitivityMultiplier = 1;
173177

174178
_tvRemoteGestureRecognizers = [NSMutableDictionary new];
179+
_preferredContentOffset = NO_PREFERRED_CONTENT_OFFSET;
175180
}
176181

177182
return self;
@@ -465,6 +470,13 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
465470
scrollView.keyboardDismissMode = RCTUIKeyboardDismissModeFromProps(newScrollViewProps);
466471
}
467472

473+
#if TARGET_OS_TV
474+
if (oldScrollViewProps.scrollSnapType != newScrollViewProps.scrollSnapType) {
475+
scrollView.scrollSnapEnabled = newScrollViewProps.scrollSnapType.has_value() &&
476+
newScrollViewProps.scrollSnapType.value() == "mandatory";
477+
}
478+
#endif
479+
468480
[super updateProps:props oldProps:oldProps];
469481
}
470482

@@ -722,7 +734,10 @@ - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView
722734
withVelocity:(CGPoint)velocity
723735
targetContentOffset:(inout CGPoint *)targetContentOffset
724736
{
725-
if (fabs(_endDraggingSensitivityMultiplier - 1) > 0.0001f) {
737+
if (!CGPointEqualToPoint(self.preferredContentOffset, NO_PREFERRED_CONTENT_OFFSET))
738+
{
739+
*targetContentOffset = self.preferredContentOffset;
740+
} else if (fabs(_endDraggingSensitivityMultiplier - 1) > 0.0001f) {
726741
if (targetContentOffset->y > 0) {
727742
const CGFloat travel = targetContentOffset->y - scrollView.contentOffset.y;
728743
targetContentOffset->y = scrollView.contentOffset.y + travel * _endDraggingSensitivityMultiplier;
@@ -974,6 +989,18 @@ - (void)scrollToEnd:(BOOL)animated
974989
[self scrollToOffset:offset animated:animated];
975990
}
976991

992+
// Custom animation curve
993+
- (void)setContentOffset:(CGPoint)offset animated:(BOOL)animated
994+
{
995+
if (animated) {
996+
[UIView animateWithDuration:0.8 delay:0 usingSpringWithDamping:1.0 initialSpringVelocity:0 options:UIViewAnimationOptionCurveEaseInOut animations:^{
997+
[_scrollView setContentOffset:offset animated:NO];
998+
} completion:nil];
999+
} else {
1000+
[_scrollView setContentOffset:offset animated:NO];
1001+
}
1002+
}
1003+
9771004
#pragma mark - Child views mounting
9781005

9791006
- (void)updateClippedSubviewsWithClipRect:(CGRect)clipRect relativeToView:(UIView *)clipView
@@ -1034,7 +1061,7 @@ - (void)scrollToOffset:(CGPoint)offset animated:(BOOL)animated
10341061

10351062
[self _forceDispatchNextScrollEvent];
10361063

1037-
[_scrollView setContentOffset:offset animated:animated];
1064+
[self setContentOffset:offset animated:animated];
10381065

10391066
if (!animated) {
10401067
// When not animated, the expected workflow in ``scrollViewDidEndScrollingAnimation`` after scrolling is not going
@@ -1137,19 +1164,109 @@ - (void)_adjustForMaintainVisibleContentPosition
11371164
#pragma mark Apple TV swipe and focus handling
11381165

11391166
#if TARGET_OS_TV
1167+
// Focus marker helper: traverses view hierarchy to find scrollSnapAlign prop
1168+
// Returns the view that has the property via the output parameter
1169+
- (NSString *)findScrollSnapAlignInView:(UIView *)view foundView:(UIView **)outView
1170+
{
1171+
UIView *testView = view;
1172+
UIView *snapTarget;
1173+
NSString *marker;
1174+
1175+
while (testView && testView != self) {
1176+
if (![testView isKindOfClass:RCTViewComponentView.class])
1177+
{
1178+
testView = [testView superview];
1179+
continue;
1180+
}
1181+
RCTViewComponentView *componentView = (RCTViewComponentView *)testView;
1182+
1183+
const auto &viewProps = static_cast<const facebook::react::BaseViewProps &>(*componentView.props);
1184+
if (viewProps.scrollSnapAlign.has_value() && !viewProps.scrollSnapAlign.value().empty()) {
1185+
marker = [NSString stringWithUTF8String:viewProps.scrollSnapAlign.value().c_str()];
1186+
snapTarget = componentView;
1187+
}
1188+
1189+
testView = [testView superview];
1190+
}
1191+
*outView = snapTarget;
1192+
return marker;
1193+
}
1194+
1195+
- (void)_handleScrollSnapForFocusedView:(UIView *)focusedView
1196+
{
1197+
const auto &scrollProps = static_cast<const ScrollViewProps &>(*_props);
1198+
UIView *snapAlignView = nil;
1199+
NSString *scrollSnapAlign = [self findScrollSnapAlignInView:focusedView foundView:&snapAlignView];
1200+
if (scrollSnapAlign == nil || snapAlignView == nil) {
1201+
return;
1202+
}
1203+
1204+
RCTEnhancedScrollView *scrollView = (RCTEnhancedScrollView *)_scrollView;
1205+
CGRect focusedFrame = [snapAlignView convertRect:snapAlignView.bounds toView:_scrollView];
1206+
CGFloat targetOffset;
1207+
CGFloat scrollPadding = scrollProps.scrollPadding;
1208+
1209+
BOOL isHorizontalSnap = _scrollView.contentSize.width > self.frame.size.width;
1210+
// Determine axis-specific properties
1211+
CGFloat viewportSize, focusedOrigin, focusedSize, currentOffset, maxContentSize;
1212+
if (isHorizontalSnap) {
1213+
viewportSize = scrollView.bounds.size.width;
1214+
focusedOrigin = focusedFrame.origin.x;
1215+
focusedSize = focusedFrame.size.width;
1216+
currentOffset = scrollView.contentOffset.x;
1217+
maxContentSize = scrollView.contentSize.width;
1218+
} else {
1219+
viewportSize = scrollView.bounds.size.height;
1220+
focusedOrigin = focusedFrame.origin.y;
1221+
focusedSize = focusedFrame.size.height;
1222+
currentOffset = scrollView.contentOffset.y;
1223+
maxContentSize = scrollView.contentSize.height;
1224+
}
1225+
// Calculate target offset based on scrollSnapAlign (unified for both axes)
1226+
if ([scrollSnapAlign isEqualToString:@"start"]) {
1227+
targetOffset = focusedOrigin - scrollPadding;
1228+
} else if ([scrollSnapAlign isEqualToString:@"center"]) {
1229+
CGFloat viewportCenter = viewportSize / 2;
1230+
CGFloat focusedCenter = focusedOrigin + (focusedSize / 2);
1231+
targetOffset = focusedCenter - viewportCenter + (scrollPadding / 2);
1232+
} else if ([scrollSnapAlign isEqualToString:@"end"]) {
1233+
targetOffset = (focusedOrigin + focusedSize) - viewportSize + scrollPadding;
1234+
} else {
1235+
targetOffset = currentOffset;
1236+
}
1237+
1238+
// Apply snap-to-interval if configured
1239+
if (scrollView.snapToInterval > 0) {
1240+
CGFloat interval = scrollView.snapToInterval;
1241+
targetOffset = floor(targetOffset / interval) * interval;
1242+
}
1243+
1244+
// Clamp to valid range
1245+
CGFloat maxOffset = MAX(maxContentSize - viewportSize, 0);
1246+
targetOffset = MAX(0, MIN(targetOffset, maxOffset));
1247+
CGPoint targetContentOffset = isHorizontalSnap
1248+
? CGPointMake(targetOffset, scrollView.contentOffset.y)
1249+
: CGPointMake(scrollView.contentOffset.x, targetOffset);
1250+
self.preferredContentOffset = targetContentOffset;
1251+
[self setContentOffset:targetContentOffset animated:YES];
1252+
}
1253+
11401254
- (void)didUpdateFocusInContext:(UIFocusUpdateContext *)context
11411255
withAnimationCoordinator:(UIFocusAnimationCoordinator *)coordinator
11421256
{
1143-
if (context.previouslyFocusedView == context.nextFocusedView || !_props->isTVSelectable) {
1257+
self.preferredContentOffset = NO_PREFERRED_CONTENT_OFFSET;
1258+
const auto &scrollProps = static_cast<const ScrollViewProps &>(*_props);
1259+
BOOL hasScrollSnapType = scrollProps.scrollSnapType.has_value() && scrollProps.scrollSnapType.value() == "mandatory";
1260+
if (context.previouslyFocusedView == context.nextFocusedView || (!_props->isTVSelectable && !hasScrollSnapType)) {
11441261
return;
11451262
}
1146-
if (context.nextFocusedView == self) {
1263+
if (_props->isTVSelectable && context.nextFocusedView == self) {
11471264
[self becomeFirstResponder];
11481265
[self addSwipeGestureRecognizers];
11491266
// if we enter the scroll view from different view then block first touch event since it is the event that triggered the focus
11501267
_blockFirstTouch = (unsigned long)context.focusHeading != 0;
11511268
[self addArrowsListeners];
1152-
} else if (context.previouslyFocusedView == self) {
1269+
} else if (_props->isTVSelectable && context.previouslyFocusedView == self) {
11531270
[self removeArrowsListeners];
11541271
[self removeSwipeGestureRecognizers];
11551272
[self resignFirstResponder];
@@ -1171,6 +1288,8 @@ - (void)didUpdateFocusInContext:(UIFocusUpdateContext *)context
11711288
[self scrollToHorizontalOffset:scrollView.contentSize.width];
11721289
}
11731290
}
1291+
} else if ([context.nextFocusedView isDescendantOfView:_scrollView]) {
1292+
[self _handleScrollSnapForFocusedView:context.nextFocusedView];
11741293
}
11751294
}
11761295

0 commit comments

Comments
 (0)