Skip to content

Commit 5d2a818

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

23 files changed

Lines changed: 495 additions & 11 deletions

File tree

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/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/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.mm

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ - (void)setPrivateDelegate:(id<UIScrollViewDelegate>)delegate
132132

133133
- (id<UIScrollViewDelegate>)delegate
134134
{
135-
return _publicDelegate;
135+
return [super delegate];
136136
}
137137

138138
- (void)setDelegate:(id<UIScrollViewDelegate>)delegate

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

Lines changed: 110 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;
@@ -722,7 +727,10 @@ - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView
722727
withVelocity:(CGPoint)velocity
723728
targetContentOffset:(inout CGPoint *)targetContentOffset
724729
{
725-
if (fabs(_endDraggingSensitivityMultiplier - 1) > 0.0001f) {
730+
if (!CGPointEqualToPoint(self.preferredContentOffset, NO_PREFERRED_CONTENT_OFFSET))
731+
{
732+
*targetContentOffset = self.preferredContentOffset;
733+
} else if (fabs(_endDraggingSensitivityMultiplier - 1) > 0.0001f) {
726734
if (targetContentOffset->y > 0) {
727735
const CGFloat travel = targetContentOffset->y - scrollView.contentOffset.y;
728736
targetContentOffset->y = scrollView.contentOffset.y + travel * _endDraggingSensitivityMultiplier;
@@ -974,6 +982,18 @@ - (void)scrollToEnd:(BOOL)animated
974982
[self scrollToOffset:offset animated:animated];
975983
}
976984

985+
// Custom animation curve
986+
- (void)setContentOffset:(CGPoint)offset animated:(BOOL)animated
987+
{
988+
if (animated) {
989+
[UIView animateWithDuration:0.8 delay:0 usingSpringWithDamping:1.0 initialSpringVelocity:0 options:UIViewAnimationOptionCurveEaseInOut animations:^{
990+
[_scrollView setContentOffset:offset animated:NO];
991+
} completion:nil];
992+
} else {
993+
[_scrollView setContentOffset:offset animated:NO];
994+
}
995+
}
996+
977997
#pragma mark - Child views mounting
978998

979999
- (void)updateClippedSubviewsWithClipRect:(CGRect)clipRect relativeToView:(UIView *)clipView
@@ -1034,7 +1054,7 @@ - (void)scrollToOffset:(CGPoint)offset animated:(BOOL)animated
10341054

10351055
[self _forceDispatchNextScrollEvent];
10361056

1037-
[_scrollView setContentOffset:offset animated:animated];
1057+
[self setContentOffset:offset animated:animated];
10381058

10391059
if (!animated) {
10401060
// When not animated, the expected workflow in ``scrollViewDidEndScrollingAnimation`` after scrolling is not going
@@ -1137,19 +1157,50 @@ - (void)_adjustForMaintainVisibleContentPosition
11371157
#pragma mark Apple TV swipe and focus handling
11381158

11391159
#if TARGET_OS_TV
1160+
// Focus marker helper: traverses view hierarchy to find scrollSnapAlign prop
1161+
// Returns the view that has the property via the output parameter
1162+
- (NSString *)findScrollSnapAlignInView:(UIView *)view foundView:(UIView **)outView
1163+
{
1164+
UIView *testView = view;
1165+
UIView *snapTarget;
1166+
NSString *marker;
1167+
1168+
while (testView && testView != self) {
1169+
if (![testView isKindOfClass:RCTViewComponentView.class])
1170+
{
1171+
testView = [testView superview];
1172+
continue;
1173+
}
1174+
RCTViewComponentView *componentView = (RCTViewComponentView *)testView;
1175+
1176+
const auto &viewProps = static_cast<const facebook::react::BaseViewProps &>(*componentView.props);
1177+
if (viewProps.scrollSnapAlign.has_value() && !viewProps.scrollSnapAlign.value().empty()) {
1178+
marker = [NSString stringWithUTF8String:viewProps.scrollSnapAlign.value().c_str()];
1179+
snapTarget = componentView;
1180+
}
1181+
1182+
testView = [testView superview];
1183+
}
1184+
*outView = snapTarget;
1185+
return marker;
1186+
}
1187+
11401188
- (void)didUpdateFocusInContext:(UIFocusUpdateContext *)context
11411189
withAnimationCoordinator:(UIFocusAnimationCoordinator *)coordinator
11421190
{
1143-
if (context.previouslyFocusedView == context.nextFocusedView || !_props->isTVSelectable) {
1191+
self.preferredContentOffset = NO_PREFERRED_CONTENT_OFFSET;
1192+
const auto &scrollProps = static_cast<const ScrollViewProps &>(*_props);
1193+
BOOL hasScrollSnapType = scrollProps.scrollSnapType.has_value() && scrollProps.scrollSnapType.value() == "mandatory";
1194+
if (context.previouslyFocusedView == context.nextFocusedView || (!_props->isTVSelectable && !hasScrollSnapType)) {
11441195
return;
11451196
}
1146-
if (context.nextFocusedView == self) {
1197+
if (_props->isTVSelectable && context.nextFocusedView == self) {
11471198
[self becomeFirstResponder];
11481199
[self addSwipeGestureRecognizers];
11491200
// if we enter the scroll view from different view then block first touch event since it is the event that triggered the focus
11501201
_blockFirstTouch = (unsigned long)context.focusHeading != 0;
11511202
[self addArrowsListeners];
1152-
} else if (context.previouslyFocusedView == self) {
1203+
} else if (_props->isTVSelectable && context.previouslyFocusedView == self) {
11531204
[self removeArrowsListeners];
11541205
[self removeSwipeGestureRecognizers];
11551206
[self resignFirstResponder];
@@ -1171,6 +1222,60 @@ - (void)didUpdateFocusInContext:(UIFocusUpdateContext *)context
11711222
[self scrollToHorizontalOffset:scrollView.contentSize.width];
11721223
}
11731224
}
1225+
} else if ([context.nextFocusedView isDescendantOfView:_scrollView]) {
1226+
// Focus marker-based scrolling: Check if the focused view has a scrollSnapAlign
1227+
UIView *snapAlignView = nil;
1228+
NSString *scrollSnapAlign = [self findScrollSnapAlignInView:context.nextFocusedView foundView:&snapAlignView];
1229+
if (scrollSnapAlign != nil && snapAlignView != nil) {
1230+
RCTEnhancedScrollView *scrollView = (RCTEnhancedScrollView *)_scrollView;
1231+
CGRect focusedFrame = [snapAlignView convertRect:snapAlignView.bounds toView:_scrollView];
1232+
CGFloat targetOffset;
1233+
CGFloat scrollPadding = scrollProps.scrollPadding;
1234+
1235+
BOOL isHorizontalSnap = _scrollView.contentSize.width > self.frame.size.width;
1236+
// Determine axis-specific properties
1237+
CGFloat viewportSize, focusedOrigin, focusedSize, currentOffset, maxContentSize;
1238+
if (isHorizontalSnap) {
1239+
viewportSize = scrollView.bounds.size.width;
1240+
focusedOrigin = focusedFrame.origin.x;
1241+
focusedSize = focusedFrame.size.width;
1242+
currentOffset = scrollView.contentOffset.x;
1243+
maxContentSize = scrollView.contentSize.width;
1244+
} else {
1245+
viewportSize = scrollView.bounds.size.height;
1246+
focusedOrigin = focusedFrame.origin.y;
1247+
focusedSize = focusedFrame.size.height;
1248+
currentOffset = scrollView.contentOffset.y;
1249+
maxContentSize = scrollView.contentSize.height;
1250+
}
1251+
// Calculate target offset based on scrollSnapAlign (unified for both axes)
1252+
if ([scrollSnapAlign isEqualToString:@"start"]) {
1253+
targetOffset = focusedOrigin - scrollPadding;
1254+
} else if ([scrollSnapAlign isEqualToString:@"center"]) {
1255+
CGFloat viewportCenter = viewportSize / 2;
1256+
CGFloat focusedCenter = focusedOrigin + (focusedSize / 2);
1257+
targetOffset = focusedCenter - viewportCenter + (scrollPadding / 2);
1258+
} else if ([scrollSnapAlign isEqualToString:@"end"]) {
1259+
targetOffset = (focusedOrigin + focusedSize) - viewportSize + scrollPadding;
1260+
} else {
1261+
targetOffset = currentOffset;
1262+
}
1263+
1264+
// Apply snap-to-interval if configured
1265+
if (scrollView.snapToInterval > 0) {
1266+
CGFloat interval = scrollView.snapToInterval;
1267+
targetOffset = floor(targetOffset / interval) * interval;
1268+
}
1269+
1270+
// Clamp to valid range
1271+
CGFloat maxOffset = MAX(maxContentSize - viewportSize, 0);
1272+
targetOffset = MAX(0, MIN(targetOffset, maxOffset));
1273+
CGPoint targetContentOffset = isHorizontalSnap
1274+
? CGPointMake(targetOffset, scrollView.contentOffset.y)
1275+
: CGPointMake(scrollView.contentOffset.x, targetOffset);
1276+
self.preferredContentOffset = targetContentOffset;
1277+
[self setContentOffset:targetContentOffset animated:YES];
1278+
}
11741279
}
11751280
}
11761281

packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ NS_ASSUME_NONNULL_BEGIN
8080
@property(nonatomic, nullable) UIFocusGuide *focusGuideLeft;
8181
@property(nonatomic, nullable) UIFocusGuide *focusGuideRight;
8282
@property(nonatomic, nullable, strong) RCTTVRemoteSelectHandler *tvRemoteSelectHandler;
83+
84+
- (NSString *)scrollSnapAlign;
8385
#endif
8486

8587
/**

packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ @implementation RCTViewComponentView {
8787
BOOL _trapFocusRight;
8888
NSArray* _focusDestinations;
8989
id<UIFocusItem> _previouslyFocusedItem;
90+
NSString *_scrollSnapAlign;
9091
RCTSwiftUIContainerViewWrapper *_swiftUIWrapper;
9192
BOOL _shouldFocusOnMount;
9293
}
@@ -441,6 +442,13 @@ - (void)removeParallaxMotionEffects
441442
_motionEffectsAdded = NO;
442443
}
443444

445+
- (NSString *)scrollSnapAlign
446+
{
447+
#if TARGET_OS_TV
448+
return _scrollSnapAlign;
449+
#endif
450+
return nil;
451+
}
444452

445453
- (BOOL)isTVFocusGuide
446454
{
@@ -1170,6 +1178,15 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
11701178
_trapFocusDown = newViewProps.trapFocusDown;
11711179
_trapFocusLeft = newViewProps.trapFocusLeft;
11721180
_trapFocusRight = newViewProps.trapFocusRight;
1181+
1182+
// `scrollSnapAlign`
1183+
if (oldViewProps.scrollSnapAlign != newViewProps.scrollSnapAlign) {
1184+
if (newViewProps.scrollSnapAlign.has_value()) {
1185+
_scrollSnapAlign = [[NSString alloc] initWithUTF8String:newViewProps.scrollSnapAlign.value().c_str()];
1186+
} else {
1187+
_scrollSnapAlign = nil;
1188+
}
1189+
}
11731190
#endif
11741191

11751192
_needsInvalidateLayer = _needsInvalidateLayer || needsInvalidateLayer;

0 commit comments

Comments
 (0)