Files
iOS/Sources/App/Frontend/WebView/WebViewController+EmptyState.swift
Bruno Pantaleão Gonçalves 3cc6d99f67 Fix iOS 15 safe-area inflation breaking WebView layout and taps (#4653)
<!-- Thank you for submitting a Pull Request and helping to improve Home
Assistant. Please complete the following sections to help the processing
and review of your changes. Please do not delete anything from this
template. -->

## Summary
<!-- Provide a brief summary of the changes you have made and most
importantly what they aim to achieve -->
On iOS 15, when a WebViewController is hosted in a
UINavigationController with the navigation bar hidden, adding a
full-screen child UIViewController whose subtree contains a
UIHostingController triggers a UIKit safe-area inflation bug:
view.safeAreaInsets.top becomes roughly twice the real status-bar
height, the native statusBarView (pinned to
view.safeAreaLayoutGuide.topAnchor) oversizes, and WKWebView caches a
hit-test region from the inflated value — so taps near the top of the
page (e.g. the dashboard hamburger menu) land at the wrong content
coordinates until the device is rotated. iOS 16+ is unaffected.

Two callsites on main triggered this independently:

1. KioskSecretExitGestureViewController was installed unconditionally
from KioskModeManager.setup(using:) — even when kiosk mode wasn't
active. Now installed in enableKioskMode() and torn down in
disableKioskMode(); setup() only re-anchors it for an already-active
kiosk session.

2. WebViewController+EmptyState.setupEmptyState() called
addChild(emptyState.hostingViewController) unconditionally (added in
#4572 for iOS-16 SwiftUI safe-area propagation). The addChild +
didMove(toParent:) calls are now guarded with #available(iOS 16.0, *).
The empty state still renders on iOS 15; it just skips the parenting
that iOS 15 doesn't honor the same way anyway.

Updated the related test to XCTSkip on iOS 15 with a comment pointing at
this commit.

Fixes #4499.
## Screenshots
<!-- If this is a user-facing change not in the frontend, please include
screenshots in light and dark mode. -->

## Link to pull request in Documentation repository
<!-- Pull requests that add, change or remove functionality must have a
corresponding pull request in the Companion App Documentation repository
(https://github.com/home-assistant/companion.home-assistant). Please add
the number of this pull request after the "#" -->
Documentation: home-assistant/companion.home-assistant#

## Any other notes
<!-- If there is any other information of note, like if this Pull
Request is part of a bigger change, please include it here. -->
2026-05-26 09:46:35 +02:00

136 lines
4.9 KiB
Swift

import Shared
import SwiftUI
import UIKit
// MARK: - Empty State
extension WebViewController {
func setupEmptyState() {
let emptyState = WebViewEmptyStateWrapperView(
style: emptyStateStyle(for: connectionState),
server: server,
showsErrorDetailsButton: shouldShowErrorDetailsButton,
retryAction: { [weak self] in
self?.hideEmptyState()
self?.refresh()
},
settingsAction: { [weak self] in
self?.showSettingsViewController()
},
errorDetailsAction: { [weak self] in
self?.presentLatestLoadErrorDetails()
},
reauthAction: { [weak self] urlType in
self?.performReauthentication(using: urlType)
},
dismissAction: { [weak self] in
self?.hideEmptyState()
}
)
// On iOS 16+, parenting the empty state's UIHostingController as a child of self lets the
// SwiftUI safe-area plumbing reflect the real layout (#4572). On iOS 15 the same call
// triggers a UIKit safe-area-inflation bug that oversizes the native statusBarView and
// breaks WKWebView hit-testing for the entire WebViewController (#4499). Skip the
// child-VC parenting on iOS 15 the empty state still renders, it just doesn't get the
// iOS-16-specific SwiftUI safe-area propagation #4572 added.
if #available(iOS 16.0, *) {
addChild(emptyState.hostingViewController)
}
view.addSubview(emptyState)
emptyState.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
emptyState.leftAnchor.constraint(equalTo: view.leftAnchor),
emptyState.rightAnchor.constraint(equalTo: view.rightAnchor),
emptyState.topAnchor.constraint(equalTo: view.topAnchor),
emptyState.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
emptyState.alpha = 0
emptyStateView = emptyState
if #available(iOS 16.0, *) {
emptyState.hostingViewController.didMove(toParent: self)
}
}
func emptyStateStyle(for connectionState: FrontEndConnectionState) -> WebViewEmptyStateStyle {
switch connectionState {
case .authInvalid:
.unauthenticated
case .connected, .disconnected, .unknown:
.disconnected
}
}
func showEmptyState() {
emptyStateView?.update(
style: emptyStateStyle(for: connectionState),
showsErrorDetailsButton: shouldShowErrorDetailsButton
)
UIView.animate(withDuration: emptyStateTransitionDuration, delay: 0, options: .curveEaseInOut, animations: {
self.emptyStateView?.alpha = 1
}, completion: nil)
}
var shouldShowErrorDetailsButton: Bool {
connectionState == .disconnected && latestLoadError != nil
}
func presentLatestLoadErrorDetails() {
guard let latestLoadError else { return }
presentOverlayController(
controller: UIHostingController(rootView: ConnectionErrorDetailsView(
server: server,
error: latestLoadError
)),
animated: true
)
}
@objc func hideEmptyState() {
UIView.animate(withDuration: emptyStateTransitionDuration, delay: 0, options: .curveEaseInOut, animations: {
self.emptyStateView?.alpha = 0
}, completion: nil)
}
// To avoid keeping the empty state on screen when user is disconnected in background
// due to inactivity, we reset the empty state timer
@objc func resetEmptyStateTimerWithLatestConnectedState() {
let state: FrontEndConnectionState = if connectionState == .authInvalid {
.authInvalid
} else {
isConnected ? .connected : .disconnected
}
updateFrontendConnectionState(state: state.rawValue)
}
func emptyStateObservations() {
// Hide empty state when enter background
NotificationCenter.default.addObserver(
self,
selector: #selector(hideEmptyState),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
// Show empty state again if after entering foreground it is not connected
NotificationCenter.default.addObserver(
self,
selector: #selector(resetEmptyStateTimerWithLatestConnectedState),
name: UIApplication.willEnterForegroundNotification,
object: nil
)
}
func removeEmptyStateObservations() {
NotificationCenter.default.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil)
NotificationCenter.default.removeObserver(
self,
name: UIApplication.willEnterForegroundNotification,
object: nil
)
}
}