Skip to content

Fix large titles rendering collapsed on initial display#256

Closed
pardeyke wants to merge 3 commits into
hotwired:mainfrom
pardeyke:fix-large-title-collapsed-on-initial-load
Closed

Fix large titles rendering collapsed on initial display#256
pardeyke wants to merge 3 commits into
hotwired:mainfrom
pardeyke:fix-large-title-collapsed-on-initial-load

Conversation

@pardeyke

@pardeyke pardeyke commented Jul 12, 2026

Copy link
Copy Markdown

The problem

Since #199, the web view's scroll view is explicitly designated as the navigation bar's tracked content scroll view (and made the first subview, so UIKit's ordering heuristic finds it too). That fixed large titles never collapsing on scroll (#169), but it exposed a new issue: with navigationBar.prefersLargeTitles = true and navigationItem.largeTitleDisplayMode = .always, screens frequently show a collapsed (small) title when they first appear, only expanding after the user drags down. Which screens are affected feels random — the same screen can work on one navigation and fail on the next.

Root cause (verified by instrumenting a real app)

I logged contentOffset / adjustedContentInset / bar height / transition state through the visitable lifecycle in a production Hotwire Native app:

  1. The web view activates before its view controller is pushed (navigationController is still nil at activation), so nav-bar tracking begins while the outgoing page's geometry is active.
  2. Turbo scrolls the web view during visits. On a page-cache revisit, the snapshot render's scroll-to-top lands before/during the push, computed against the outgoing (small-bar) geometry, e.g. -116 instead of the incoming expanded top -168.
  3. UIKit derives the incoming bar state from that stale tracked offset → collapsed. Captured trace of a push from a small-title screen to a /clients screen (page-cache hit — deterministic reproduction):
didActivate:   offset=-116  adjustedTop=116  navigationController=nil
viewDidAppear: offset=-116  adjustedTop=116  barHeight=54   ← locked collapsed

First visits usually survive (no cached snapshot to render early); revisits reliably fail. Slow first renders can also collapse the bar seconds after a successful appearance. That mix is why the symptom looks random.

Two negative results worth recording, both verified in the same setup:

  • navigationBar.sizeToFit() does not re-expand a collapsed bar resting at the top.
  • Detaching via setContentScrollView(nil, for: .all) doesn't help either — the first-subview ordering keeps the scroll view tracked through UIKit's fallback heuristic.

The only input UIKit responds to is tracked scroll offset changes (which is also what collapsed the bar in the first place).

The fix

  • On activation (Visitable.activateVisitableWebView): align the scroll offset to the adjusted top on the next run-loop tick, only when the scroll view sits between its unadjusted and adjusted top — a restored scroll position is left untouched.
  • After appearance and after render (VisitableViewController): once any transition has settled, and only for the topmost on-screen controller, sample the adjusted top inset whenever an expanded large-title bar is observed; if the bar is instead stuck collapsed with the page resting at the top, drive the tracked offset to the sampled expanded top — the bar and insets follow, restoring the expanded large title. Pages scrolled below the top are left alone, and screens with largeTitleDisplayMode == .never (or .automatic, which may legitimately collapse) are never forced.

Steps to reproduce (on main / 1.3.0)

  1. Configure a custom navigation controller with navigationBar.prefersLargeTitles = true via Hotwire.config.defaultNavigationController.
  2. In a HotwireWebViewController subclass, set navigationItem.largeTitleDisplayMode = .always in viewWillAppear for some screens.
  3. From a small-title screen, push a large-title screen, navigate back, and push it again (page-cache hit).
  4. The revisit renders with a small title; pull down and it expands.

With this change, large titles render expanded from the settled first frame and stay expanded after Turbo finishes rendering — verified end-to-end in a real app on the iOS simulator across cold tab loads, pushes from small-title screens, page-cache revisits, and pops back to small-title screens (no lingering large title).

🤖 Generated with Claude Code

pardeyke and others added 3 commits July 12, 2026 12:22
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 hotwired#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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@pardeyke

Copy link
Copy Markdown
Author

Closing this — broader testing showed the approach doesn't hold up reliably. Filed the full analysis as #257 instead.

@pardeyke pardeyke closed this Jul 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant