From 230e0cd7eb4b6ae26e7c3be9fae0951915ce86b2 Mon Sep 17 00:00:00 2001 From: Shridhar Gupta Date: Wed, 10 Jun 2026 19:05:35 +0800 Subject: [PATCH] fix(iOS, Tabs): resolve content scroll view for tabBarMinimizeBehavior in real-world RN hierarchies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On iOS 26, UIKit drives tabBarMinimizeBehavior (tab-bar minimize + inline bottom accessory) from the selected tab view controller's bottom-edge content scroll view. UIKit's automatic detection cannot find a UIScrollView nested inside a typical React Native screen tree — styled wrapper views, headers, virtualized lists (FlashList/LegendList), or a nested stack navigator inside the tab — so minimize never engages. The collapsable={false} workaround from #3954 only addresses the trivial single-wrapper case and cannot help with nested stacks or virtualized lists. Implement contentScrollViewForEdge: (UIKit's documented override point) on RNSTabsScreenViewController and RNSScreen, covering whichever controller UIKit queries: try UIKit's own resolution first, then fall back to a breadth-first search of the attached content. Restricted to the bottom edge so top-edge (navigation bar) resolution is unaffected. The new RNSScrollViewFinder.findScrollViewBreadthFirstFrom: is a bounded BFS that skips horizontal-only scrollers (carousels), so minimize tracks the vertical list. Fixes #4145 --- ios/RNSScreen.mm | 19 +++++++++++ ios/helpers/scroll-view/RNSScrollViewFinder.h | 14 ++++++++ .../scroll-view/RNSScrollViewFinder.mm | 33 +++++++++++++++++++ .../screen/RNSTabsScreenViewController.mm | 20 +++++++++++ 4 files changed, 86 insertions(+) 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