diff --git a/ios/RNSScreen.mm b/ios/RNSScreen.mm index 9b66012dc8..8a70c68daa 100644 --- a/ios/RNSScreen.mm +++ b/ios/RNSScreen.mm @@ -1538,6 +1538,25 @@ - (void)viewDidDisappear:(BOOL)animated _shouldNotify = YES; } +// See RNSTabsScreenViewController: when a tab hosts a nested stack, UIKit may +// resolve the bottom-edge content scroll view (which drives iOS 26 +// `tabBarMinimizeBehavior`) against the visible stack screen's view +// controller rather than the tab root. UIKit's automatic detection cannot +// reach UIScrollViews nested in a React Native screen tree, so fall back to a +// breadth-first search. Restricted to the bottom edge so top-edge +// (navigation bar) resolution is unaffected. +- (UIScrollView *)contentScrollViewForEdge:(NSDirectionalRectEdge)edge +{ + UIScrollView *scrollView = [super contentScrollViewForEdge:edge]; + if (scrollView != nil) { + return scrollView; + } + if (edge == NSDirectionalRectEdgeBottom) { + return [RNSScrollViewFinder findScrollViewBreadthFirstFrom:self.view]; + } + return nil; +} + - (void)viewDidLayoutSubviews { [super viewDidLayoutSubviews]; diff --git a/ios/helpers/scroll-view/RNSScrollViewFinder.h b/ios/helpers/scroll-view/RNSScrollViewFinder.h index 04d40b38ba..b01437641d 100644 --- a/ios/helpers/scroll-view/RNSScrollViewFinder.h +++ b/ios/helpers/scroll-view/RNSScrollViewFinder.h @@ -25,4 +25,18 @@ */ + (nullable UIScrollView *)findContentScrollViewWithDelegatingToProvider:(nullable UIView *)view; +/** + * Breadth-first search for the first vertically-scrollable UIScrollView + * descendant. Unlike the first-descendant-chain walk, this finds scroll views + * in real-world screen trees (styled wrapper views, headers, virtualized + * lists such as FlashList/LegendList, nested stack navigators), which + * UIKit's automatic content-scroll-view detection cannot reach. + * Horizontal-only scrollers (e.g. carousels) are skipped, so behaviors driven + * by the result (such as `tabBarMinimizeBehavior`) track vertical scrolling. + * The search is bounded so a pathological tree cannot stall the main thread. + * + * When `view == nil`, it returns `nil`. + */ ++ (nullable UIScrollView *)findScrollViewBreadthFirstFrom:(nullable UIView *)view; + @end diff --git a/ios/helpers/scroll-view/RNSScrollViewFinder.mm b/ios/helpers/scroll-view/RNSScrollViewFinder.mm index 2434c61295..237300aeff 100644 --- a/ios/helpers/scroll-view/RNSScrollViewFinder.mm +++ b/ios/helpers/scroll-view/RNSScrollViewFinder.mm @@ -41,4 +41,37 @@ + (nullable UIScrollView *)findContentScrollViewWithDelegatingToProvider:(nullab return nil; } ++ (nullable UIScrollView *)findScrollViewBreadthFirstFrom:(nullable UIView *)view +{ + if (view == nil) { + return nil; + } + + static const NSUInteger kMaxVisitedViews = 2000; + NSMutableArray *queue = [NSMutableArray arrayWithObject:view]; + NSUInteger index = 0; + + while (index < queue.count && queue.count < kMaxVisitedViews) { + UIView *current = queue[index++]; + + if ([current isKindOfClass:UIScrollView.class]) { + UIScrollView *scrollView = static_cast(current); + // Skip horizontal-only scrollers (carousels). Unmeasured scroll views + // (zero contentSize) pass, since the main list may not be laid out yet + // when UIKit first resolves the content scroll view. + BOOL isHorizontalOnly = scrollView.contentSize.width > scrollView.bounds.size.width && + scrollView.contentSize.height <= scrollView.bounds.size.height; + if (!isHorizontalOnly) { + return scrollView; + } + // Do not descend into a skipped horizontal scroller. + continue; + } + + [queue addObjectsFromArray:current.subviews]; + } + + return nil; +} + @end diff --git a/ios/tabs/screen/RNSTabsScreenViewController.mm b/ios/tabs/screen/RNSTabsScreenViewController.mm index 6bfece3edd..4dbbd1366d 100644 --- a/ios/tabs/screen/RNSTabsScreenViewController.mm +++ b/ios/tabs/screen/RNSTabsScreenViewController.mm @@ -105,6 +105,26 @@ - (bool)tabScreenSelectedRepeatedly return false; } +// UIKit resolves the scroll view that drives `tabBarMinimizeBehavior` +// (iOS 26 tab-bar minimize + inline bottom accessory) by asking the selected +// tab's view controller for its bottom-edge content scroll view. UIKit's +// automatic detection cannot find UIScrollViews nested inside a typical +// React Native screen tree (styled wrappers, headers, virtualized lists, +// nested stack navigators), so minimize never engages. Fall back to a +// breadth-first search of the currently attached content. Restricted to the +// bottom edge so top-edge (navigation bar) resolution is unaffected. +- (UIScrollView *)contentScrollViewForEdge:(NSDirectionalRectEdge)edge +{ + UIScrollView *scrollView = [super contentScrollViewForEdge:edge]; + if (scrollView != nil) { + return scrollView; + } + if (edge == NSDirectionalRectEdgeBottom) { + return [RNSScrollViewFinder findScrollViewBreadthFirstFrom:self.view]; + } + return nil; +} + #if !TARGET_OS_TV - (RNSOrientation)evaluateOrientation