Skip to content

[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

Closed

Description

@js-commit

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-sheet 0.16.1
  • react-native 0.86.0, Expo SDK 57.0.4, New Architecture
  • iOS deployment target 16.4; observed on iOS 26

Activity

  1. LeCiel commented on Jul 16, 2026

    @LeCiel
    Contributor

    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.x mounted 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's drag() and reorder is expected to follow the finger vertically.

    What happens on iOS

    • Long-press succeeds — drag() fires, the row is picked up, the library's ShadowDecorator renders the elevated overlay row. Visually the pickup is clean.
    • The moment the finger starts moving vertically, the sheet's pan takes the gesture and the sheet slides down instead of the row moving inside the list. The overlay row stays put; the outer list never gets an onUpdate.

    Android, same code path, works as expected — the reorder tracks the finger.

    Why I think #63 is the same root

    • The drag handle is a short strip (matches your "short chrome" symptom).
    • Once drag() fires, DraggableFlatList mounts the pickup as an overlay outside the underlying FlatList's subtree. When gestureRecognizerShouldBegin runs (post-threshold), the finger's current location may hit that overlay — not the FlatList — so scrollView(containing:) walks up from the overlay, finds no UIScrollView ancestor, and disableScrollableNegotiation doesn't fire → the sheet's pan begins.
    • The shouldBeRequiredToFailBy: other is UIPanGestureRecognizer delegate 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 shouldReceive would resolve scrollView(containing:) against the pre-pickup point (inside the FlatList), 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-sheet 0.15.3
    • react-native-draggable-flatlist 4.x
    • react-native-reanimated 4.5.2, react-native-gesture-handler 2.31.x
    • RN 0.85.3 (Fabric), Hermes
    • iOS 26 device

    Happy to test a build with your patch against this scenario if that would help validate coverage.

  2. patrickmichalik commented on Jul 19, 2026

    @patrickmichalik
    Collaborator

    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.

  3. patrickmichalik commented on Sep 25, 2026

    @patrickmichalik
    Collaborator

    Version 0.17.0-next.2 fixes this.

  4. patrickmichalik commented on Oct 18, 2026

    @patrickmichalik
    Collaborator

    Version 0.17.0 is now available.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions