From 7506eaa4aaa7a6733f0dd0c061b4d0f645a3c9a0 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 10 Nov 2026 09:50:29 +0100 Subject: [PATCH] fix(native): the sign-in bar's room for the keyboard holds, and Android 7 to 10 see the keyboard On iOS the keyboard plugin sizes the body and zeroes the scroll inset in a frame update that runs after the keyboard is up, so the room made for it was taken back. The inset is now kept while a sign-in page shows, the body gets its height back after that update, and with the keyboard down the page can scroll past the bar. On Android before 11 the keyboard went unseen in the insets Capacitor passes on, so the bar stayed over the form; it is read off the window's own insets now. --- .../java/com/liketrek/trek/MainActivity.java | 12 ++- .../App/App/TrekBridgeViewController.swift | 82 ++++++++++++++----- 2 files changed, 68 insertions(+), 26 deletions(-) diff --git a/client/android/app/src/main/java/com/liketrek/trek/MainActivity.java b/client/android/app/src/main/java/com/liketrek/trek/MainActivity.java index 836103180..14a83a6b1 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/MainActivity.java +++ b/client/android/app/src/main/java/com/liketrek/trek/MainActivity.java @@ -10,7 +10,6 @@ import android.view.KeyboardShortcutGroup; import android.view.Menu; import android.view.View; import android.view.ViewGroup; -import android.view.WindowInsets; import android.widget.Button; import android.widget.FrameLayout; import android.widget.LinearLayout; @@ -146,9 +145,14 @@ public class MainActivity extends BridgeActivity { root.addView(bar, params); // Above the gesture bar, and above the keyboard while it is up. bar.setOnApplyWindowInsetsListener((v, insets) -> { - boolean modern = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R; - int bottom = modern ? insets.getInsets(WindowInsets.Type.systemBars()).bottom : insets.getStableInsetBottom(); - boolean imeVisible = modern ? insets.isVisible(WindowInsets.Type.ime()) : insets.getSystemWindowInsetBottom() > bottom * 3; + // Read off the window's own insets: Capacitor hands the views below the + // decor a copy with the system bars zeroed, and before Android 11 that + // takes the keyboard with it. + androidx.core.view.WindowInsetsCompat window = androidx.core.view.ViewCompat.getRootWindowInsets(getWindow().getDecorView()); + int bottom = window != null + ? window.getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars()).bottom + : insets.getStableInsetBottom(); + boolean imeVisible = window != null && window.isVisible(androidx.core.view.WindowInsetsCompat.Type.ime()); if (imeVisible != keyboardUp) { keyboardUp = imeVisible; updateSignInBar(); diff --git a/client/ios/App/App/TrekBridgeViewController.swift b/client/ios/App/App/TrekBridgeViewController.swift index 7ef4ab3fe..724e30acd 100644 --- a/client/ios/App/App/TrekBridgeViewController.swift +++ b/client/ios/App/App/TrekBridgeViewController.swift @@ -66,17 +66,28 @@ class TrekBridgeViewController: CAPBridgeViewController { /// which TREK's own script makes everywhere else (native/keepFieldInView.ts). private var signInBar: UIView? private var urlObservation: NSKeyValueObservation? + private var insetObservation: NSKeyValueObservation? private var onSignInPage = false private var keyboardUp = false + /// How far the keyboard reaches into the view while it is up. + private var keyboardRoom: CGFloat = 0 + /// Whether the bottom inset is this controller's, so leaving the page puts it back. + private var ownsInset = false + private var bodyReset: DispatchWorkItem? private func watchSignInPages() { urlObservation = webView?.observe(\.url, options: [.initial, .new]) { [weak self] webView, _ in let signIn = TrekServerStore.isSignInHost(webView.url) DispatchQueue.main.async { self?.signInPageChanged(signIn) } } + // The keyboard plugin zeroes the scroll inset on every frame change it + // makes, the last one a while after the keyboard is up. On a sign-in + // page the inset wanted here is put back each time. + insetObservation = webView?.scrollView.observe(\.contentInset, options: [.new]) { [weak self] _, _ in + DispatchQueue.main.async { self?.applyInset() } + } let center = NotificationCenter.default - center.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil) - center.addObserver(self, selector: #selector(keyboardDidShow(_:)), name: UIResponder.keyboardDidShowNotification, object: nil) + center.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil) center.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil) } @@ -89,34 +100,61 @@ class TrekBridgeViewController: CAPBridgeViewController { /// Out of the way while the keyboard is up: the form needs the room. private func updateSignInBar() { signInBar?.isHidden = !(onSignInPage && !keyboardUp) + applyInset() } - @objc private func keyboardWillShow() { - keyboardUp = true - updateSignInBar() - } - - /// The keyboard plugin sizes the body to the visible area and resets the - /// scroll inset on every frame change, which left a foreign page with no way - /// to scroll a button under the keyboard into view. After its last change, - /// the page gets its height back and as much room below as the keyboard takes. - @objc private func keyboardDidShow(_ note: Notification) { - guard onSignInPage, let frame = note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } - let room = max(0, frame.height - view.safeAreaInsets.bottom) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - guard let self, self.onSignInPage, self.keyboardUp, let webView = self.webView else { return } - webView.evaluateJavaScript("document.body.style.height = ''", completionHandler: nil) - webView.scrollView.contentInset.bottom = room - webView.scrollView.verticalScrollIndicatorInsets.bottom = room + /// On a sign-in page the page can scroll past what covers its end: the + /// keyboard while it is up, the bar otherwise. TREK's own pages are left alone. + private func applyInset() { + guard let scrollView = webView?.scrollView else { return } + let wanted: CGFloat + if onSignInPage { + if keyboardUp { + wanted = keyboardRoom + } else if let bar = signInBar { + view.layoutIfNeeded() + wanted = max(0, view.bounds.height - bar.frame.minY) + } else { + wanted = 0 + } + ownsInset = true + } else if ownsInset { + wanted = 0 + ownsInset = false + } else { + return } + if scrollView.contentInset.bottom != wanted { scrollView.contentInset.bottom = wanted } + if scrollView.verticalScrollIndicatorInsets.bottom != wanted { scrollView.verticalScrollIndicatorInsets.bottom = wanted } + } + + /// The keyboard plugin also sizes the body to the area above the keyboard, + /// after the keyboard's animation and a further 0.2 s. On a sign-in page the + /// body gets its height back once that has run, so the button under the + /// keyboard can be scrolled into view through the inset above. + @objc private func keyboardWillShow(_ note: Notification) { + keyboardUp = true + if let frame = note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect { + keyboardRoom = max(0, view.bounds.height - view.convert(frame, from: nil).minY) + } + bodyReset?.cancel() + if onSignInPage { + let duration = note.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double ?? 0.25 + let reset = DispatchWorkItem { [weak self] in + guard let self, self.onSignInPage, self.keyboardUp, let webView = self.webView else { return } + webView.evaluateJavaScript("document.body.style.height = ''", completionHandler: nil) + self.applyInset() + } + bodyReset = reset + DispatchQueue.main.asyncAfter(deadline: .now() + duration + 0.35, execute: reset) + } + updateSignInBar() } @objc private func keyboardWillHide() { keyboardUp = false + bodyReset?.cancel() updateSignInBar() - guard onSignInPage, let webView else { return } - webView.scrollView.contentInset.bottom = 0 - webView.scrollView.verticalScrollIndicatorInsets.bottom = 0 } private func makeSignInBar() -> UIView {