plans/rngh-sheet.md
Last Update (Jan 24): All tests pass, code review complete, docs written.
What was implemented:
Test Results:
User Feedback:
resisted())When sheet is at middle position and user does FAST swipe up:
onBegin callbackscrollEnabled={false}, scroll is locked BUT handoff breaks (Case 9 fails)Like gorhom/bottom-sheet and react-native-actions-sheet, we need synchronous native-thread control. Instead of requiring full Reanimated, we can use the lighter react-native-worklets-core package.
New Architecture - @tamagui/native package:
// Entry points (side-effect imports only, no setup() functions):
import '@tamagui/native/setup-gesture-handler' // RNGH
import '@tamagui/native/setup-worklets' // react-native-worklets-core
// In your app:
import '@tamagui/native/setup-gesture-handler'
import '@tamagui/native/setup-worklets'
// That's it! Sheet will automatically use worklets when available
See: https://docs.swmansion.com/react-native-worklets/docs/
✓ should show RNGH enabled
✓ should open sheet at position 0
✓ Case 1: swipe DOWN at scrollY=0 should drag sheet, NOT scroll
✓ Case 2: at top snap, swipe UP should scroll content
✓ Case 3: drag sheet up from position 1 should NOT scroll simultaneously
✓ Case 4: scroll down, then swipe down should scroll back to 0 first
✓ Case 5: HANDOFF - scroll to 0 then drag sheet in one gesture
✓ Case 6: multiple direction changes without getting stuck
✓ Case 7: drag UP from position 1 should NOT scroll content (new!)
✓ Case 8: rubber band at top - dragging up keeps sheet at top (new!)
Problem: When sheet was at position 1 (non-top) and user dragged UP, scroll content was firing even though pan should handle the gesture. Max scroll Y was reaching 400+ pixels.
Root Cause: Scroll events were being processed BEFORE pan gesture's onBegin callback had a chance to set the scroll lock. The simultaneousHandlers pattern means both gestures run, but JS callbacks can't prevent native scroll events in time.
Solution: Preemptive scroll state management based on sheet position:
useAnimatedNumberReaction, track when sheet reaches/leaves top positionsetScrollEnabled(false) and setNativeProps// In SheetImplementationCustom.tsx useAnimatedNumberReaction callback:
const nowAtTop = value <= minY + 5
if (wasAtTop !== nowAtTop) {
scrollBridge.isAtTop = nowAtTop
if (nowAtTop) {
scrollBridge.scrollLockY = undefined
scrollBridge.setScrollEnabled?.(true)
} else {
scrollBridge.scrollLockY = 0
scrollBridge.setScrollEnabled?.(false)
}
}
gorhom/bottom-sheet:
SCROLLABLE_STATUS.LOCKED sets decelerationRate: 0 to effectively freeze scrollanimatedScrollableState shared value for immediate state changesreact-native-actions-sheet:
scrollable(true/false) function called in onChange handlersetNativeProps({ scrollEnabled: value }) for fast updatesOur approach (without Reanimated):
setNativeProps and React state as backupisAtTop flag that changes when animated position crosses thresholdsetupGestureHandler({ Gesture, GestureDetector, ScrollView }) to avoid double registrationgestureState.ts moduleThe "big test" from user requirements:
ALL WITHOUT LIFTING FINGER
Problem: require('react-native-gesture-handler') inside setupGestureHandler() caused:
Invariant Violation: Tried to register two views with the same name RNGestureHandlerButton
This happened because the app imports RNGH at the top (import 'react-native-gesture-handler') and then our setup tried to require it again.
Solution: Changed API to accept config object instead of auto-detecting:
// OLD (broken):
import 'react-native-gesture-handler'
import { setupGestureHandler } from '@tamagui/sheet/setup-gesture-handler'
setupGestureHandler() // ❌ tries to require RNGH again, double registration
// NEW (fixed):
import {
Gesture,
GestureDetector,
GestureHandlerRootView,
} from 'react-native-gesture-handler'
import { setupGestureHandler } from '@tamagui/sheet/setup-gesture-handler'
setupGestureHandler({ Gesture, GestureDetector }) // ✅ uses already-imported refs
1. manualActivation Pattern
Instead of activeOffsetY([-5, 5]) which always activates after 5px movement, we use:
Gesture.Pan()
.manualActivation(true)
.onTouchesMove((event, stateManager) => {
// decide whether to activate pan or let scroll handle it
if (isFullOpen && hasScrollableContent && !nodeIsScrolling) {
stateManager.fail() // let native scroll work
return
}
if (nodeIsScrolling) {
stateManager.fail() // scroll is handling it
return
}
stateManager.activate() // activate pan for sheet drag
})
2. Dynamic scrollBridge Checks
Read scrollBridge.hasScrollableContent inside gesture handlers (not captured props) so it reflects current state.
3. simultaneousWithExternalGesture
The ScrollView uses Gesture.Native().simultaneousWithExternalGesture(panGesture) to coordinate with the sheet's pan gesture.
useGestureHandlerPan.tsx - manualActivation pattern, removed hasScrollView propSheetScrollView.tsx - cleaned up debug codeSheetImplementationCustom.tsx - removed hasScrollView prope2e/SheetScrollableDrag.test.ts - fixed assertions, added item visibility checksThe scroll→drag handoff (scroll back to 0 then drag sheet) needs dynamic re-evaluation during gesture. This is an advanced feature for future work.
Sheet + Sheet.ScrollView gesture coordination on iOS is fundamentally broken with PanResponder because iOS's UIScrollView gesture recognizers fire BEFORE React Native's responder system.
Issue: scrollBridge.y is always 0 when using RNGH path.
Root Cause: The onScroll handler in SheetScrollView only fires during normal scroll events, but when RNGH takes over the gesture coordination, the native scroll events may not fire the same way.
Evidence: User testing on iOS simulator showed "it doesn't do nice handoff" and "scrollview y btw isn't updating how you'd expect - always 0"
code/ui/sheet/src/gestureState.ts - Global state (no native deps)code/ui/sheet/src/setupGestureHandler.ts - Auto-detects RNGH via require()code/ui/sheet/src/useGestureHandlerPan.tsx - Pan gesture hook with blockPancode/ui/sheet/src/GestureDetectorWrapper.tsx - Conditional wrappercode/ui/sheet/src/GestureSheetContext.tsx - Context for gesture refscode/ui/sheet/src/SheetImplementationCustom.tsx - Uses hook, falls back to PanRespondercode/ui/sheet/src/SheetScrollView.tsx - simultaneousWithExternalGesturecode/ui/sheet/package.json - Export and optional peer depcode/kitchen-sink/src/App.native.tsx - Calls setupGestureHandler()code/kitchen-sink/src/usecases/SheetScrollableDrag.tsx - Test casecode/kitchen-sink/src/features/home/screen.tsx - RNGH status indicatorThe scrollBridge.y must be updated continuously even when RNGH is handling gestures.
Approach A: Use ScrollView's onScroll with scrollEventThrottle={1} for maximum frequency
Approach B: Use a native scroll handler attached via ref
Approach C: Track scroll offset via Gesture.Native() event handlers
Look at how gorhom/bottom-sheet tracks animatedScrollableState.contentOffsetY:
// They use Reanimated's scrollTo and track via worklets
// We need a JS-based equivalent since we don't require Reanimated
Create code/kitchen-sink/tests/SheetScrollableDrag.detox.test.ts:
describe('Sheet + ScrollView RNGH Integration', () => {
beforeAll(async () => {
await device.launchApp()
// Navigate to SheetScrollableDrag test case
})
it('should show RNGH enabled indicator', async () => {
// Verify setupGestureHandler() worked
await expect(element(by.text('RNGH: ✓ enabled'))).toBeVisible()
})
it('scrolls content when at top snap point and swiping up', async () => {
// Open sheet
await element(by.id('sheet-scrollable-drag-trigger')).tap()
await waitFor(element(by.id('sheet-scrollable-drag-frame'))).toBeVisible()
// Swipe up on content
await element(by.id('sheet-scrollable-drag-scrollview')).swipe('up', 'slow', 0.5)
// Take screenshot to verify scroll position changed
// Check scroll-y indicator shows > 0
await expect(element(by.id('sheet-scrollable-drag-scroll-y'))).not.toHaveText(
'ScrollView Y: 0'
)
// Sheet position should still be 0 (top snap)
await expect(element(by.id('sheet-scrollable-drag-position'))).toHaveText(
'Sheet position: 0'
)
})
it('drags sheet when swiping down from scrollY=0', async () => {
// Reset and open sheet
await element(by.id('sheet-scrollable-drag-reset')).tap()
await element(by.id('sheet-scrollable-drag-trigger')).tap()
// Swipe down on handle/content when at scroll top
await element(by.id('sheet-scrollable-drag-handle')).swipe('down', 'slow', 0.3)
// Sheet should have moved to lower snap point
await expect(element(by.id('sheet-scrollable-drag-position'))).toHaveText(
'Sheet position: 1'
)
})
it('hands off from scroll to sheet drag seamlessly', async () => {
// Open sheet and scroll down
await element(by.id('sheet-scrollable-drag-trigger')).tap()
await element(by.id('sheet-scrollable-drag-scrollview')).swipe('up', 'slow', 0.5)
// Now drag down - should scroll to top first, then drag sheet
await element(by.id('sheet-scrollable-drag-scrollview')).swipe('down', 'slow', 0.8)
// Check: scroll should be at 0
await expect(element(by.id('sheet-scrollable-drag-scroll-y'))).toHaveText(
'ScrollView Y: 0'
)
// Check: sheet should have moved down (position > 0 or dismissed)
// This is the key handoff test
})
it('hands off from sheet drag to scroll seamlessly', async () => {
// Open sheet at lower snap point
await element(by.id('sheet-scrollable-drag-trigger')).tap()
// ... drag to lower snap
// Drag up past top snap point
// Content should start scrolling
})
})
Once we have failing tests, we can properly debug with evidence.
Uses Reanimated worklets to track scroll offset in real-time:
const animatedScrollableState = useSharedValue({
contentOffsetY: 0,
// ...
})
// Updated via scrollHandler worklet
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
animatedScrollableState.value = {
contentOffsetY: event.contentOffset.y,
// ...
}
},
})
Tracks scroll via refs and direct measurements:
// They store scroll offset per-node
const offsets = useRef<number[]>([])
// Updated in scroll handler
offsets.current[i] = scrollY
Since we don't require Reanimated, we need to:
scrollEventThrottle={1}scrollRef.current?.scrollTo() can also read position// In SheetScrollView
useEffect(() => {
if (!isGestureHandlerEnabled()) return
// For RNGH path, we may need to poll or use a different mechanism
const scrollNode = scrollRef.current?.getScrollableNode()
if (!scrollNode) return
// Option: Native scroll listener
const handleScroll = (e) => {
scrollBridge.y = e.nativeEvent.contentOffset.y
}
// This might work better with RNGH
}, [])
Studied both ~/github/react-native-bottom-sheet and ~/github/react-native-actions-sheet:
Critical Finding: Neither uses manualActivation!
Both use:
simultaneousHandlers - let both gestures run simultaneouslyonChange - decide who "owns" the gestureblockPan flag - skip pan processing when scroll should handle itscrollable(true/false) - dynamically enable/disable scroll on nodesCase 1: Sheet not fully open + swiping up
→ scrollable(false), blockPan = false [PAN HANDLES IT]
Case 2: Sheet fully open + swiping up
→ scrollable(true), blockPan = true [SCROLL HANDLES IT]
Case 3: Sheet not fully open + swiping down
→ if (nodeIsScrolling)
- scrollable(true), blockPan = true [SCROLL HANDLES IT]
else
- scrollable(false), blockPan = false [PAN HANDLES IT]
Case 4: Sheet fully open + swiping down + scroll offset > 0
→ if (nodeIsScrolling)
- scrollable(true), blockPan = true [SCROLL HANDLES IT]
else
- scrollable(false), blockPan = false [PAN HANDLES IT]
// In onChange handler:
const isFullOpen = currentSheetPos <= minY + 5
const isSwipingDown = deltaY > 0
const nodeIsScrolling = scrollBridge.y > 0
if (!isFullOpen && !isSwipingDown) {
// Case 1: sheet not fully open, swiping up -> pan handles
scrollBridge.setScrollEnabled?.(false)
// process pan...
} else if (isFullOpen && !isSwipingDown) {
// Case 2: sheet fully open, swiping up -> scroll handles
scrollBridge.setScrollEnabled?.(true)
return // blockPan
} else if (!isFullOpen && isSwipingDown) {
// Case 3: sheet not fully open, swiping down
if (nodeIsScrolling) {
scrollBridge.setScrollEnabled?.(true)
return // blockPan
}
scrollBridge.setScrollEnabled?.(false)
// process pan...
} else if (isFullOpen && isSwipingDown) {
// Case 4: sheet fully open, swiping down
if (nodeIsScrolling) {
scrollBridge.setScrollEnabled?.(true)
return // blockPan
}
scrollBridge.setScrollEnabled?.(false)
// process pan...
}
The manualActivation pattern I tried doesn't work - the onTouchesMove callbacks aren't firing on the content area. Need to switch to the state-based approach used by both reference implementations.
Testing with debug logs in:
SheetScrollView.tsx - logs [SheetScrollView] onScroll called, y: <number>useGestureHandlerPan.tsx - logs gesture activation decisionsApp.native.tsx / HomeScreen - logs RNGH statusTo verify:
# Start Metro for kitchen-sink
cd code/kitchen-sink && yarn start --port 8081
# Run iOS app
npx expo run:ios
# Check RNGH status in app
# Look for "RNGH: ✓ enabled" on home screen
# Run Detox tests (when written)
yarn detox build -c ios.sim.debug
yarn detox test -c ios.sim.debug tests/SheetScrollableDrag.detox.test.ts
currentScrollOffset.current updated on every scroll eventrunOnJS(true) works fine for our use casecode/ui/sheet/src/gestureState.ts - Re-exports from @tamagui/nativecode/ui/sheet/src/useGestureHandlerPan.tsx - Pan gesture with scroll coordinationcode/ui/sheet/src/SheetScrollView.tsx - RNGH ScrollView with simultaneousHandlerscode/ui/sheet/src/GestureDetectorWrapper.tsx - Conditional gesture wrappercode/ui/sheet/src/GestureSheetContext.tsx - Context for gesture ref sharingcode/ui/sheet/src/SheetImplementationCustom.tsx - Integration with fallbackcode/ui/sheet/src/types.tsx - Extended ScrollBridge typecode/core/native/src/setup-gesture-handler.ts - Side-effect setupThis task was driven by a ralph loop prompt asking to achieve native-quality sheet gestures. Key requirements from the loop:
The session took approximately 4 hours of focused work across multiple iterations.