Skip to content

Large titles render collapsed on screens pushed onto the navigation stack (non-root views) since 1.3.0 #257

Description

@pardeyke

Summary

Since 1.3.0 (#199), large titles (navigationBar.prefersLargeTitles = true + navigationItem.largeTitleDisplayMode = .always) only work reliably on screens at the root of a navigation stack (e.g. tab roots). Screens pushed onto the stack render with a collapsed (small) title and only expand after the user pulls down to the top. First pushes sometimes appear correct; revisits (Turbo page-cache hits) fail deterministically.

Before 1.3.0 this didn't happen, because UIKit's subview-ordering heuristic usually failed to find the web view's scroll view at all — .always titles simply stayed large (the flip side was #169: titles never collapsed on scroll).

Environment

  • hotwire-native-ios 1.3.0 (also reproduces on current main)
  • Real app with HotwireTabBarController, custom HotwireNavigationController (prefersLargeTitles = true via Hotwire.config.defaultNavigationController), largeTitleDisplayMode set per-screen from path-configuration properties in viewWillAppear
  • Reproduced on iOS 26 device and iOS 27 simulator

Steps to reproduce

  1. Set navigationBar.prefersLargeTitles = true in a custom navigation controller registered via Hotwire.config.defaultNavigationController.
  2. In a HotwireWebViewController subclass, set navigationItem.largeTitleDisplayMode = .always in viewWillAppear for some screens.
  3. From a small-title screen (e.g. a tab root with .never), tap a link to a large-title screen — it may still render correctly this first time.
  4. Navigate back, then tap the same link again (Turbo page-cache hit).
  5. The pushed screen renders with a collapsed title; pull down and it expands. Tab-root screens with .always are unaffected.

Findings (from instrumenting contentOffset / adjustedContentInset / bar height / transition state through the visitable lifecycle)

  1. The web view activates before its view controller is pushed. At visitableDidActivateWebView time, navigationController is still nil — yet Fix TabBarMinimize and prefersLargeTitle behaviour #199's setContentScrollView(_:for:) (and the insertSubview(webView, at: 0) ordering) starts navigation-bar tracking at that moment.
  2. Turbo scrolls the web view during visits. On a page-cache revisit, the snapshot render's scroll-to-top executes before/during the push and is computed against the outgoing page's geometry — e.g. contentOffset.y = -116 (small-bar top) instead of the incoming screen's expanded top (-168).
  3. UIKit derives the incoming bar state from that stale tracked offset and locks the large title collapsed:
didActivate:   offset=-116  adjustedTop=116  navigationController=nil
viewWillAppear (mode=.always, mid-push): barHeight=54
viewDidAppear: offset=-116  adjustedTop=116  barHeight=54   ← locked collapsed
  1. A slow first render can also collapse an initially-correct bar seconds after the push: the render's scroll reset lands at the small-bar top while the bar is expanded, UIKit reads it as an upward scroll and collapses:
viewDidAppear:      offset=-168  adjustedTop=168  barHeight=106   ← expanded, correct
visitableDidRender: offset=-116  adjustedTop=116  barHeight=54    ← collapsed after render
  1. A collapsed large-title bar resting at the top never re-expands without a user drag — which is why the broken state sticks.

Timing relative to the push transition decides the outcome, which is why the symptom looks random per-screen: cache-hit revisits and slow renders fail, everything else survives. Screens pushed from another large-title screen inherit a compatible offset and survive too.

Negative results (tried and verified not to work)

  • navigationController?.navigationBar.sizeToFit() after re-aligning the offset does not re-expand a collapsed bar resting at the top.
  • setContentScrollView(nil, for: .all) does not stop the tracking (and so can't prevent the stale-offset decision): the web view is the first subview, so UIKit's ordering heuristic keeps tracking it regardless.

The only input that changes the bar's expanded/collapsed state is tracked scroll-offset changes — the same mechanism that breaks it.

Expected behavior

A pushed screen with largeTitleDisplayMode = .always should render with the expanded large title, regardless of Turbo page-cache state or render timing — as tab-root screens already do.

Related: #169, #199.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions