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

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:
gestureRecognizerShouldBeginscrollView(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

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