Skip to content

KeyboardChatScrollView: Fix scroll gestures on Android when scrollview content is shorter than the scrollview's viewport#1455

Draft
trcoffman wants to merge 2 commits into
kirillzyusko:mainfrom
trcoffman:fix-android-short-conversations
Draft

KeyboardChatScrollView: Fix scroll gestures on Android when scrollview content is shorter than the scrollview's viewport#1455
trcoffman wants to merge 2 commits into
kirillzyusko:mainfrom
trcoffman:fix-android-short-conversations

Conversation

@trcoffman
Copy link
Copy Markdown
Contributor

@trcoffman trcoffman commented May 11, 2026

📜 Description

Fixes #1453

Reworks the Android blank-space / extraContentPadding / keyboard-lift
behavior in KeyboardChatScrollView so it matches iOS. Four separate bugs
were stacked on top of each other; fixing one at a time required fixing
the rest.

💡 Motivation and Context

The original symptom was that on Android, a drag gesture started inside
the message area was ignored when the conversation was shorter than the
viewport — only drags starting inside the blank-space region scrolled.
Investigating that uncovered a chain of related bugs in the Android
blank-space path: mid-stream snap-to-0 as AI replies grew, wrong
isScrollAtEnd / visibleFraction / max-scroll math, and wildly wrong
keyboard-lift math on short content. The fixes bring Android's behavior
in line with the iOS per-frame model, where contentInset.bottom cleanly
separates the applied inset from the natural content size.

📢 Changelog

JS

  • useChatKeyboard: introduced a naturalContentHeight() helper on
    Android to subtract the applied inset (max(blankSpace, padding + extraContentPadding)) from size.value.height. Android reports
    contentSize as contentView.getHeight() which now includes the
    decorator's extension, whereas iOS reports natural content size
    independent of contentInset. Fed the adjusted value into
    isScrollAtEnd, clampScrollIfNeeded, getVisibleMinimumPaddingFraction,
    clampedScrollTarget, and the persistent-close maxScroll.
  • useChatKeyboard: replaced the visibleFraction >= 1 binary gate in
    onStart with proportional absorption matching iOS:
    absorbed = max(0, visibleFraction * blankSpace - extraContentPadding).
    Fixes short content being pushed off the top when the keyboard opened
    (blankSpace extends past the viewport on short content, so the
    fraction is never 1 and the gate was always skipping absorption).
  • useChatKeyboard: replaced minimumPaddingFractionOnOpen with a
    visiblePaddingOnOpen snapshot so onMove uses the same iOS formula
    as onStart (max(0, visiblePadding - extraContent) instead of
    (blankSpace - extraContent) * fraction). The two formulas only
    agreed at fraction === 1; at partial fractions the composer clipped
    the last message's timestamp.
  • useChatKeyboard: non-inverted onMove now skips e.duration === -1
    snap-back events (matching the inverted path), so dragging the scroll
    view with the keyboard open no longer snaps the scroll position back
    on finger-up.
  • useChatKeyboard: persistent-close now uses
    offsetBefore + actualOpenShift instead of
    offsetBefore + padding.value, so closing the keyboard doesn't jump
    content up when blankSpace absorbed part of the keyboard height.
  • useEndVisible: added an appliedInset option and a Platform-gated
    natural-content-height adjustment so isScrollAtEnd reports correctly
    on Android.
  • KeyboardChatScrollView: moved the totalPadding derived value above
    useEndVisible and threaded it through as appliedInset.
  • ScrollViewWithBottomPadding: minor comment cleanup clarifying that
    contentInset / scrollIndicatorInsets are iOS-only and
    contentInsetBottom / contentInsetTop are the Android
    KeyboardControllerScrollView props.

Android

  • ClippingScrollViewDecoratorView: the scroll-range extension now
    extends contentView.bottom directly (by the applied inset) instead
    of using scrollView.paddingBottom. Base Android
    ScrollView.computeVerticalScrollRange() derives from
    child.getBottom() and ignores padding, so paddingBottom alone
    didn't make canScrollVertically(1) return true inside the message
    area on short content — drags started there weren't intercepted.
    Extending child.bottom fixes that.
  • ClippingScrollViewDecoratorView: installs an
    OnLayoutChangeListener on the content view that re-applies the
    child.bottom extension on every Yoga relayout, and re-orders RN's
    own listener to fire after ours (RN's listener clamps scrollY
    against the current child.getHeight() at ReactScrollView.java:1275
    — if it ran first it would use the un-extended natural height and
    snap scrollY toward 0 as each AI reply chunk arrived). The listener
    is cleaned up in onDetachedFromWindow.

iOS

  • No iOS native changes. JS-side math was adjusted to match iOS's
    existing per-frame model (see "JS" above).

🤔 How Has This Been Tested?

📸 Screenshots (if appropriate):

📝 Checklist

  • CI successfully passed
  • I added new mocks and corresponding unit-tests if library API was changed

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 11, 2026

📊 Package size report

Current size Target Size Difference
325509 bytes 317457 bytes 8052 bytes 📈

@trcoffman trcoffman force-pushed the fix-android-short-conversations branch from 7efbe56 to 67f3405 Compare May 12, 2026 00:01
Rework the Android blank-space / extraContentPadding / keyboard-lift
behavior so it matches iOS. Four separate bugs were stacked on top of
each other; fixing one at a time required fixing the rest.

1. Drag-gesture ignored on short content (the original symptom).
   When the conversation was shorter than the viewport but was
   scrollable via blankSpace + extraContentPadding, Android's
   ScrollView.onInterceptTouchEvent refused to steal drags that
   originated inside the message area — only drags starting in the
   blank region scrolled. Root cause: short content's child.bottom is
   below the viewport, and canScrollVertically(1) at scrollY=0 returns
   false (range=child.bottom, extent=ScrollView.getHeight()). The
   decorator now extends child.bottom by the applied inset (instead of
   extending the scroll range via scrollView.paddingBottom), so
   scrollRange > viewport and intercept fires everywhere.

2. Scroll position snapping to 0 mid-stream when the AI reply grew.
   Both our decorator and RN's ReactScrollView need to react to content
   relayout: RN registers an OnLayoutChangeListener on the content view
   (at ReactScrollView.java:1145) whose clamp (line 1275) uses the
   current child.getHeight(). Our extension of `child.bottom` is
   overwritten by each Yoga relayout, so if RN's listener fires before
   we re-apply the extension, the clamp uses the un-extended natural
   height and snaps scrollY toward 0 as each reply chunk arrives.
   `decorateScrollView()` now removes RN's listener from the content
   view and re-adds it after our own so ours runs first, and our
   listener re-applies the `child.bottom` extension on every relayout.

3. `isScrollAtEnd` / visibleFraction / max-scroll math all wrong on
   Android. On Android, native scroll events report
   contentSize.height = contentView.getHeight() = bottom - top, which
   now includes the extension — whereas iOS reports pure natural
   content size independent of contentInset. All helpers that treated
   size.value.height as "natural content" were giving wrong answers.
   useChatKeyboard's Android variant now computes
   naturalContentHeight = size.value.height - appliedInset (where
   appliedInset = max(blankSpace, padding + extraContentPadding)) and
   feeds that into isScrollAtEnd, getVisibleMinimumPaddingFraction,
   clampScrollIfNeeded, clampedScrollTarget, and the persistent-close
   maxScroll. useEndVisible does the same adjustment Platform-gated.

4. Keyboard-lift math for short content was wildly wrong. Two sub-bugs:

   a) onStart used a binary-gate `visibleFraction >= 1` to decide
      whether to absorb blankSpace into the keyboard. For short content
      blankSpace extends past the viewport so fraction is never 1, and
      we'd shift by the full keyboard height (pushing content off the
      top). Switched to proportional absorption matching iOS's per-
      frame math: absorbed = max(0, visibleFraction * blankSpace -
      extraContentPadding).

   b) onMove used a different formula than onStart:
      (blankSpace - extraContent) * fraction
      versus onStart's
      fraction * blankSpace - extraContent.
      The two only agree when fraction == 1; at fraction=0.5 the onMove
      formula subtracted only half of extraContent worth of composer
      reservation, so the shift was ~(1 - fraction) * extraContent too
      small and the composer clipped the last message's timestamp.
      Replaced minimumPaddingFractionOnOpen with a visiblePaddingOnOpen
      snapshot so onMove uses the iOS formula with the open-time
      visiblePadding.

Also fixed two smaller bugs that fell out of the above:

- Non-inverted onMove did not skip `e.duration === -1` events.
  When the user dragged the scroll view with the keyboard open,
  Android emitted snap-back keyboard frames that ran our scrollTo and
  overrode the in-progress drag — on finger-up the scroll position
  snapped back to where the drag started. Now guarded, matching the
  inverted path.

- persistent-close used `keepAt = offsetBefore + padding.value`,
  which scrolled to the full-keyboard-height target even though the
  actual open shift was smaller (blankSpace absorbed part of the
  keyboard). When closing, content would jump UP instead of staying in
  place. Now uses offsetBefore + actualOpenShift — the real clamped
  shift captured at the end of the open animation.
@trcoffman trcoffman force-pushed the fix-android-short-conversations branch from 67f3405 to 644ed8f Compare May 12, 2026 00:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

KeyboardChatScrollView (blankSpace): On Android, scroll gesture doesn't work on content when is less tall than viewport.

1 participant