eleven / messenger Public

ios: swipe the feed down to dismiss the keyboard (#130)

With the keyboard open, dragging the feed down until the finger meets the
keyboard now carries it away with the drag (UIScrollView's .interactive
mode), instead of leaving it stranded open.

The feed scrolls inside a nested WebKit overflow scroll view (the page body
is pinned at 100%, so the webview's own scrollView never scrolls), so
.interactive is re-applied to that inner scroller on page load, feed
re-attach, and keyboard-show.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell cae02d8859f52340452bc4ceb6e11e91ed2ca31c parent 147e22d
3 files changed, +37 −0
  • changelog/2026-07-03-swipe-down-to-dismiss-keyboard.md +1 −0
  • ios/LChat/FeedWebCache.swift +7 −0
  • ios/LChat/Views/ChatView.swift +29 −0
diff --git a/changelog/2026-07-03-swipe-down-to-dismiss-keyboard.md b/changelog/2026-07-03-swipe-down-to-dismiss-keyboard.md
new file mode 100644
index 0000000..bf73c4d
--- /dev/null
+++ b/changelog/2026-07-03-swipe-down-to-dismiss-keyboard.md
@@ -0,0 +1 @@
+In the iOS app, you can now swipe the keyboard away: with it open, drag the messages downward and the keyboard slides out with your finger as you reach it — the way Messages does it — instead of staying stuck open.
diff --git a/ios/LChat/FeedWebCache.swift b/ios/LChat/FeedWebCache.swift
index de634f3..471900d 100644
--- a/ios/LChat/FeedWebCache.swift
+++ b/ios/LChat/FeedWebCache.swift
@@ -103,6 +103,13 @@ final class FeedWebCache {
// The shell owns the insets (they ride in as CSS vars) — don't let WebKit
// add its own on top when the webview runs full-bleed.
web.scrollView.contentInsetAdjustmentBehavior = .never
+ // A downward drag that meets the keyboard should carry it away with the
+ // finger (the Messages feel). The feed scrolls in a nested WebKit overflow
+ // scroll view — the page body is pinned at 100% so this outer scrollView
+ // never scrolls — so the bridge re-applies .interactive to that inner
+ // scroller on load / attach / keyboard-show (see WebBridge below).
+ web.scrollView.keyboardDismissMode = .interactive
+ bridge.enableInteractiveKeyboardDismiss()
#if DEBUG
if #available(iOS 16.4, macCatalyst 16.4, *) { web.isInspectable = true }
#endif
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift
index 78b7439..6e2b78a 100644
--- a/ios/LChat/Views/ChatView.swift
+++ b/ios/LChat/Views/ChatView.swift
@@ -414,6 +414,34 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
applyInsets()
+ applyKeyboardDismiss()
+ }
+
+ // MARK: Interactive keyboard dismissal
+ //
+ // With the keyboard up, dragging the feed down until the finger meets the
+ // keyboard should carry it away with the drag, not leave it stranded open.
+ // That's UIScrollView's `.interactive` mode — but the feed scrolls inside a
+ // nested overflow scroll view WebKit builds for `#feed` (the page body is
+ // pinned at 100%, so the webview's own scrollView never scrolls). WebKit
+ // creates that inner scroller lazily and can rebuild it, so re-apply the mode
+ // whenever the page loads, the feed re-attaches, and — the moment that counts —
+ // the keyboard is about to show.
+ func enableInteractiveKeyboardDismiss() {
+ NotificationCenter.default.addObserver(
+ self, selector: #selector(applyKeyboardDismiss),
+ name: UIResponder.keyboardWillShowNotification, object: nil)
+ }
+
+ @objc func applyKeyboardDismiss() {
+ // Only the on-screen feed owns the visible keyboard; skip parked webviews.
+ guard let webView, webView.window != nil else { return }
+ setInteractiveDismiss(in: webView.scrollView)
+ }
+
+ private func setInteractiveDismiss(in view: UIView) {
+ (view as? UIScrollView)?.keyboardDismissMode = .interactive
+ for sub in view.subviews { setInteractiveDismiss(in: sub) }
}
// Called when this thread's (possibly cached) webview lands on screen. A parked
@@ -427,6 +455,7 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav
// `force` (a notification tap) overrides that "scrolled up" hold: the message
// you were pinged about is the newest one, so jump to it regardless.
func didAttach(force: Bool = false) {
+ applyKeyboardDismiss() // the inner scroller now exists + is on screen
webView?.evaluateJavaScript(
"window.lchatNative && window.lchatNative.didAttach && window.lchatNative.didAttach(\(force))")
}