From f1882313ed8455baa8ca31e5e52d7cf5aff27771 Mon Sep 17 00:00:00 2001 From: "expo-tuft[bot]" <288127324+expo-tuft[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:27:40 +0530 Subject: [PATCH] [ui][android] Don't forward a disallow-intercept release to Compose mid-gesture (#49520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Why Fixes #49510. On Android, a drag that starts on a `TextInput` inside a Compose `Host` does not scroll the surrounding `ScrollView`. The same drag started in the gap between two inputs scrolls, and both drags scroll outside a `Host`. The cause is a claim/release flip inside a single move event: 1. [`ReactEditText.onTouchEvent`](https://github.com/facebook/react-native/blob/v0.86.3/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt#L296-L320) calls `requestDisallowInterceptTouchEvent(true)` on `ACTION_DOWN`, then `(false)` on the first `ACTION_MOVE` when the field cannot scroll its own content — the ordinary single-line case. 2. `TouchDispatchingRootViewGroup` forwards both to its parent, which is Compose's `AndroidViewHolder`. 3. That sets `PointerInteropFilter.disallowIntercept`. On the initial pass the filter dispatches the move into the hosted view and **consumes** it; on the final pass `changes.fastAny { it.isConsumed } && !disallowIntercept` now holds — it reads its own consumption as a Compose claim and calls `stopDispatching`, sending `ACTION_CANCEL` into the hosted subtree. 4. The React Native subtree receives nothing more for that gesture, so the `ScrollView` never scrolls. A drag from the gap never flips the flag, so it never reaches step 3 — which is exactly the asymmetry in the report. # How `TouchDispatchingRootViewGroup` no longer forwards a `requestDisallowInterceptTouchEvent(false)` to Compose after a `true` in the same gesture. The latch resets on `ACTION_DOWN`. React Native ancestors inside the wrapper still receive the release; Compose clears its own flag when the gesture ends. **Tradeoff worth a maintainer's call:** the latch also stops a Compose parent, such as `HorizontalPager`, from taking over a gesture that began on a hosted `TextInput`. Compose cancels that subtree today, so nothing usable is lost in practice — but it is a real behavioural change, which is why this is a draft. # Test Plan Measured A/B on a hosted Android emulator with EAS `preview` builds, using the same slow 800 ms drag in both arms: | Arm | Gesture | Stock | Patched | | --- | --- | --- | --- | | No sheet, no `Host` | drag from an input | scrolls | scrolls | | No sheet, in `Host` | drag from an input | **no scroll** | scrolls | | No sheet, in `Host` | drag from the gap | scrolls | scrolls | | No sheet, in `Host` | tap an input | focuses | focuses | | universal `BottomSheet` | drag from an input | **no scroll** | scrolls | | Compose `ModalBottomSheet` | drag from an input | **no scroll** | scrolls | | universal `BottomSheet` | drag on the drag handle | dismisses | dismisses | A downward drag from a `TextInput` also scrolls the hosted list back up on the patched build, so the fix is not direction-specific. `forwardedDisallowIntercept` was confirmed present in the patched APK's dex and absent from the stock one. In `packages/expo-ui`: `typecheck` clean, `lint --max-warnings 0` clean, `pnpm test` 31 suites / 174 tests passing. # Checklist - [x] `CHANGELOG.md` entry added. - [ ] `HorizontalPager` tradeoff above needs a maintainer's sign-off. --- Diagnosis and fix by `@expo-bot` via `/verify --fix` on #49510 (branch `expo-bot:verify/49510-33192081914`); it withheld the PR because three open PRs touch `RNHostView.kt`. I checked those diffs — #48355 adds `dispatchTouchEventToJS`/`reissueCancel`, and #49427/#49483 are `matchContents` layout. None touch `requestDisallowInterceptTouchEvent`, so this is not a duplicate. Co-authored-by: expo-bot --- packages/expo-ui/CHANGELOG.md | 1 + .../main/java/expo/modules/ui/RNHostView.kt | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/expo-ui/CHANGELOG.md b/packages/expo-ui/CHANGELOG.md index 91c5717461aef0..4862074f544b90 100644 --- a/packages/expo-ui/CHANGELOG.md +++ b/packages/expo-ui/CHANGELOG.md @@ -28,6 +28,7 @@ ### 🐛 Bug fixes +- [Android] Fix a drag that starts on a hosted `TextInput` not scrolling the `ScrollView` around it. React Native's text input asks its ancestors not to intercept the gesture, then releases them one move later, and Jetpack Compose read that release as "Compose claimed the gesture" and cancelled the hosted subtree. `RNHostView` no longer passes such a release on to Compose. - [Android] Fix the system status bar and navigation bar turning light while a `BottomSheet` or `ModalBottomSheet` with a custom dark background is open. A custom container color matches no color-scheme role, so the default content color fell back to black and Material3 themed the sheet window's system bars from it. The default content color is now derived from the container color's luminance, so it also contrasts with a custom background. ([#49394](https://github.com/expo/expo/pull/49394) by [@expo-bot](https://github.com/expo-bot)) - [iOS] Fixed a crash when a focused `TextField` or `SecureField` is unmounted inside a list row that is being removed, for example closing a modal with the keyboard up on a field nested in `SwipeActions`. Hosted text inputs now blur before React removes their native views. ([#49348](https://github.com/expo/expo/issues/49348) by [@nishan](https://github.com/intergalacticspacehighway)) ([#49357](https://github.com/expo/expo/pull/49357) by [@expo-tuft[bot]](https://github.com/apps/expo-tuft)) - Fixed the keyboard staying open when tapping outside a text field hosted by ``. A parent `ScrollView`'s tap-to-dismiss, its `keyboardShouldPersistTaps` setting, and `Keyboard.dismiss()` now reach hosted text fields. React Native treats the whole `` as the input, so a tap on other content inside the same host still keeps the keyboard open. ([#48788](https://github.com/expo/expo/pull/48788) by [@intergalacticspacehighway](https://github.com/intergalacticspacehighway)) diff --git a/packages/expo-ui/android/src/main/java/expo/modules/ui/RNHostView.kt b/packages/expo-ui/android/src/main/java/expo/modules/ui/RNHostView.kt index 4985363753510f..299a25c6578e1e 100644 --- a/packages/expo-ui/android/src/main/java/expo/modules/ui/RNHostView.kt +++ b/packages/expo-ui/android/src/main/java/expo/modules/ui/RNHostView.kt @@ -270,6 +270,10 @@ private class TouchDispatchingRootViewGroup( // True if the sheet consumed scroll on the most recent drag frame; drives the settle decision. private var sheetMovingOnLastDragFrame = false + // True once a descendant asked us to stop ancestors from intercepting this gesture, so a later + // release from a different descendant doesn't reach Compose. See requestDisallowInterceptTouchEvent. + private var forwardedDisallowIntercept = false + // True once a fling was dispatched this gesture, so the gentle-release settle doesn't double-fire. private var flingHandledThisGesture = false @@ -336,6 +340,7 @@ private class TouchDispatchingRootViewGroup( trackingGestureOffset = true sheetMovingOnLastDragFrame = false flingHandledThisGesture = false + forwardedDisallowIntercept = false } // While a nested scroll is in flight the sheet may be sliding this whole view up/down. Re-express @@ -440,6 +445,19 @@ private class TouchDispatchingRootViewGroup( // yields. But don't call super: setting our own FLAG_DISALLOW_INTERCEPT would skip // onInterceptTouchEvent, which must keep firing to dispatch touches to JS (the reason #43716 // added this override). + // + // Never forward a release after a claim in the same gesture. Compose's `AndroidView` interop + // cancels this subtree when the flag goes true then false inside one move event: it dispatches + // the move and consumes it on the initial pass, then reads that same consumption on the final + // pass as "Compose claimed the gesture" and sends ACTION_CANCEL down here. `ReactEditText` makes + // exactly that flip — it claims on ACTION_DOWN and releases on the first ACTION_MOVE — so a drag + // that starts on a TextInput killed the hosted ScrollView. The release is meant for the React + // Native ancestors inside this wrapper, which still get it; Compose clears its own flag when the + // gesture ends. + if (!disallowIntercept && forwardedDisallowIntercept) { + return + } + forwardedDisallowIntercept = disallowIntercept parent?.requestDisallowInterceptTouchEvent(disallowIntercept) }