mirror of
https://github.com/home-assistant/iOS.git
synced 2026-04-13 01:12:53 -05:00
<!-- 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. -->
168 lines
7.0 KiB
Swift
168 lines
7.0 KiB
Swift
import Shared
|
|
import UIKit
|
|
@preconcurrency import WebKit
|
|
|
|
// MARK: - Web View Configuration & Setup
|
|
|
|
enum WebViewKeyboardAvoidance {
|
|
static func keyboardAnimationDuration(from notification: Notification) -> TimeInterval {
|
|
guard let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber else {
|
|
return 0
|
|
}
|
|
|
|
return duration.doubleValue
|
|
}
|
|
|
|
static func keyboardAnimationOptions(from notification: Notification) -> UIView.AnimationOptions {
|
|
guard let curve = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber else {
|
|
return .curveEaseInOut
|
|
}
|
|
|
|
return UIView.AnimationOptions(rawValue: UInt(curve.uintValue << 16))
|
|
}
|
|
|
|
static func keyboardOverlapHeight(in view: UIView, notification: Notification) -> CGFloat {
|
|
guard let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else {
|
|
return 0
|
|
}
|
|
|
|
return view.bounds.intersection(view.convert(keyboardFrame, from: nil)).height
|
|
}
|
|
}
|
|
|
|
extension WKWebView {
|
|
func scrollFocusedElementIntoView(logError: @escaping (Error) -> Void = { _ in }) {
|
|
evaluateJavaScript(WebViewJavascriptCommands.scrollFocusedElementIntoView) { _, error in
|
|
if let error {
|
|
logError(error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension WebViewController {
|
|
static func makeWebViewBottomConstraint(for webView: WKWebView, in view: UIView) -> NSLayoutConstraint {
|
|
webView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
|
}
|
|
|
|
func setupKeyboardAvoidance() {
|
|
guard !Current.isCatalyst else { return }
|
|
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(handleKeyboardWillChangeFrame(_:)),
|
|
name: UIResponder.keyboardWillChangeFrameNotification,
|
|
object: nil
|
|
)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(handleKeyboardDidChangeFrame(_:)),
|
|
name: UIResponder.keyboardDidChangeFrameNotification,
|
|
object: nil
|
|
)
|
|
}
|
|
|
|
@objc private func handleKeyboardWillChangeFrame(_ notification: Notification) {
|
|
updateWebViewBottomConstraint(using: notification)
|
|
scheduleFocusedElementScroll(using: notification)
|
|
}
|
|
|
|
@objc private func handleKeyboardDidChangeFrame(_ notification: Notification) {
|
|
guard WebViewKeyboardAvoidance.keyboardOverlapHeight(in: view, notification: notification) > 0 else { return }
|
|
scrollFocusedElementIntoView()
|
|
}
|
|
|
|
func scheduleFocusedElementScroll(using notification: Notification) {
|
|
let overlapHeight = WebViewKeyboardAvoidance.keyboardOverlapHeight(in: view, notification: notification)
|
|
keyboardFocusedElementScrollWorkItem?.cancel()
|
|
|
|
guard overlapHeight > 0 else {
|
|
keyboardFocusedElementScrollWorkItem = nil
|
|
return
|
|
}
|
|
|
|
let workItem = DispatchWorkItem { [weak self] in
|
|
self?.scrollFocusedElementIntoView()
|
|
}
|
|
keyboardFocusedElementScrollWorkItem = workItem
|
|
|
|
let delay = WebViewKeyboardAvoidance.keyboardAnimationDuration(from: notification)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
|
|
}
|
|
|
|
func updateWebViewBottomConstraint(using notification: Notification) {
|
|
let overlapHeight = WebViewKeyboardAvoidance.keyboardOverlapHeight(in: view, notification: notification)
|
|
let duration = WebViewKeyboardAvoidance.keyboardAnimationDuration(from: notification)
|
|
let options = WebViewKeyboardAvoidance.keyboardAnimationOptions(from: notification)
|
|
|
|
webViewBottomConstraint?.constant = -overlapHeight
|
|
|
|
UIView.animate(withDuration: duration, delay: 0, options: [options, .beginFromCurrentState]) { [weak self] in
|
|
self?.view.layoutIfNeeded()
|
|
}
|
|
}
|
|
|
|
private func scrollFocusedElementIntoView() {
|
|
webView.scrollFocusedElementIntoView { error in
|
|
Current.Log.error("Error scrolling focused element into view: \(error)")
|
|
}
|
|
}
|
|
|
|
func setupUserContentController() -> WKUserContentController {
|
|
let userContentController = WKUserContentController()
|
|
let safeScriptMessageHandler = SafeScriptMessageHandler(server: server, delegate: webViewScriptMessageHandler)
|
|
userContentController.add(safeScriptMessageHandler, name: "getExternalAuth")
|
|
userContentController.add(safeScriptMessageHandler, name: "revokeExternalAuth")
|
|
userContentController.add(safeScriptMessageHandler, name: "externalBus")
|
|
userContentController.add(safeScriptMessageHandler, name: "updateThemeColors")
|
|
userContentController.add(safeScriptMessageHandler, name: "logError")
|
|
return userContentController
|
|
}
|
|
|
|
func setupWebViewConstraints(statusBarView: UIView) {
|
|
webView.translatesAutoresizingMaskIntoConstraints = false
|
|
webView.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
|
|
webView.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
|
|
webViewBottomConstraint = Self.makeWebViewBottomConstraint(for: webView, in: view)
|
|
webViewBottomConstraint?.isActive = true
|
|
webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
|
|
// Create the top constraint based on edge-to-edge setting
|
|
// On iOS (not Catalyst), edge-to-edge mode pins the webview to the top of the view
|
|
// On Catalyst, we always show the status bar buttons, so we pin to statusBarView
|
|
// Also use edge-to-edge behavior when fullScreen is enabled (status bar hidden)
|
|
let edgeToEdge = (Current.settingsStore.edgeToEdge || Current.settingsStore.fullScreen) && !Current.isCatalyst
|
|
if edgeToEdge {
|
|
webViewTopConstraint = webView.topAnchor.constraint(equalTo: view.topAnchor)
|
|
statusBarView.isHidden = true
|
|
} else {
|
|
webViewTopConstraint = webView.topAnchor.constraint(equalTo: statusBarView.bottomAnchor)
|
|
statusBarView.isHidden = false
|
|
}
|
|
webViewTopConstraint?.isActive = true
|
|
}
|
|
|
|
func setupURLObserver() {
|
|
urlObserver = webView.observe(\.url) { [weak self] webView, _ in
|
|
guard let self else { return }
|
|
|
|
guard let currentURL = webView.url?.absoluteString.replacingOccurrences(of: "?external_auth=1", with: ""),
|
|
let cleanURL = URL(string: currentURL), let scheme = cleanURL.scheme else {
|
|
return
|
|
}
|
|
|
|
guard ["http", "https"].contains(scheme) else {
|
|
Current.Log.warning("Was going to provide invalid URL to NSUserActivity! \(currentURL)")
|
|
return
|
|
}
|
|
|
|
userActivity?.webpageURL = cleanURL
|
|
userActivity?.userInfo = [
|
|
RestorableStateKey.lastURL.rawValue: cleanURL,
|
|
RestorableStateKey.server.rawValue: server.identifier.rawValue,
|
|
]
|
|
userActivity?.becomeCurrent()
|
|
}
|
|
}
|
|
}
|