Repository navigation
[iOS] Scrollable hit-test uses the post-threshold pan location, so drags starting on a short handle strip are refused (Android uses the touch-down point) #63
Description
Summary
On iOS, the checks that decide whether the sheet or a nested scrollable owns a pan resolve the touched scroll view from panGesture.location(in: sheetContainer), read inside gestureRecognizerShouldBegin — i.e. after UIKit's pan translation threshold (~10pt) has already been exceeded. That is the finger's current location, not the point it landed on.
When the draggable chrome above a scrollable is short (a grabber strip, a compact header), that threshold movement is enough to carry the touch point out of the strip and into the scrollable's frame before the check runs. The sheet then concludes the pan started on the scrollable and declines a drag the user deliberately aimed at the handle.
The Android implementation does not have this problem: it records initialTouchX/initialTouchY at ACTION_DOWN and hit-tests the scrollable from those stored coordinates. The iOS/Android divergence looks unintentional rather than deliberate.
Affected versions
Present in every published version I checked, and on main:
| Version | Site |
|---|---|
| v0.16.1 | ios/BottomSheetHostingView.swift :839, :854 |
| v0.16.2 | ios/BottomSheetHostingView.swift :875, :890 |
v0.17.0-next.1 / main (25a2ba3) |
ios/BottomSheetHostingView.swift :1220 — plus :874 in handlePan .began, which resolves activeScrollViewStates from the same post-threshold location |
The 0.17 negotiation rewrite (#22 / #45) carried the behavior through unchanged.
Symptom
Setup: a sheet whose content is a FlatList, with a short (~18pt) draggable strip above it.
- Drag down starting on the strip → frequently refused. The list does not scroll and the sheet does not move.
- Drag up from the exact same pixel → works reliably.
The directional asymmetry is the tell. An upward drag moves the touch point away from the scrollable (further into non-scrollable space, so it still reads as "outside"); a downward drag moves it into the scrollable. The shorter the strip, the more reliably down-drags fail — at ~23pt of chrome it is intermittent, at ~18pt it fails most of the time.
With disableScrollableNegotiation (≤0.16.x) / scrollableNegotiation="none" (0.17), the path is:
gestureRecognizerShouldBegin → scrollView(containing: locationInContainer) finds the list → return false.
At the largest detent the second check compounds it: the same misread location is used, and scrollableChainIsAtStart() additionally requires the list to sit at offset 0 — so collapsing from the largest detent from the strip is effectively impossible once the list has been scrolled.
This is not specific to none. Under 0.17's default { expand: 'handoff', collapse: 'initial' }, a down-drag from the strip at the largest detent with a scrolled list still resolves into the scrollable chain, hits .initial, and is refused by scrollableChainIsAtStart().
Root cause
gestureRecognizerShouldBegin is only invoked once the pan recognizer is about to leave .possible, which by definition is after the translation threshold has been passed. location(in:) read at that moment necessarily reflects post-threshold movement, so it is not a sound proxy for "which subview did the user grab".
Android, for comparison
android/.../BottomSheetHostView.kt stores the touch-down point at ACTION_DOWN (:1006-1011) and resolves the scrollable from it (:1464):
private fun findScrollableAtTouch(): View? {
val containerX = initialTouchX - sheetContainer.left - sheetContainer.translationX
val containerY = initialTouchY - sheetContainer.top - sheetContainer.translationY
...
return findScrollableAtPoint(sheetContainer, containerX, containerY)
}Suggested fix
Capture the touch-down location in the pan's shouldReceive delegate callback — which fires before any movement — and use it for the scrollable hit-tests, matching the Android semantics:
private var panInitialLocationInContainer: CGPoint?
public func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldReceive touch: UITouch
) -> Bool {
if gestureRecognizer === panGesture {
panInitialLocationInContainer = touch.location(in: sheetContainer)
}
return true
}and at each check site:
let locationInContainer = panInitialLocationInContainer ?? panGesture.location(in: sheetContainer)I've been running this as a local patch against 0.16.1. Strip drags become reliable in both directions, at every detent, regardless of the list's scroll offset. Touches that genuinely begin on the list are unaffected — the touch-down point and the post-threshold point are both inside the list there, so the checks resolve identically.
Happy to open a PR against main if the approach looks right — the main version would want the same treatment at both :874 and :1220.
Environment
@swmansion/react-native-bottom-sheet0.16.1- react-native 0.86.0, Expo SDK 57.0.4, New Architecture
- iOS deployment target 16.4; observed on iOS 26
Activity
LeCiel commentedon Jul 16, 2026
Adjacent case that likely shares this root cause — surfacing here in case the proposed touch-down capture also fixes it, or in case it's actually a second symptom worth calling out separately.
Setup
react-native-draggable-flatlist@4.xmounted inside a full-height sheet detent. Each row has a short (~36pt wide × 64pt tall) drag handle at the left; long-pressing the handle calls the library'sdrag()and reorder is expected to follow the finger vertically.What happens on iOS
drag()fires, the row is picked up, the library'sShadowDecoratorrenders the elevated overlay row. Visually the pickup is clean.onUpdate.Android, same code path, works as expected — the reorder tracks the finger.
Why I think #63 is the same root
drag()fires, DraggableFlatList mounts the pickup as an overlay outside the underlyingFlatList's subtree. WhengestureRecognizerShouldBeginruns (post-threshold), the finger's current location may hit that overlay — not theFlatList— soscrollView(containing:)walks up from the overlay, finds noUIScrollViewancestor, anddisableScrollableNegotiationdoesn't fire → the sheet's pan begins.shouldBeRequiredToFailBy: other is UIPanGestureRecognizerdelegate ought to make the sheet wait for the draggable pan, but iOS only routes that delegate between recognizers UIKit sees as related, and the gesture-handler pan attached below the overlay may not qualify — so the sheet doesn't defer.Where the proposed fix might not be enough
Capturing the touch-down location in
shouldReceivewould resolvescrollView(containing:)against the pre-pickup point (inside theFlatList), which is exactly what we'd want here. That should turn the negotiation the correct way for the pickup case even after the overlay appears.The one lingering unknown is whether that still holds after the row overlay has visually detached — the touch-down point remains inside the original list frame, so the hit-test should still resolve to the
FlatList, but wanted to flag the extra overlay layer in case it warrants separate handling.Environment
@swmansion/react-native-bottom-sheet0.15.3react-native-draggable-flatlist4.xreact-native-reanimated4.5.2,react-native-gesture-handler2.31.xHappy to test a build with your patch against this scenario if that would help validate coverage.
patrickmichalik commentedon Jul 19, 2026
Hello! Thanks for the thorough report. The diagnosis and proposed solution look right; a PR would be welcome.
@LeCiel, it would be useful to test the proposed patch in your setup, but that might be a separate arbitration issue. If the problem remains after the patch, we should track it separately, with a minimal reproducible example.
patrickmichalik commentedon Sep 25, 2026
Version
0.17.0-next.2fixes this.patrickmichalik commentedon Oct 18, 2026
Version
0.17.0is now available.