TabBarMinimize と prefersLargeTitle の動作を安定させるためのビュー階層修正
iOS 26 で導入された TabBarMinimizeBehavior と UINavigationBar.prefersLargeTitles がスクロール検知に依存するため、Hotwire‑Native で WebView が正しく検知されず、タブバーの最小化やラージタイトルが期待通りに動作しない問題を、WebView をビュー階層の最初の子に配置し、iOS 15+ では明示的にコンテンツスクロールビューを設定することで解消した。
背景
この不安定さは Visitable が表示するビュー階層で、screenshotContainerView や activityIndicatorView が WebView より先に追加されることが原因だった。UIKit のスクロール検知はサブビューの先頭要素をヒューリスティックに使用しており、WebView が最初の子でないとタブバーの最小化やラージタイトルのトリガーが発生しないケースが報告されている。
PR の報告では、WebView が表示時に必ずしも最初の子ビューになる保証がなく、特定の遷移で期待した挙動が失われていたことが確認された。これによりユーザー体験が予測不可能になっていた。
技術的な変更
VisitableView の activateWebView(_:forVisitable:) で従来の addSubview(webView) を insertSubview(webView, at: 0) に置き換え、WebView を常に最初の子ビューとして明示的に配置した。
open func activateWebView(_ webView: WKWebView, forVisitable visitable: Visitable) {
self.webView = webView
self.visitable = visitable
// iOS 14 fallback: with no explicit contentScrollView, UIKit's heuristic requires
// the scrollable view to be the first subview for large-title / tab-bar-minimize
// to work. On iOS 15+ this is made robust by setContentScrollView in Visitable.
insertSubview(webView, at: 0)
addFillConstraints(for: webView)
installRefreshControl()
showOrHideWebView()
}
さらに Visitable の activateVisitableWebView(_:) と deactivateVisitableWebView() に、iOS 15 以降で setContentScrollView を呼び出すロジックを追加した。これにより WebView の scrollView をコンテンツスクロールビューとして登録し、解除時にクリアする。
func activateVisitableWebView(_ webView: WKWebView) {
visitableView.activateWebView(webView, forVisitable: self)
// Explicitly designate the web view's scroll view as the tracked content
// scroll view so behaviour like UINavigationBar.prefersLargeTitles and the
// iOS 26 tab bar minimize trigger reliably, without depending on UIKit's
// subview-ordering heuristic. `.all` covers the top (nav bar) and bottom
// (tab bar) edges.
if #available(iOS 15.0, *) {
visitableViewController.setContentScrollView(webView.scrollView, for: .all)
}
visitableDidActivateWebView(webView)
}
func deactivateVisitableWebView() {
visitableWillDeactivateWebView()
if #available(iOS 15.0, *) {
visitableViewController.setContentScrollView(nil, for: .all)
}
visitableView.deactivateWebView()
visitableDidDeactivateWebView()
}
これらの変更に対して VisitableViewControllerTests に 4 つのテストが追加され、WebView がロード中・復元時に最初の子ビューになること、iOS 15+ で contentScrollView が正しく設定・解除されることを自動検証している。
設計判断
VisitableView の全体構造を大幅に変更する代わりに、insertSubview による順序指定を採用した。これにより既存ロジックや他のサブビュー(スクリーンショット、インジケータ)への影響を最小限に抑えつつ、UIKit のヒューリスティックに依存しない確実な動作を保証できる。
iOS 15 以降の setContentScrollView API を併用した点は、将来的に UIKit のサブビュー順序に対する依存が削除された場合でも安定動作を保つ前方互換性を意識した選択である。新たな公開プロパティや大規模リファクタリングを避け、変更侵入度を低く抑えることでリグレッションリスクを最小化している。
まとめ
この PR は WebView をビュー階層の先頭に配置し、iOS 15+ ではコンテンツスクロールビューを明示的に設定することで、TabBarMinimizeBehavior と prefersLargeTitles の不安定な動作を根本的に解消した。変更は局所的かつ後方互換性を維持しつつ、ユーザー体験の一貫性を向上させている。