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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions