Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions Source/Turbo/Visitable/Visitable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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, *) {
Expand Down
62 changes: 62 additions & 0 deletions Source/Turbo/Visitable/VisitableViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -57,6 +58,67 @@ open class VisitableViewController: UIViewController, Visitable {
open func visitableDidRender() {
navigationItem.title = visitableView.webView?.title
visitableLocationState = .resolved
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 }

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)
}
}

open func showVisitableActivityIndicator() {
Expand Down