eleven / ocho Public

ios: smoother feed scrolling around the keyboard (#197)

Two fixes in the embedded chat feed's native inset pipeline:

- Dismissing the keyboard no longer makes the feed flash up and down. The
  native re-pin now fires only when the reserved space GROWS (keyboard rise,
  composer growth); while it SHRINKS the browser already clamps scrollTop, so
  the per-frame re-pin was racing the shrink and jittering the feed.
- Raising the keyboard now scrolls the feed to the bottom so the newest message
  rests above the composer instead of behind the keyboard. The per-frame re-pin
  can't keep up with the keyboard's ~300px growth in one animation, so we snap
  to the fresh bottom once it settles (only if the feed was already at bottom).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 55ec7b2b9f817d859b86181147a82a045a535393 parent a19eaf4
2 files changed, +41 −6
  • changelog/2026-07-05-feed-dismiss-flash.md +1 −0
  • ios/LChat/Views/ChatView.swift +40 −6
diff --git a/changelog/2026-07-05-feed-dismiss-flash.md b/changelog/2026-07-05-feed-dismiss-flash.md
new file mode 100644
index 0000000..de12c34
--- /dev/null
+++ b/changelog/2026-07-05-feed-dismiss-flash.md
@@ -0,0 +1 @@
+Fixed a flicker in the iOS app where the messages would jitter up and down as the keyboard was swiped away — the feed now settles smoothly when the keyboard closes.
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift
index e20c566..e9a6ad1 100644
--- a/ios/LChat/Views/ChatView.swift
+++ b/ios/LChat/Views/ChatView.swift
@@ -458,23 +458,32 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav
func setInsets(top: CGFloat, bottom: CGFloat) {
guard top != topInset || bottom != bottomInset else { return }
+ // Re-pin the feed to the bottom only when the reserved space GROWS (keyboard
+ // rise, composer growth) — then scrollTop must chase the now-taller content.
+ // When it SHRINKS (the keyboard dismissing, esp. an interactive drag firing
+ // per-frame inset changes) the browser already clamps scrollTop to the new
+ // bottom on its own; re-pinning every frame instead races that shrink and
+ // makes the feed flash up and down. So: grow → repin, shrink → let it be.
+ let grew = bottom > bottomInset || top > topInset
topInset = top
bottomInset = bottom
- applyInsets()
+ applyInsets(repin: grew)
// The keyboard animates ~0.25s of per-frame inset changes; run one more
- // apply after it settles so a lost race mid-animation can't strand the
- // feed a few px shy of the bottom.
+ // apply (always re-pinning) after it settles so a lost race mid-animation
+ // can't strand the feed a few px shy of the bottom.
settle?.cancel()
- let task = DispatchWorkItem { [weak self] in self?.applyInsets() }
+ let task = DispatchWorkItem { [weak self] in self?.applyInsets(repin: true) }
settle = task
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: task)
}
- private func applyInsets() {
+ private func applyInsets(repin: Bool = true) {
// Keep the feed pinned to the bottom through padding changes (keyboard
// rise, composer growth). Judge "was at bottom" BEFORE the padding
// mutates — measured after, the gap equals the padding just added and
// the check always fails, stranding the newest message under the bars.
+ // `repin` is false while the reserved space is shrinking (a dismiss): the
+ // browser clamps scrollTop for us, so a manual snap only causes jitter.
let js = """
(function(){
// The live chat.js feed is .chatapp-feed (id-less); the original #feed
@@ -484,7 +493,7 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav
var r = document.documentElement.style;
r.setProperty('--native-top-inset','\(Int(topInset))px');
r.setProperty('--native-bottom-inset','\(Int(bottomInset))px');
- if (atBottom) requestAnimationFrame(function(){ f.scrollTop = f.scrollHeight; });
+ if (\(repin ? "true" : "false") && atBottom) requestAnimationFrame(function(){ f.scrollTop = f.scrollHeight; });
})()
"""
webView?.evaluateJavaScript(js)
@@ -509,6 +518,9 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav
NotificationCenter.default.addObserver(
self, selector: #selector(applyKeyboardDismiss),
name: UIResponder.keyboardWillShowNotification, object: nil)
+ NotificationCenter.default.addObserver(
+ self, selector: #selector(keyboardWillRise),
+ name: UIResponder.keyboardWillShowNotification, object: nil)
}
@objc func applyKeyboardDismiss() {
@@ -522,6 +534,28 @@ final class WebBridge: NSObject, ObservableObject, WKScriptMessageHandler, WKNav
for sub in view.subviews { setInteractiveDismiss(in: sub) }
}
+ // On keyboard rise the feed should re-pin to the bottom so the newest message
+ // rests above the composer, not behind the keyboard. The per-frame inset re-pin
+ // (applyInsets) can't manage this alone: the keyboard grows the reserved space by
+ // ~300px in one animation, which outruns the re-pin and trips its "at bottom"
+ // check, stranding the feed mid-rise. So: capture whether the feed was at the
+ // bottom BEFORE the rise, and once the keyboard + inset animation has settled,
+ // snap it to the now-taller bottom. Gated on "was at bottom" so a reader who
+ // scrolled up into history isn't yanked down just for tapping the field.
+ @objc func keyboardWillRise() {
+ guard let webView, webView.window != nil else { return }
+ let probe = "(function(){var f=document.querySelector('.chatapp-feed')||document.getElementById('feed');return f?(f.scrollHeight-f.scrollTop-f.clientHeight<120):false;})()"
+ webView.evaluateJavaScript(probe) { [weak self] result, _ in
+ guard (result as? Bool) == true else { return }
+ // After the rise (~0.25s) + the inset settle (0.35s), the padding is fully
+ // grown; read the fresh scrollHeight and land on the true bottom.
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
+ self?.webView?.evaluateJavaScript(
+ "(function(){var f=document.querySelector('.chatapp-feed')||document.getElementById('feed');if(f)requestAnimationFrame(function(){f.scrollTop=f.scrollHeight;});})()")
+ }
+ }
+ }
+
// Message links and link-preview cards render as target="_blank" anchors
// (chat.js), which WebKit routes here as a new-window request, not a normal
// navigation — with no WKUIDelegate the tap fell on the floor and nothing