3030#import < React/RCTTVRemoteHandler.h>
3131#import < React/RCTTVNavigationEventNotification.h>
3232#import " React/RCTI18nUtil.h"
33+ #import " RCTViewComponentView.h"
3334#endif
3435
3536using namespace facebook ::react;
4041
4142static const CGFloat kClippingLeeway = 44.0 ;
4243static const float TV_DEFAULT_SWIPE_DURATION = 0.3 ;
44+ static const CGPoint NO_PREFERRED_CONTENT_OFFSET = CGPointMake(CGFLOAT_MIN , CGFLOAT_MIN );
4345
4446static 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