Files
iOS/Sources/App/Frontend/WebView/WebViewController.swift
Bruno Pantaleão Gonçalves 56b5c6a20e Add keyboard avoidance and focused element scroll (#4486)
<!-- 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 -->

Introduce keyboard handling for WKWebView: add WebViewKeyboardAvoidance
helpers for animation metrics and overlap calculation, a WKWebView
extension to run a JS command that scrolls the focused editable element
into view, and a managed bottom constraint for web views so the view is
animated above the keyboard. Wire up keyboard observers and
DispatchWorkItem scheduling/cleanup in WebViewController and
OnboardingAuthLoginViewController, and add the
scrollFocusedElementIntoView JavaScript to WebViewJavascriptCommands.
Add unit tests verifying the bottom constraint creation and JS contents.

## 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-04-08 12:19:04 +02:00

335 lines
11 KiB
Swift

import AVFoundation
import AVKit
import Combine
import CoreLocation
import HAKit
import Improv_iOS
import KeychainAccess
import MBProgressHUD
import PromiseKit
import Shared
import SwiftMessages
import SwiftUI
import UIKit
@preconcurrency import WebKit
final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDelegate {
var webView: WKWebView!
let server: Server
var urlObserver: NSKeyValueObservation?
var tokens = [HACancellable]()
let refreshControl = UIRefreshControl()
let leftEdgePanGestureRecognizer: UIScreenEdgePanGestureRecognizer
let rightEdgeGestureRecognizer: UIScreenEdgePanGestureRecognizer
var emptyStateView: UIView?
let emptyStateTransitionDuration: TimeInterval = 0.3
var statusBarView: UIView?
var webViewTopConstraint: NSLayoutConstraint?
var webViewBottomConstraint: NSLayoutConstraint?
var keyboardFocusedElementScrollWorkItem: DispatchWorkItem?
var initialURL: URL?
var statusBarButtonsStack: UIStackView?
var lastNavigationWasServerError = false
var reconnectBackgroundTimer: Timer? {
willSet {
if reconnectBackgroundTimer != newValue {
reconnectBackgroundTimer?.invalidate()
}
}
}
var connectionState: FrontEndConnectionState = .unknown
var loadActiveURLIfNeededInProgress = false
/// Track the timestamp of the last pull-to-refresh action
var lastPullToRefreshTimestamp: Date?
/// Handler for messages sent from the webview to the app
var webViewExternalMessageHandler: WebViewExternalMessageHandlerProtocol = WebViewExternalMessageHandler(
improvManager: ImprovManager.shared
)
/// Handler for gestures over the webview
let webViewGestureHandler = WebViewGestureHandler()
/// Handler for script messages sent from the webview to the app
let webViewScriptMessageHandler = WebViewScriptMessageHandler()
/// Defer showing the empty state until disconnected for 10 seconds (used by
/// updateFrontendConnectionState in WebViewController+ProtocolConformance.swift)
var emptyStateTimer: Timer?
/// Frontend notifies when connection is established or not
/// Each navigation resets this to false so we can show the empty state
var isConnected = false
var underlyingPreferredStatusBarStyle: UIStatusBarStyle = .lightContent
override var prefersStatusBarHidden: Bool {
Current.settingsStore.fullScreen || kioskPrefersStatusBarHidden
}
override var prefersHomeIndicatorAutoHidden: Bool {
Current.settingsStore.fullScreen || kioskPrefersHomeIndicatorAutoHidden
}
override var preferredStatusBarStyle: UIStatusBarStyle {
underlyingPreferredStatusBarStyle
}
#if targetEnvironment(macCatalyst)
override var keyCommands: [UIKeyCommand]? {
var commands = [
UIKeyCommand(
input: "c",
modifierFlags: [.shift, .command],
action: #selector(copyCurrentSelectedContent)
),
UIKeyCommand(
input: "v",
modifierFlags: [.shift, .command],
action: #selector(pasteContent)
),
UIKeyCommand(
input: "c",
modifierFlags: .command,
action: #selector(copyCurrentSelectedContent)
),
UIKeyCommand(
input: "v",
modifierFlags: .command,
action: #selector(pasteContent)
),
UIKeyCommand(
input: "r",
modifierFlags: .command,
action: #selector(refresh)
),
]
// Add find command for iOS 16+
if #available(iOS 16.0, *) {
commands.append(UIKeyCommand(
input: "f",
modifierFlags: .command,
action: #selector(showFindInteraction)
))
commands.append(UIKeyCommand(
input: "f",
modifierFlags: [.shift, .command],
action: #selector(showFindInteraction)
))
}
return commands
}
#endif
// MARK: - Initialization
init(server: Server, shouldLoadImmediately: Bool = false) {
self.server = server
self.leftEdgePanGestureRecognizer = with(UIScreenEdgePanGestureRecognizer()) {
$0.edges = .left
}
self.rightEdgeGestureRecognizer = with(UIScreenEdgePanGestureRecognizer()) {
$0.edges = .right
}
super.init(nibName: nil, bundle: nil)
userActivity = with(NSUserActivity(activityType: "\(AppConstants.BundleID).frontend")) {
$0.isEligibleForHandoff = true
}
leftEdgePanGestureRecognizer.addTarget(self, action: #selector(screenEdgeGestureRecognizerAction(_:)))
rightEdgeGestureRecognizer.addTarget(self, action: #selector(screenEdgeGestureRecognizerAction(_:)))
if shouldLoadImmediately {
loadViewIfNeeded()
loadActiveURLIfNeeded()
}
webViewExternalMessageHandler.webViewController = self
webViewGestureHandler.webView = self
webViewScriptMessageHandler.webView = self
}
convenience init?(restoring: WebViewRestorationType?, shouldLoadImmediately: Bool = false) {
if let server = restoring?.server ?? Current.servers.all.first {
self.init(server: server)
} else {
return nil
}
self.initialURL = restoring?.initialURL
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
keyboardFocusedElementScrollWorkItem?.cancel()
NotificationCenter.default.removeObserver(
self,
name: UIResponder.keyboardWillChangeFrameNotification,
object: nil
)
NotificationCenter.default.removeObserver(
self,
name: UIResponder.keyboardDidChangeFrameNotification,
object: nil
)
removeEmptyStateObservations()
self.urlObserver = nil
self.tokens.forEach { $0.cancel() }
}
static func makeWebViewConfiguration() -> WKWebViewConfiguration {
let config = WKWebViewConfiguration()
config.allowsInlineMediaPlayback = true
// Avoid interrupting background audio when the frontend loads media-capable elements.
config.mediaTypesRequiringUserActionForPlayback = .audio
return config
}
// MARK: - View Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
becomeFirstResponder()
observeConnectionNotifications()
let statusBarView = setupStatusBarView()
let config = Self.makeWebViewConfiguration()
let userContentController = setupUserContentController()
guard let wsBridgeJSPath = Bundle.main.path(forResource: "WebSocketBridge", ofType: "js"),
let wsBridgeJS = try? String(contentsOfFile: wsBridgeJSPath) else {
fatalError("Couldn't load WebSocketBridge.js for injection to WKWebView!")
}
userContentController.addUserScript(WKUserScript(
source: wsBridgeJS,
injectionTime: .atDocumentEnd,
forMainFrameOnly: true
))
userContentController.addUserScript(.init(
source: """
window.addEventListener("error", (e) => {
window.webkit.messageHandlers.logError.postMessage({
"message": JSON.stringify(e.message),
"filename": JSON.stringify(e.filename),
"lineno": JSON.stringify(e.lineno),
"colno": JSON.stringify(e.colno),
});
});
""",
injectionTime: .atDocumentStart,
forMainFrameOnly: true
))
config.userContentController = userContentController
config.applicationNameForUserAgent = HomeAssistantAPI.applicationNameForUserAgent
config.defaultWebpagePreferences.preferredContentMode = Current.isCatalyst ? .desktop : .mobile
webView = WKWebView(frame: view!.frame, configuration: config)
webView.isOpaque = false
view!.addSubview(webView)
setupGestures(numberOfTouchesRequired: 2)
setupGestures(numberOfTouchesRequired: 3)
setupEdgeGestures()
setupURLObserver()
webView.navigationDelegate = self
webView.uiDelegate = self
webView.scrollView.delegate = self
setupWebViewConstraints(statusBarView: statusBarView)
setupKeyboardAvoidance()
setupPullToRefresh()
setupEmptyState()
NotificationCenter.default.addObserver(
self,
selector: #selector(updateWebViewSettingsForNotification),
name: SettingsStore.webViewRelatedSettingDidChange,
object: nil
)
updateWebViewSettings(reason: .initial)
styleUI()
getLatestConfig()
if #available(iOS 16.4, *) {
webView.isInspectable = true
}
// Enable find interaction for iOS 16+
if #available(iOS 16.0, *) {
webView.isFindInteractionEnabled = true
}
postOnboardingNotificationPermission()
emptyStateObservations()
checkForLocalSecurityLevelDecisionNeeded()
setupKioskMode()
}
// Workaround for webview rotation issues: https://github.com/Telerik-Verified-Plugins/WKWebView/pull/263
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: { _ in
self.webView?.setNeedsLayout()
self.webView?.layoutIfNeeded()
}, completion: nil)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
loadActiveURLIfNeeded()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
updateDatabaseAndPanels()
// Refresh kiosk status bar state when view appears (e.g., after settings modal dismisses)
if KioskModeManager.shared.isKioskModeActive {
setNeedsStatusBarAppearanceUpdate()
navigationController?.setNeedsStatusBarAppearanceUpdate()
}
}
override func viewWillDisappear(_ animated: Bool) {
userActivity?.resignCurrent()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) {
webView.evaluateJavaScript("notifyThemeColors()", completionHandler: nil)
}
}
override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
if motion == .motionShake {
let action = Current.settingsStore.gestures[.shake] ?? .openDebug
webViewGestureHandler.handleGestureAction(action)
}
}
}