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>
3 files changed,
+37
−0
changelog/2026-07-03-swipe-down-to-dismiss-keyboard.md+1 −0ios/LChat/FeedWebCache.swift+7 −0ios/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))") |
| } |