plans/native-gestures.md
Sheet + Sheet.ScrollView gesture coordination on native iOS has fundamental limitations because Tamagui uses React Native's built-in PanResponder, while iOS's UIScrollView gesture recognizers fire BEFORE the RN responder system can claim the gesture.
This causes:
This is what gorhom/bottom-sheet and react-native-actions-sheet achieve using react-native-gesture-handler.
Key Patterns:
// Gesture.Pan() with refs for coordination
Gesture.Pan()
.withRef(panGestureRef)
.onChange(event => onChange(...))
.runOnJS(true)
.activeOffsetY([-5, 5])
.failOffsetX([-5, 5])
.onEnd(onEnd)
blockPan flag pattern - Simple boolean to control gesture routing:
let blockPan = false
// In onChange:
// 1. Sheet not fully open, swiping up: scrollable(false); blockPan = false → allow panning
// 2. Sheet fully open, swiping up: scrollable(true); blockPan = true → allow scrolling
// 3. Sheet not fully open, swiping down: depends on nodeIsScrolling
// 4. Sheet fully open, swiping down with scroll offset: hand off when scrollY=0
if (blockPan) return // Exit early
scrollable() function - Enable/disable scroll and restore positions:
function scrollable(value: boolean) {
for (let node of draggableNodes.current) {
if (Platform.OS === 'ios') {
scrollRef.scrollTo({ x: 0, y: offsets[i], animated: false })
} else if (Platform.OS === 'android') {
scrollRef?.setNativeProps({ scrollEnabled: value })
}
}
}
Key Patterns:
simultaneousHandlers for gesture coordination:
// In createBottomSheetScrollableComponent.tsx
const scrollableGesture = useMemo(
() =>
draggableGesture
? Gesture.Native()
.simultaneousWithExternalGesture(draggableGesture)
.shouldCancelWhenOutside(false)
: undefined,
[draggableGesture]
)
Context-based gesture state:
// GestureHandlersProvider creates content and handle pan gestures
const contentPanGestureHandler = useGestureHandler(
GESTURE_SOURCE.CONTENT,
animatedContentGestureState,
...
);
const handlePanGestureHandler = useGestureHandler(
GESTURE_SOURCE.HANDLE,
animatedHandleGestureState,
...
);
Worklet-based gesture handlers (useGestureEventsHandlersDefault):
const handleOnChange = useCallback(
function handleOnChange(source, { translationY }) {
'worklet';
// Lock scrollable position when scrolled
if (animatedScrollableState.get().contentOffsetY > 0) {
context.value = { ...context.value, isScrollablePositionLocked: true };
}
// Negative offset subtraction for smooth handoff
const negativeScrollableContentOffset =
(context.value.initialPosition === highestSnapPoint &&
source === GESTURE_SOURCE.CONTENT)
? animatedScrollableState.get().contentOffsetY * -1
: 0;
// Accumulated position with scroll offset
const accumulatedDraggedPosition = draggedPosition + negativeScrollableContentOffset;
// Unlock when reaching highest point
if (context.value.isScrollablePositionLocked &&
animatedPosition.value === highestSnapPoint) {
context.value = { ...context.value, isScrollablePositionLocked: false };
}
},
[...]
);
Files to create:
code/ui/sheet/src/setupGestureHandler.ts - Setup functioncode/ui/sheet/src/gestureState.ts - Global state for RNGH availabilitycode/ui/sheet/src/GestureSheetContext.tsx - Context for gesture refsPattern (following @tamagui/portal):
// setupGestureHandler.ts
export type GestureHandlerState = {
enabled: boolean
GestureDetector: typeof GestureDetector | null
Gesture: typeof Gesture | null
// Note: We DON'T require Reanimated - use Tamagui's animation driver
}
let state: GestureHandlerState = { enabled: false, GestureDetector: null, Gesture: null }
export function setupGestureHandler(config: {
GestureDetector: typeof GestureDetector
Gesture: typeof Gesture
}): void {
const g = globalThis as any
if (g.__tamagui_gesture_handler_setup) return
g.__tamagui_gesture_handler_setup = true
state = {
enabled: true,
GestureDetector: config.GestureDetector,
Gesture: config.Gesture,
}
}
export function getGestureHandlerState(): GestureHandlerState {
return state
}
export function isGestureHandlerEnabled(): boolean {
return state.enabled
}
Modify SheetImplementationCustom.tsx:
import { isGestureHandlerEnabled, getGestureHandlerState } from './gestureState'
// In component:
const gestureHandlerEnabled = isGestureHandlerEnabled()
// Create PanResponder OR GestureDetector based on availability
const panGesture = React.useMemo(() => {
if (gestureHandlerEnabled) {
return createGestureHandlerPan(/* ... */)
}
return createPanResponder(/* ... current implementation */)
}, [gestureHandlerEnabled, /* ... */])
// Render with conditional wrapper
{gestureHandlerEnabled ? (
<GestureDetectorWrapper gesture={panGesture}>
<AnimatedView ...></AnimatedView>
</GestureDetectorWrapper>
) : (
<AnimatedView {...panResponder?.panHandlers} ...></AnimatedView>
)}
Modify SheetScrollView.tsx:
import { isGestureHandlerEnabled, getGestureHandlerState } from './gestureState'
// Get the sheet's pan gesture ref from context
const { panGestureRef } = useSheetGestureContext()
// Create simultaneous gesture for ScrollView
const scrollableGesture = React.useMemo(() => {
if (!isGestureHandlerEnabled() || !panGestureRef) return null
const { Gesture } = getGestureHandlerState()
return Gesture.Native()
.simultaneousWithExternalGesture(panGestureRef)
.shouldCancelWhenOutside(false)
}, [panGestureRef])
// Wrap ScrollView with GestureDetector when available
return scrollableGesture ? (
<GestureDetector gesture={scrollableGesture}>
<ScrollView {...props} />
</GestureDetector>
) : (
<ScrollView {...props}></ScrollView>
)
Add to SheetContext/scrollBridge:
// Extend scrollBridge with gesture coordination state
scrollBridge.blockPan = false
scrollBridge.isScrollablePositionLocked = false
scrollBridge.initialPosition = 0
scrollBridge.contentOffsetY = 0
// In gesture onChange handler:
function onChange(absoluteX, absoluteY, translationY) {
const isFullOpen = getCurrentPosition() === positions[0]
const isSwipingDown = prevDeltaY < translationY
const nodeIsScrolling = scrollBridge.y > 0
// Decision tree (from actions-sheet):
if (!isFullOpen && !isSwipingDown) {
// Sheet not fully open, swiping up → allow panning
scrollable(false)
scrollBridge.blockPan = false
} else if (isFullOpen && !isSwipingDown) {
// Sheet fully open, swiping up → allow scrolling only
scrollable(true)
scrollBridge.blockPan = true
} else if (!isFullOpen && isSwipingDown) {
// Sheet not fully open, swiping down → depends on scroll state
if (nodeIsScrolling) {
scrollable(true)
scrollBridge.blockPan = true
} else {
scrollable(false)
scrollBridge.blockPan = false
}
} else if (isFullOpen && isSwipingDown) {
// Sheet fully open, swiping down → hand off at scrollY=0
if (nodeIsScrolling) {
scrollable(true)
scrollBridge.blockPan = true
} else {
scrollable(false)
scrollBridge.blockPan = false
}
}
if (scrollBridge.blockPan) return
// Continue with sheet position update...
}
Update package.json exports:
{
"exports": {
".": {
/* existing */
},
"./setup-gesture-handler": {
"react-native": {
"types": "./types/setupGestureHandler.d.ts",
"module": "./dist/esm/setupGestureHandler.js",
"import": "./dist/esm/setupGestureHandler.js",
"require": "./dist/cjs/setupGestureHandler.js"
}
}
}
}
Documentation for users:
// In app entry point (index.js or App.tsx)
import { setupGestureHandler } from '@tamagui/sheet/setup-gesture-handler'
import {
Gesture,
GestureDetector,
GestureHandlerRootView,
} from 'react-native-gesture-handler'
// Call once at startup
setupGestureHandler({ Gesture, GestureDetector })
// Wrap app with GestureHandlerRootView (user's responsibility)
export default function App() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<YourApp />
</GestureHandlerRootView>
)
}
code/ui/sheet/src/setupGestureHandler.ts - Setup function and statecode/ui/sheet/src/gestureState.ts - Global state managementcode/ui/sheet/src/GestureSheetContext.tsx - Context for gesture refscode/ui/sheet/src/createGestureHandlerPan.ts - RNGH-based pan gesture creationcode/ui/sheet/src/GestureDetectorWrapper.tsx - Conditional wrapper componentcode/ui/sheet/src/SheetImplementationCustom.tsx - Conditional gesture handlingcode/ui/sheet/src/SheetScrollView.tsx - simultaneousHandlers integrationcode/ui/sheet/src/SheetContext.tsx - Extended scrollBridge interfacecode/ui/sheet/package.json - Export setup-gesture-handlercode/kitchen-sink/tests/SheetGestureHandler.test.tsx - New E2E tests for RNGH pathcode/kitchen-sink/src/usecases/SheetScrollableDrag.tsx - Update for testingFirst, write failing tests for the expected behavior:
Run tests on iOS simulator/device:
Test matrix:
describe('Sheet with RNGH', () => {
beforeAll(() => {
setupGestureHandler({ Gesture, GestureDetector })
})
it('scrolls content when at top snap point and swiping up', async () => {
// Open sheet at 85% snap point
// Swipe up on content area
// Verify content scrolls (scrollY increases)
// Verify sheet position unchanged
})
it('drags sheet down when at top snap point with scrollY=0', async () => {
// Open sheet at 85% snap point
// Swipe down on content area
// Verify sheet position decreases
// Verify content doesn't scroll
})
it('seamlessly hands off from scroll to sheet drag', async () => {
// Open sheet, scroll content down
// Start dragging up (scrolling)
// When scrollY reaches 0, continue motion
// Verify sheet starts moving up without interruption
})
it('seamlessly hands off from sheet drag to scroll', async () => {
// Open sheet at lower snap point
// Drag up until hitting top snap point
// Continue upward motion
// Verify content starts scrolling without interruption
})
})
Gesture.Pan() with runOnJS(true) works fine for our use casesimultaneousWithExternalGesture, not workletsWhen setupGestureHandler() is NOT called:
PanResponder implementationOptional peer dependency:
{
"peerDependencies": {
"react-native-gesture-handler": ">=2.0.0"
},
"peerDependenciesMeta": {
"react-native-gesture-handler": {
"optional": true
}
}
}
Core implementation complete:
gestureState.ts - global state for RNGH availability (no native deps)setupGestureHandler.ts - auto-detects RNGH via require() (like teleport pattern)useGestureHandlerPan.tsx - pan gesture hook with blockPan logicGestureDetectorWrapper.tsx - conditional wrapper componentGestureSheetContext.tsx - context for sharing gesture ref with ScrollViewSheetImplementationCustom.tsx - integrated gesture handler with fallback to PanResponderSheetScrollView.tsx - simultaneousWithExternalGesture for native coordinationpackage.json - added setup-gesture-handler export and optional peer depKitchen sink setup:
setupGestureHandler() call in App.native.tsxNext step: Test on iOS simulator to verify behavior: