Fix TabBarMinimize and prefersLargeTitle behaviour#199
Fix TabBarMinimize and prefersLargeTitle behaviour#199nbelzer wants to merge 2 commits intohotwired:mainfrom
Conversation
When we perform a visit we add several views to the VisitableView in the order: 1. activityIndicatorView 2. screenshotContainerView 3. webView Whenever a visits starts we move the activityIndicator to the bottom of the hierarchy (using `bringSubviewToFront`) and before displaying the view we remove the screenshotContainerView (using `removeFromSuperview`). As a result the webView ends up being the first view in the hierarchy when inspecting the application at any point after loading the Visitable. When starting the application and performing the initial load this process causes no issues. However, with subsequent visits this results in behaviour like the UINavigationBar.prefersLargeTitles or TabBarMinimize functionality in iOS 26 not working as expected. It appears like scrolling through the webview is not detected, therefore therefore not triggering these specific behaviours. While this is undocumented by Apple I found several sources[^1][^2] that indicate that behaviour like UINavigationBar.prefersLargeTitles requires that the scollable view is the first child in the view hierarchy. While our webview is eventually the first view in the hierachy, the fact that it isn't during loading seems to cause the issues that I experienced. This led me to experiment with insertSubview to ensure the webView is always the first child in the view hierarchy. As a result I have seen no more issues with the behaviours mentioned above. A better solution might be to rework the way we add views to the VisitableView such that the webview always ends up in the initial position. While I attempted such a solution I found that using insertSubview is more explicit and less likely to break by future changes to this file. [^1]: https://swiftsenpai.com/development/large-title-uinavigationbar-glitches/ [^2]: software-mansion/react-native-screens#1034
|
Nice work! Do you know if this changes anything in iOS 18 or below? |
On iOS 18 it solves the same issue with the I have updated the SceneDelegate example to also include a tab bar: import HotwireNative
import UIKit
let rootURL = URL(string: "https://hotwire-native-demo.dev")!
class CustomNavigationController: HotwireNavigationController {
override func viewDidLoad() {
super.viewDidLoad()
navigationBar.prefersLargeTitles = true
}
}
extension HotwireTab {
static let all = [
HotwireTab(title: "Home", image: UIImage(systemName: "house")!, url: rootURL)
]
}
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private let tabBarController = HotwireTabBarController()
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
Hotwire.config.defaultNavigationController = { CustomNavigationController() }
window?.rootViewController = tabBarController
tabBarController.load(HotwireTab.all)
}
}Beforeios18-before.mp4Afterios18-after.mp4 |
|
This resolved the prefersLargeTitle behaviour in our app as well (tested on both iOS 18 and 26), thank you @nbelzer! |
|
This is great, nice work @nbelzer! I'm inclined to also prefer large titles in the demo app (see diff bellow). But when I launch the app for the first time, the first tab doesn't correctly show a large title. Are you seeing the same? RocketSim_Recording_iPhone_17_Pro_6.3_2025-11-06_08.17.01.mp4diff --git a/Demo/AppDelegate.swift b/Demo/AppDelegate.swift
index a6912b9..42ad6e8 100644
--- a/Demo/AppDelegate.swift
+++ b/Demo/AppDelegate.swift
@@ -38,6 +38,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
OverflowMenuComponent.self,
])
+ // Use a custom navigation controller that prefers large titles.
+ Hotwire.config.defaultNavigationController = {
+ LargeTitleNavigationController()
+ }
+
// Set configuration options
Hotwire.config.backButtonDisplayMode = .minimal
Hotwire.config.showDoneButtonOnModals = truediff --git a/Demo/LargeTitleNavigationController.swift b/Demo/LargeTitleNavigationController.swift
new file mode 100644
index 0000000..224149a
--- /dev/null
+++ b/Demo/LargeTitleNavigationController.swift
@@ -0,0 +1,9 @@
+import HotwireNative
+import UIKit
+
+class LargeTitleNavigationController: HotwireNavigationController {
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ navigationBar.prefersLargeTitles = true
+ }
+} |
|
Found an additional reference indicating that the scrollable view should be placed at index 0 in the subview hierarchy. Regarding the issue with large title on load, I took some time to look into the issue and found the following:
import WebKit
import UIKit
let rootURL = URL(string: "https://hotwire-native-demo.dev")!
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
let navigationController = UINavigationController()
navigationController.navigationBar.prefersLargeTitles = true
window?.rootViewController = navigationController
let webView = WKWebView(frame: .zero)
webView.translatesAutoresizingMaskIntoConstraints = false
let request = URLRequest(url: rootURL)
webView.load(request)
let viewController = UIViewController()
viewController.view.addSubview(webView)
NSLayoutConstraint.activate([
webView.leadingAnchor.constraint(equalTo: viewController.view.leadingAnchor),
webView.trailingAnchor.constraint(equalTo: viewController.view.trailingAnchor),
webView.topAnchor.constraint(equalTo: viewController.view.topAnchor),
webView.bottomAnchor.constraint(equalTo: viewController.view.bottomAnchor)
])
viewController.title = "Home"
navigationController.pushViewController(viewController, animated: true)
}
}issue-on-plain-scene-delegate.mp4
Based on the fact that the issue with large titles appears outside the HotwireNative library, and the incorrect contentOffset is being set by internal WebKit logic, I believe the issue is a bug in iOS. I reported my findings to Apple through the Feedback Assistant. I will keep this thread updated when I hear something back. On the short-term we might want to look into a fix built into the HotwireNative library. @joemasilotti Do we want to include such a fix as part of this PR? |
|
Great work on this @nbelzer! And thanks for reporting to Apple. I'm all in favor of this change. I think we had a similar PR back before the Hotwire Native rebrand but it got lost in the transition. Also of note, I think the bug is actually fixed in iOS 26.1! Here's two simulators, iOS 26.1 on the left and iOS 26.0 on the right, running the same code you shared above. Notice how the large title works as expected right on launch in iOS 26.1.
@svara, is it possible to test this against HEY/Basecamp? Everything seems to work fine in the apps I'm currently working on but I'd love another check before merging this. |
|
Just to clarify, this fixes the issue with scrolling not always triggering the relevant TabBarMinimize and prefersLargeTitle behaviors, and the bug that was fixed in iOS is just for having the correct state on initial page load? So this PR is still needed for correct behavior in those two instances? Thanks! |
|
@joemasilotti and @nbelzer I still have the collapsed/shrunk title on launch with iOS 26.2 simulator and 26.3 actual iPhone 17 Pro, using the code from #199 (comment) |
Deep dive: WebKit's cold-start large title collapsing + workaroundI've been debugging this issue extensively and wanted to share findings that go beyond what this PR addresses. PR #199's Root causeAfter a Timeline (from logging)The clamp happens between Workaround: KVO on
|
|
Thanks @octave for confirming that the cold-boot is still an issue on iOS 26.2 and 26.3, your findings perfectly match mine on iOS 26.01. Did you report your findings to Apple? maybe if they receive multiple reports they will look into it. I like the idea of providing a fix for the cold-boot behavior out-of-the-box, but I'd argue it should be part of follow-up PR. @joemasilotti @svara is there anything I can do to get this initial fix in to the next Hotwire Native release? The Footnotes |
|
@nbelzer Thanks for the reply! I haven't reported to Apple yet — I'll file a Feedback Assistant report as well. Agree that the A couple of additional findings since my last comment, relevant for a future cold-boot fix: Multi-tab edge case: In apps with multiple tabs using The workaround I'm using: the first tab caches its correct expanded offset in a static/shared property. When a subsequent tab appears with an already-collapsed offset, it uses the shared value to force-expand (without starting a KVO watcher, since the clamp already happened).
Happy to help with a follow-up PR for the cold-boot fix if that would be useful. |




I recently delved into an issue with the new TabBarMinimize functionality in iOS 26. For certain visits the minimize behavior would not trigger on scroll resulting in an unpredictable user experience.
Based on my findings the scrolling of the webView is not always detected by the TabBarController, therefore not triggering the minimized state of the tab bar. This also seems to extend to other functionality that triggers on scroll like the prefersLargeTitle configuration.
Based on my initial findings the issue appears to be that the webView in HotwireNative is not always the first child in the view hierarchy when the view appears. In HotwireNative we have several other views, like the
screenshotContainerViewandactivityIndicatorViewthat are added and removed depending on the loading state of the page.The proposed fix is to always place the webView as the first view in the hierarchy using insertSubview.
A better solution might be to rework the way we add views to the VisitableView such that the webview is always the first child. While I attempted such a solution I found that using insertSubview is more explicit and less likely to break by future changes to this file.
I have included more specific details in the commit description.
Would love to see this fixed in a future release. Also happy to discuss and look into any alternatives.
Example
At the moment I cannot share any examples from the app I am working on. However, I was able to use the demo app to re-create the issue using the following SceneDelegate:
Before
before.mp4
After
after.mp4