Skip to content

Conversation

@nbelzer
Copy link

@nbelzer nbelzer commented Oct 31, 2025

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 screenshotContainerView and activityIndicatorView that 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:

// SceneDelegate.swift
import HotwireNative
import UIKit

let rootURL = URL(string: "https://hotwire-native-demo.dev")!

class CustomNavigationController: HotwireNavigationController {
    override func viewDidLoad() {
        super.viewDidLoad()
        navigationBar.prefersLargeTitles = true
    }
}

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    private let navigator = Navigator(configuration: .init(
        name: "main",
        startLocation: rootURL
    ))

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        Hotwire.config.defaultNavigationController = { CustomNavigationController() }
        window?.rootViewController = navigator.rootViewController
        navigator.start()
    }
}

Before

before.mp4

After

after.mp4

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
@joemasilotti
Copy link
Member

Nice work! Do you know if this changes anything in iOS 18 or below?

@nbelzer
Copy link
Author

nbelzer commented Nov 2, 2025

Nice work! Do you know if this changes anything in iOS 18 or below?

On iOS 18 it solves the same issue with the prefersLargeTitles configuration. It also seems to resolve an issue with the tab bar being translucent on initial load. I have not tested anything below iOS 18.

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)
    }
}

Before

ios18-before.mp4

After

ios18-after.mp4

@Jacoblab1
Copy link

This resolved the prefersLargeTitle behaviour in our app as well (tested on both iOS 18 and 26), thank you @nbelzer!

@joemasilotti
Copy link
Member

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.mp4
diff --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 = true
diff --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
+    }
+}

@joemasilotti joemasilotti added this to the v1.3.0 milestone Nov 6, 2025
@nbelzer
Copy link
Author

nbelzer commented Nov 6, 2025

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?

I am seeing the same with the fix in this branch applied. Without the fix the title does appear in the large version right from launch (but we see the glitches documented above).

Playing around with largeTitleDisplayMode = .always does not appear to change anything.

When creating a simple app with native views only, the large title is displayed on launch. Ideally that would also be the behavior to expect from our HotwireWebView. I will need to look into why that is not happening with the fix applied.

EDIT: Some debugging shows a relationship between the large title view and the scroll view used for our webView:

CleanShot 2025-11-06 at 18 31 48@2x

Perhaps some logic is pushing the large title up before the view is displayed?

@nbelzer
Copy link
Author

nbelzer commented Nov 14, 2025

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:

  • the navigation view seems to add a negative contentOffset to the scrollable view when using large titles.
  • when VisitableViewController#viewDidAppear is called, the content offset (visitableView.webView?.scrollView.contentOffset) for a large title is correct. I assume this value is different depending on the iOS version and device. On an iPhone 17 running iOS 26.1 this value is (0.0, -168.0).
  • by the time we hit visitableDidRender the content offset has changed to (0.0, -116.0).
  • when manually updating the content offset back to a y of -168.0 the large title is displayed correctly.
  • This only happens on initial application load, further navigations do not see this difference.
  • When adding a UIScrollViewDelegate to the webView.scrollView I see several calls to scrollViewDidScroll. The final one being triggered by a method changeContentOffsetBoundedInValidRange in WebKit. It seems part of the core iOS libraries so I am unable to inspect the code further:
    CleanShot 2025-11-14 at 13 00 43@2x
  • I was able to re-create the effect without the TurboNative library using the following SceneDelegate:
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
  • Experimenting with the constrains does show that the issue might be related to the constraints.
    • When I use a heightAnchor instead of the bottomAnchor with a constant smaller than the screen, the issue doesn't present itself. However, this is is not an acceptable solution as we wouldn't be able to use the entire screen height for our WebView.
    • I believe the way we add the WebView and set its constraints is common practice.

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?

@joemasilotti
Copy link
Member

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.

image

@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.

@joemasilotti joemasilotti requested a review from svara November 17, 2025 17:46
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.

3 participants