From 8aa5c7ee43c2851def09053e8d3cf56d43c22941 Mon Sep 17 00:00:00 2001 From: Nick Belzer Date: Fri, 24 Oct 2025 16:18:23 +0200 Subject: [PATCH 1/3] Fix TabBarMinimize behaviour on visit When we perform a visit we add several views to the VisitableView in the order: 1. activityIndicatorView 2. screenshotContainerView 3. webView Whenever a visits starts we move the activityIndicator to the bottom of the hierarchy (using `bringSubviewToFront`) and before displaying the view we remove the screenshotContainerView (using `removeFromSuperview`). As a result the webView ends up being the first view in the hierarchy when inspecting the application at any point after loading the Visitable. When starting the application and performing the initial load this process causes no issues. However, with subsequent visits this results in behaviour like the UINavigationBar.prefersLargeTitles or TabBarMinimize functionality in iOS 26 not working as expected. It appears like scrolling through the webview is not detected, therefore therefore not triggering these specific behaviours. While this is undocumented by Apple I found several sources[^1][^2] that indicate that behaviour like UINavigationBar.prefersLargeTitles requires that the scollable view is the first child in the view hierarchy. While our webview is eventually the first view in the hierachy, the fact that it isn't during loading seems to cause the issues that I experienced. This led me to experiment with insertSubview to ensure the webView is always the first child in the view hierarchy. As a result I have seen no more issues with the behaviours mentioned above. A better solution might be to rework the way we add views to the VisitableView such that the webview always ends up in the initial position. While I attempted such a solution I found that using insertSubview is more explicit and less likely to break by future changes to this file. [^1]: https://swiftsenpai.com/development/large-title-uinavigationbar-glitches/ [^2]: https://github.com/software-mansion/react-native-screens/issues/1034 --- Source/Turbo/Visitable/VisitableView.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Source/Turbo/Visitable/VisitableView.swift b/Source/Turbo/Visitable/VisitableView.swift index f0adc362..162469c1 100644 --- a/Source/Turbo/Visitable/VisitableView.swift +++ b/Source/Turbo/Visitable/VisitableView.swift @@ -24,7 +24,10 @@ open class VisitableView: UIView { open func activateWebView(_ webView: WKWebView, forVisitable visitable: Visitable) { self.webView = webView self.visitable = visitable - addSubview(webView) + // For behaviour like UINavigationBar.prefersLargeTitles or + // TabBarMinimize to work the scrollable view shoud be the + // first child in the hierarchy from the moment it is added. + insertSubview(webView, at: 0) addFillConstraints(for: webView) installRefreshControl() showOrHideWebView() From ecf33138853f14d615092f8b901bf10aabd48133 Mon Sep 17 00:00:00 2001 From: Nick Belzer Date: Sun, 2 Nov 2025 10:04:29 +0100 Subject: [PATCH 2/3] Add VisitableView subview ordering tests --- .../Turbo/VisitableViewControllerTests.swift | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Tests/Turbo/VisitableViewControllerTests.swift b/Tests/Turbo/VisitableViewControllerTests.swift index a3bcb3ca..d971d234 100644 --- a/Tests/Turbo/VisitableViewControllerTests.swift +++ b/Tests/Turbo/VisitableViewControllerTests.swift @@ -47,6 +47,30 @@ class VisitableViewControllerTests: XCTestCase { XCTAssertEqual(viewController.initialVisitableURL, originalURL) XCTAssertEqual(viewController.currentVisitableURL, overriddenURL) } + + func test_webview_is_first_child_during_loading() { + XCTAssertEqual(viewController.visitableView.subviews.first, viewController.visitableView.activityIndicatorView) + + viewController.visitableView.activateWebView(webView, forVisitable: viewController) + + XCTAssertEqual(viewController.visitableView.subviews.first, webView) + XCTAssertEqual(viewController.visitableView.subviews.last, viewController.visitableView.activityIndicatorView) + } + + func test_webview_is_first_child_on_restore() { + XCTAssertEqual(viewController.visitableView.subviews.first, viewController.visitableView.activityIndicatorView) + + viewController.showVisitableScreenshot() + viewController.visitableView.activateWebView(webView, forVisitable: viewController) + + XCTAssertEqual(viewController.visitableView.subviews.count, 3) + XCTAssertEqual(viewController.visitableView.subviews.first, viewController.visitableView.webView) + + viewController.hideVisitableScreenshot() + + XCTAssertEqual(viewController.visitableView.subviews.count, 2) + XCTAssertEqual(viewController.visitableView.subviews.first, viewController.visitableView.webView) + } } final class WebViewSpy: WKWebView { From ddbe71f50a339f40e80780dab7b2596e37bab93d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20=C5=A0vara?= Date: Thu, 2 Jul 2026 10:52:11 +0200 Subject: [PATCH 3/3] Make large-title / tab-bar-minimize tracking robust via setContentScrollView. --- Source/Turbo/Visitable/Visitable.swift | 11 ++++++++++ Source/Turbo/Visitable/VisitableView.swift | 6 +++--- .../Turbo/VisitableViewControllerTests.swift | 20 +++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/Source/Turbo/Visitable/Visitable.swift b/Source/Turbo/Visitable/Visitable.swift index db0c0403..557e175d 100644 --- a/Source/Turbo/Visitable/Visitable.swift +++ b/Source/Turbo/Visitable/Visitable.swift @@ -49,11 +49,22 @@ extension Visitable { func activateVisitableWebView(_ webView: WKWebView) { visitableView.activateWebView(webView, forVisitable: self) + // Explicitly designate the web view's scroll view as the tracked content + // scroll view so behaviour like UINavigationBar.prefersLargeTitles and the + // iOS 26 tab bar minimize trigger reliably, without depending on UIKit's + // subview-ordering heuristic. `.all` covers the top (nav bar) and bottom + // (tab bar) edges. + if #available(iOS 15.0, *) { + visitableViewController.setContentScrollView(webView.scrollView, for: .all) + } visitableDidActivateWebView(webView) } func deactivateVisitableWebView() { visitableWillDeactivateWebView() + if #available(iOS 15.0, *) { + visitableViewController.setContentScrollView(nil, for: .all) + } visitableView.deactivateWebView() visitableDidDeactivateWebView() } diff --git a/Source/Turbo/Visitable/VisitableView.swift b/Source/Turbo/Visitable/VisitableView.swift index 162469c1..bba9d761 100644 --- a/Source/Turbo/Visitable/VisitableView.swift +++ b/Source/Turbo/Visitable/VisitableView.swift @@ -24,9 +24,9 @@ open class VisitableView: UIView { open func activateWebView(_ webView: WKWebView, forVisitable visitable: Visitable) { self.webView = webView self.visitable = visitable - // For behaviour like UINavigationBar.prefersLargeTitles or - // TabBarMinimize to work the scrollable view shoud be the - // first child in the hierarchy from the moment it is added. + // iOS 14 fallback: with no explicit contentScrollView, UIKit's heuristic requires + // the scrollable view to be the first subview for large-title / tab-bar-minimize + // to work. On iOS 15+ this is made robust by setContentScrollView in Visitable. insertSubview(webView, at: 0) addFillConstraints(for: webView) installRefreshControl() diff --git a/Tests/Turbo/VisitableViewControllerTests.swift b/Tests/Turbo/VisitableViewControllerTests.swift index d971d234..f5d4a170 100644 --- a/Tests/Turbo/VisitableViewControllerTests.swift +++ b/Tests/Turbo/VisitableViewControllerTests.swift @@ -71,6 +71,26 @@ class VisitableViewControllerTests: XCTestCase { XCTAssertEqual(viewController.visitableView.subviews.count, 2) XCTAssertEqual(viewController.visitableView.subviews.first, viewController.visitableView.webView) } + + @available(iOS 15.0, *) + func test_activating_webview_designates_its_scroll_view_as_content_scroll_view() { + XCTAssertNil(viewController.contentScrollView(for: .top)) + + viewController.activateVisitableWebView(webView) + + XCTAssertEqual(viewController.contentScrollView(for: .top), webView.scrollView) + XCTAssertEqual(viewController.contentScrollView(for: .bottom), webView.scrollView) + } + + @available(iOS 15.0, *) + func test_deactivating_webview_clears_the_content_scroll_view() { + viewController.activateVisitableWebView(webView) + XCTAssertEqual(viewController.contentScrollView(for: .top), webView.scrollView) + + viewController.deactivateVisitableWebView() + + XCTAssertNil(viewController.contentScrollView(for: .top)) + } } final class WebViewSpy: WKWebView {