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;
@@ -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
0 commit comments