From 595772c503481039d56b2cf34fc4f18744f2a1c1 Mon Sep 17 00:00:00 2001 From: Jonas Pardeyke Date: Sun, 12 Jul 2026 12:22:46 +0200 Subject: [PATCH 1/3] Align web view scroll offset to adjusted top on activation A web view attached after its view controller is already on screen misses UIKit's automatic initial content-offset adjustment, leaving it at contentOffset 0 instead of -adjustedContentInset.top. Since #199 explicitly designates the web view's scroll view as the tracked content scroll view, the navigation bar treats the page as scrolled down and renders large titles (prefersLargeTitles + largeTitleDisplayMode = .always) collapsed on initial display, only expanding after the user scrolls to the top. Align the scroll offset to the adjusted top after activation, once the pending layout pass has produced the final insets. Restored scroll positions (contentOffset.y > 0) are left untouched. Co-Authored-By: Claude Fable 5 --- Source/Turbo/Visitable/Visitable.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Source/Turbo/Visitable/Visitable.swift b/Source/Turbo/Visitable/Visitable.swift index 557e175d..0f78ceca 100644 --- a/Source/Turbo/Visitable/Visitable.swift +++ b/Source/Turbo/Visitable/Visitable.swift @@ -57,9 +57,28 @@ extension Visitable { if #available(iOS 15.0, *) { visitableViewController.setContentScrollView(webView.scrollView, for: .all) } + alignWebViewScrollOffsetToAdjustedTop(webView) visitableDidActivateWebView(webView) } + /// The web view attaches after its view controller is already on screen, so + /// it misses UIKit's automatic initial content-offset adjustment and sits at + /// contentOffset 0 instead of -adjustedContentInset.top. The navigation bar + /// then treats the page as scrolled down and renders a large title + /// (`prefersLargeTitles`) collapsed until the user scrolls to the top. + /// Align the offset once the pending layout pass has produced the final + /// adjusted insets, and only when the web view is at its unadjusted top — + /// a restored scroll position is left untouched. + private func alignWebViewScrollOffsetToAdjustedTop(_ webView: WKWebView) { + let scrollView = webView.scrollView + DispatchQueue.main.async { + let top = -scrollView.adjustedContentInset.top + if scrollView.contentOffset.y > top, scrollView.contentOffset.y <= 0 { + scrollView.setContentOffset(CGPoint(x: 0, y: top), animated: false) + } + } + } + func deactivateVisitableWebView() { visitableWillDeactivateWebView() if #available(iOS 15.0, *) { From 228083c89277eaefe23e3fb9d05ee0719be40e27 Mon Sep 17 00:00:00 2001 From: Jonas Pardeyke Date: Sun, 12 Jul 2026 23:49:57 +0200 Subject: [PATCH 2/3] Restore expanded large title after Turbo renders a visit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turbo scrolls to the top after rendering, which WKWebView applies against the collapsed-bar geometry. UIKit interprets that as an upward scroll and collapses a large title — and a collapsed bar resting at the top never re-expands without a user drag. Screens configured for large titles could therefore appear expanded during the push transition and then collapse the moment the page finished rendering, with the outcome depending on render timing. After visitableDidRender, re-align the scroll offset to the adjusted top and re-measure the navigation bar, leaving restored scroll positions below the top untouched. Co-Authored-By: Claude Fable 5 --- .../Visitable/VisitableViewController.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Source/Turbo/Visitable/VisitableViewController.swift b/Source/Turbo/Visitable/VisitableViewController.swift index df2032ce..9e71269b 100644 --- a/Source/Turbo/Visitable/VisitableViewController.swift +++ b/Source/Turbo/Visitable/VisitableViewController.swift @@ -57,6 +57,28 @@ open class VisitableViewController: UIViewController, Visitable { open func visitableDidRender() { navigationItem.title = visitableView.webView?.title visitableLocationState = .resolved + restoreExpandedLargeTitleIfNeeded() + } + + /// Turbo scrolls to the top after rendering a visit, which WKWebView applies + /// against the collapsed-bar geometry. UIKit interprets that as an upward + /// scroll and collapses a large title — and a collapsed bar resting at the + /// top never re-expands without a user drag. Once the pending layout pass + /// has produced the final insets, re-align the offset and re-measure the + /// bar so screens configured for large titles render them expanded. A page + /// scrolled below the top (e.g. a restored scroll position) is left alone. + private func restoreExpandedLargeTitleIfNeeded() { + guard let navigationBar = navigationController?.navigationBar, + navigationBar.prefersLargeTitles, + navigationItem.largeTitleDisplayMode != .never, + let scrollView = visitableView.webView?.scrollView else { return } + DispatchQueue.main.async { + let top = -scrollView.adjustedContentInset.top + if scrollView.contentOffset.y <= top + 1 { + scrollView.setContentOffset(CGPoint(x: 0, y: top), animated: false) + navigationBar.sizeToFit() + } + } } open func showVisitableActivityIndicator() { From f7dabc65dcd423e3932f456e21c403293f8ea41a Mon Sep 17 00:00:00 2001 From: Jonas Pardeyke Date: Mon, 13 Jul 2026 00:39:30 +0200 Subject: [PATCH 3/3] Replace sizeToFit restore with tracked-offset large-title repair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field testing against a real app showed two things the previous commit missed. First, the web view activates before its view controller is even pushed, and Turbo's cache-restore render scrolls it against the outgoing page's geometry — so UIKit derives the incoming bar state from a stale offset and reliably locks large titles collapsed on every page-cache revisit. Second, neither navigationBar.sizeToFit() nor detaching the content scroll view re-expands a collapsed bar at rest (the subview-order fallback keeps the scroll view tracked regardless); the only input UIKit responds to is tracked scroll offset changes. Sample the adjusted top inset whenever an expanded large-title bar is observed, and after every appearance/render — once transitions have settled and only for the topmost, on-screen controller — re-align a web view resting at the top and, if the bar is stuck collapsed, drive the tracked offset to the sampled expanded top so the bar reopens. Restored scroll positions below the top are left untouched. Co-Authored-By: Claude Fable 5 --- .../Visitable/VisitableViewController.swift | 76 ++++++++++++++----- 1 file changed, 58 insertions(+), 18 deletions(-) diff --git a/Source/Turbo/Visitable/VisitableViewController.swift b/Source/Turbo/Visitable/VisitableViewController.swift index 9e71269b..6e060bb2 100644 --- a/Source/Turbo/Visitable/VisitableViewController.swift +++ b/Source/Turbo/Visitable/VisitableViewController.swift @@ -36,6 +36,7 @@ open class VisitableViewController: UIViewController, Visitable { override open func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + syncLargeTitlePresentationAfterTransition() if appearReason == .tabSelected { return } visitableDelegate?.visitableViewDidAppear(self) } @@ -57,27 +58,66 @@ open class VisitableViewController: UIViewController, Visitable { open func visitableDidRender() { navigationItem.title = visitableView.webView?.title visitableLocationState = .resolved - restoreExpandedLargeTitleIfNeeded() - } - - /// Turbo scrolls to the top after rendering a visit, which WKWebView applies - /// against the collapsed-bar geometry. UIKit interprets that as an upward - /// scroll and collapses a large title — and a collapsed bar resting at the - /// top never re-expands without a user drag. Once the pending layout pass - /// has produced the final insets, re-align the offset and re-measure the - /// bar so screens configured for large titles render them expanded. A page - /// scrolled below the top (e.g. a restored scroll position) is left alone. - private func restoreExpandedLargeTitleIfNeeded() { - guard let navigationBar = navigationController?.navigationBar, - navigationBar.prefersLargeTitles, + syncLargeTitlePresentationAfterTransition() + } + + /// Adjusted top content inset sampled while a large-title bar is expanded, + /// used to recognise (and repair) a bar that is stuck collapsed. + private static var expandedTopContentInset: CGFloat? + + /// The web view activates before its view controller is pushed, and Turbo + /// scrolls it during visits — including cache-restore renders that happen + /// mid-transition against the outgoing page's geometry. UIKit derives the + /// navigation bar's large-title state from that stale offset and locks the + /// title collapsed; a collapsed bar resting at the top never re-expands + /// without scrolling. Once any transition has settled, re-align the offset + /// and, if the bar is stuck collapsed, drive the tracked offset to the + /// sampled expanded top so the bar reopens. Real scroll positions (below + /// the top) are left untouched. + private func syncLargeTitlePresentationAfterTransition() { + guard navigationController?.navigationBar.prefersLargeTitles == true, + navigationItem.largeTitleDisplayMode != .never else { return } + if let coordinator = transitionCoordinator { + coordinator.animate(alongsideTransition: nil) { [weak self] _ in + self?.syncLargeTitlePresentation() + } + } else { + DispatchQueue.main.async { [weak self] in + self?.syncLargeTitlePresentation() + } + } + } + + private func syncLargeTitlePresentation() { + // Only the settled, topmost controller may adjust the bar — an async + // continuation from a page that was popped or covered must not restyle + // the bar the next page now owns. + guard navigationController?.topViewController == self, + transitionCoordinator == nil, + viewIfLoaded?.window != nil, + navigationController?.navigationBar.prefersLargeTitles == true, navigationItem.largeTitleDisplayMode != .never, let scrollView = visitableView.webView?.scrollView else { return } - DispatchQueue.main.async { - let top = -scrollView.adjustedContentInset.top - if scrollView.contentOffset.y <= top + 1 { - scrollView.setContentOffset(CGPoint(x: 0, y: top), animated: false) - navigationBar.sizeToFit() + + let adjustedTop = scrollView.adjustedContentInset.top + let top = -adjustedTop + let atTop = scrollView.contentOffset.y <= top + 1 + + if navigationItem.largeTitleDisplayMode == .always { + if let expanded = Self.expandedTopContentInset, expanded > adjustedTop + 1, atTop { + // Stuck collapsed at the top: UIKit only re-expands in response + // to scrolling, so drive the tracked offset to the expanded + // top; the bar and insets follow. + scrollView.setContentOffset(CGPoint(x: 0, y: -expanded), animated: false) + return } + Self.expandedTopContentInset = max(Self.expandedTopContentInset ?? 0, adjustedTop) + } + + // Align a web view resting between the raw and adjusted top (fresh + // attach or post-render scroll reset). + if scrollView.contentOffset.y > top, scrollView.contentOffset.y <= 0 { + scrollView.setContentOffset(CGPoint(x: 0, y: top), animated: false) } }