feat(app): dismiss the chat keyboard on a fast upward flick#2417
Open
nllptrx wants to merge 1 commit into
Open
feat(app): dismiss the chat keyboard on a fast upward flick#2417nllptrx wants to merge 1 commit into
nllptrx wants to merge 1 commit into
Conversation
Scrolling the history to read earlier messages left the keyboard up, so the visible transcript stayed cramped and the keyboard had to be closed by hand first — two steps for what should be one. React Native's keyboardDismissMode cannot express the wanted behaviour: "on-drag" fires on the first pixel and kills the keyboard on any peek-scroll, and "interactive" is broken on inverted lists. So the gesture is measured here: samples are taken from the scroll events and, at release, the speed over the drag's final stretch decides. Measuring at release rather than averaging the whole drag is what keeps a fast but controlled read-scroll from counting as a flick, since such a gesture decelerates before the finger lifts. Timestamps and offsets come from the events, never from a clock: with a busy JS thread the callbacks arrive in a burst long after the gesture, and wall-clock spacing then reads a calm scroll as a flick. The release offset comes from the end-drag event for the same reason — the last onScroll can be stale by the time a short flick lands. Android needs both the blur and the dismiss. Dismissing alone leaves the input focused and the keyboard inset applied, so the layout stays shifted with an empty gap where the keyboard was; blurring alone releases focus but leaves the IME on screen. Verified on an Android device with a Release build: a slow drag and a 0.6 dp/ms scroll keep the keyboard, a 2.9 dp/ms flick dismisses it and the composer settles back with no leftover gap. iOS was exercised by hand on device only, without automated coverage of the gesture itself.
nllptrx
force-pushed
the
feat/chat-keyboard-flick-dismiss
branch
from
July 24, 2026 22:43
c28b126 to
0844156
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Linked issue
Closes #2415
Type of change
What does this PR do
Scrolling the chat history back to read earlier messages left the keyboard up, so the transcript stayed squeezed into the space above it and the keyboard had to be closed by hand first. Two gestures for what should be one.
Now a fast upward flick over the history dismisses it, while a slow, or fast but controlled, read-scroll leaves it alone.
React Native's own
keyboardDismissModecannot express that:"on-drag"fires on the first pixel and kills the keyboard on any peek-scroll, and"interactive"is broken on inverted lists (the gesture directions are reversed, so it needs a long drag and feels janky). So the gesture is measured here instead: samples are taken from the scroll events, and at release the speed over the drag's final stretch decides. Measuring at release rather than averaging the whole drag is what separates a flic, finger lifted mid-motio, from a fast but controlled scroll, which decelerates before the finger lifts.Two details worth calling out, both of which were wrong in earlier revisions:
nativeEvent.timestamp, the native gesture time every platform stamps on the payload, not from a clock and not from the synthetictimeStamp. The synthetic one reads a differently spelled key, so it silently falls back to the dispatch clock, and with a busy JS thread the callbacks arrive bunched together, which reads a calm scroll as a flick.onScrollcan be stale by the time a short flick lands.Dismissal blurs the input and dismisses the keyboard, and only runs when a software keyboard actually occupies space. On Android, dismissing alone leaves the input focused with the keyboard inset still applied, so the layout stays shifted with an empty gap where the keyboard was; blurring alone releases focus but leaves the IME on screen. The occupies-space gate matters for hardware keyboards: the composer can be focused with no on-screen keyboard, and blurring there would stop the user typing to dismiss something that was never visible.
The speed logic lives in
scroll-keyboard-dismiss.tsas pure functions so it can be unit tested.How did you verify it
Behaviour on a physical Android device (Xiaomi 24069PC21G, Android 16), Release build, gestures driven with
adb shell input swipeat fixed durations over 233 dp (480 dpi, so 1 dp = 3 px):Screenshots for the three runs are attached below. I also confirmed the Release build regenerated
index.android.bundlefor each run rather than reusing a cached one, and that the earlier "empty gap after dismissal" state does not reproduce.Unit tests:
npx vitest run src/agent-stream/scroll-keyboard-dismiss.test.ts src/hooks/use-keyboard-shift-style.test.ts --project unit, 14 cases pass. They pin the behaviours that broke during development: a slow read-scroll staying below the threshold, a flick crossing it, a fast-but-decelerating drag that would pass on a whole-drag average but not at release, a drag toward newer messages staying negative, the fallback for gestures too short to sample, stepping back a sample when the release lands right after one, delayed and bunched callbacks not becoming a flick, and the native timestamp being preferred over the dispatch clock.npm run typecheck,npm run lintandnpm run format:checkall pass at the repo root.See Android
-5808126259059367132.mp4
See IOS
ScreenRecording_07-24-2026.23-52-01_1.1.mp4
Not affected, so not tested: web and desktop.
strategy-native.tsxis the native render strategy only; the web strategy is a separate module.Risk surface
Known limitation: the speed is derived from scroll offset, so a flick performed while the offset cannot move, the list already at the oldest edge, or a transcript too short to scroll, is not detected and leaves the keyboard up. On iOS the rubber-band still moves the offset, so in practice this is an Android edge case. Both platforms do expose a native
velocityon the end-drag payload, so a follow-up could cover it, but the sign and unit conventions differ per platform and need device verification.The threshold (1.5 points/ms at release) is a single constant, calibrated on one Android device and one iPhone.
Checklist
npm run typecheckpassesnpm run lintpassesnpm run formatran (Biome)