import { ProvideAdaptContext, useAdaptContext } from '@tamagui/adapt' import { AnimatePresence } from '@tamagui/animate-presence' import { useComposedRefs } from '@tamagui/compose-refs' import { isWeb, useIsomorphicLayoutEffect } from '@tamagui/constants' import { LayoutMeasurementController, View as TamaguiView, useConfiguration, useDidFinishSSR, useEvent, useThemeName, } from '@tamagui/core' import { getSafeArea } from '@tamagui/native' import { needsPortalRepropagation, Portal } from '@tamagui/portal' import React, { useState } from 'react' import type { Animated, GestureResponderEvent, LayoutChangeEvent, PanResponderGestureState, } from 'react-native' import { Dimensions, PanResponder, View } from 'react-native' import { ParentSheetContext, SheetInsideSheetContext } from './contexts' import { GestureDetectorWrapper } from './GestureDetectorWrapper' import { GestureSheetProvider } from './GestureSheetContext' import { resisted } from './helpers' import { SheetProvider } from './SheetContext' import type { SheetProps, SnapPointsMode } from './types' import { useGestureHandlerPan } from './useGestureHandlerPan' import { useKeyboardControllerSheet } from './useKeyboardControllerSheet' import { useSheetOpenState } from './useSheetOpenState' import { useSheetProviderProps } from './useSheetProviderProps' const hiddenSize = 10_000.1 // safe area top inset, cached per-session (device-constant value) let _cachedSafeAreaTop: number | undefined function getSafeAreaTopInset(): number { if (_cachedSafeAreaTop !== undefined) return _cachedSafeAreaTop // use @tamagui/native abstraction - returns 0 when not enabled _cachedSafeAreaTop = getSafeArea().getInsets().top return _cachedSafeAreaTop } let sheetHiddenStyleSheet: HTMLStyleElement | null = null // on web we are always relative to window, on to screen const relativeDimensionTo = isWeb ? 'window' : 'screen' export const SheetImplementationCustom = React.forwardRef( function SheetImplementationCustom(props, forwardedRef) { const parentSheet = React.useContext(ParentSheetContext) const { transition, transitionConfig: transitionConfigProp, modal = false, zIndex = parentSheet.zIndex + 1, moveOnKeyboardChange = false, unmountChildrenWhenHidden = false, portalProps, containerComponent: ContainerComponent = React.Fragment, onAnimationComplete, } = props const state = useSheetOpenState(props) const [overlayComponent, setOverlayComponent] = React.useState(null) const providerProps = useSheetProviderProps(props, state, { onOverlayComponent: setOverlayComponent, }) const { frameSize, setFrameSize, snapPoints, snapPointsMode, hasFit, position, setPosition, scrollBridge, screenSize, setMaxContentSize, maxSnapPoint, } = providerProps const { open, controller, isHidden } = state const openRef = React.useRef(open) openRef.current = open const sheetRef = React.useRef(undefined as unknown as View) const ref = useComposedRefs(forwardedRef, sheetRef, providerProps.contentRef as any) // TODO this can be extracted into a helper getAnimationConfig(animationProp as array | string) const { animationDriver } = useConfiguration() if (!animationDriver) { throw new Error(`Sheet requires an animation driver to be set`) } const transitionConfig = (() => { // explicit transitionConfig prop always takes precedence if (transitionConfigProp) { return transitionConfigProp } const [animationProp, animationPropConfig] = !transition ? [] : Array.isArray(transition) ? transition : ([transition] as const) // look up named animation config from driver if available if (animationProp && animationDriver.animations?.[animationProp as string]) { return { ...animationDriver.animations[animationProp as string], ...animationPropConfig, } } return null })() /** * This is a hacky workaround for native: */ const [isShowingInnerSheet, setIsShowingInnerSheet] = React.useState(false) // when using Gorhom portal (no teleport), inner sheets need to hide parent const shouldHideParentSheet = !isWeb && modal && isShowingInnerSheet && needsPortalRepropagation() const sheetInsideSheet = React.useContext(SheetInsideSheetContext) const onInnerSheet = React.useCallback((hasChild: boolean) => { setIsShowingInnerSheet(hasChild) }, []) // FIX: Store stable frameSize to prevent recalculation during exit animation const stableFrameSize = React.useRef(frameSize) React.useEffect(() => { // Only update stable size when sheet is open if (open && frameSize) { stableFrameSize.current = frameSize } }, [open, frameSize]) // use stableFrameSize when closing to prevent position jumps during exit animation // but when opening, always use the current frameSize so positions update correctly const effectiveFrameSize = open ? frameSize : stableFrameSize.current || frameSize const positions = React.useMemo( () => snapPoints.map((point) => getYPositions(snapPointsMode, point, screenSize, effectiveFrameSize) ), [screenSize, effectiveFrameSize, snapPoints, snapPointsMode] ) // keyboard state tracking — just tracks height/visibility, no position animation. // Position animation is handled via keyboard-adjusted positions below, // matching the react-native-actions-sheet pattern. const { keyboardHeight, isKeyboardVisible, dismissKeyboard, pauseKeyboardHandler, flushPendingHide, } = useKeyboardControllerSheet({ enabled: !isWeb && Boolean(moveOnKeyboardChange), }) const [isDragging, setIsDragging_] = React.useState(false) // synchronous dragging ref — set BEFORE async state commits. // RNGH onBegin fires before keyboard hide event reaches JS, // so the ref is true by the time activePositions memo re-evaluates. // Also controls pauseKeyboardHandler to freeze keyboard state during drag. const isDraggingRef = React.useRef(false) const setIsDragging = React.useCallback( (val: boolean) => { isDraggingRef.current = val pauseKeyboardHandler.current = val setIsDragging_(val) // when drag ends, flush any keyboard hide that was suppressed during drag // so isKeyboardVisible/keyboardHeight reflect actual state if (!val) { flushPendingHide() } }, [pauseKeyboardHandler, flushPendingHide] ) // keyboard-adjusted positions: shift snap points up by keyboard height // when keyboard is visible. This drives both gesture snap calculation // and animation targets — keyboard never dismissed during drag. // Capped at safe area top inset so the sheet never goes above the notch/status bar // (matching the react-native-actions-sheet pattern). // // IMPORTANT: frozen during drag to prevent gesture handler recreation. // When user drags, TextInput may blur → keyboard dismisses → positions would revert, // causing the gesture useMemo to recreate and cancel the active drag. // The post-drag reconciliation effect handles animating to correct position after drag ends. const activePositionsRef = React.useRef(positions) const activePositions = React.useMemo(() => { // during drag, return frozen positions to prevent gesture handler recreation. // check both state (for re-render trigger) and ref (for synchronous check // when keyboard hide event fires before isDragging state commits) if (isDragging || isDraggingRef.current) return activePositionsRef.current let result: number[] if (!isKeyboardVisible || keyboardHeight <= 0) { result = positions } else { const safeAreaTop = isWeb ? 0 : getSafeAreaTopInset() result = positions.map((p) => { // don't adjust the off-screen/close position (from dismissOnSnapToBottom's 0% snap) // — it must stay at screenSize so the user can drag between real snap points // without accidentally closing the sheet if (screenSize && p >= screenSize) return p return Math.max(safeAreaTop, p - keyboardHeight) }) } activePositionsRef.current = result return result }, [positions, isKeyboardVisible, keyboardHeight, screenSize, isDragging]) const { useAnimatedNumber, useAnimatedNumberStyle, useAnimatedNumberReaction } = animationDriver const AnimatedView = (animationDriver.View ?? TamaguiView) as typeof Animated.View useIsomorphicLayoutEffect(() => { if (!(sheetInsideSheet && open)) return sheetInsideSheet(true) return () => { sheetInsideSheet(false) } }, [sheetInsideSheet, open]) const nextParentContext = React.useMemo( () => ({ zIndex, }), [zIndex] ) const isMounted = useDidFinishSSR() const startPosition = isMounted && screenSize ? screenSize : hiddenSize const animatedNumber = useAnimatedNumber(startPosition) const at = React.useRef(startPosition) const hasntMeasured = at.current === hiddenSize const [disableAnimation, setDisableAnimation] = useState(hasntMeasured) // use skipNextAnimation signal from controller (set when adapt handoff occurs) const skipAdaptAnimation = React.useRef(false) if (controller?.skipNextAnimation) { skipAdaptAnimation.current = true } const hasScrollView = React.useRef(false) // safety fallback timer for sheet close opacity const opacityFallbackTimer = React.useRef | null>(null) useAnimatedNumberReaction( { value: animatedNumber, hostRef: sheetRef, }, React.useCallback( (value) => { at.current = value scrollBridge.paneY = value // update isAtTop for scroll enable/disable // activePositions[0] is the top snap point (keyboard-adjusted minY) const minY = activePositions[0] const wasAtTop = scrollBridge.isAtTop const nowAtTop = value <= minY + 5 if (wasAtTop !== nowAtTop) { scrollBridge.isAtTop = nowAtTop // when reaching top, enable scroll; when leaving top, disable scroll // this preemptively sets scroll state before any gestures start if (nowAtTop) { // if scroll drifted during drag (e.g. fast swipe from position 1), // reset it to 0 before enabling free scroll if (scrollBridge.y > 0) { scrollBridge.forceScrollTo?.(0) scrollBridge.y = 0 } scrollBridge.scrollLockY = undefined scrollBridge.setScrollEnabled?.(true) } else { scrollBridge.scrollLockY = 0 scrollBridge.setScrollEnabled?.(false) } } }, [animationDriver, activePositions] ) ) function stopSpring() { animatedNumber.stop() if (scrollBridge.onFinishAnimate) { scrollBridge.onFinishAnimate() scrollBridge.onFinishAnimate = undefined } } const animateTo = useEvent((position: number, animationOverride?: any) => { if (frameSize === 0) return let toValue = isHidden || position === -1 ? screenSize : activePositions[position] if (at.current === toValue) return at.current = toValue stopSpring() const isOpenAnimation = position !== -1 && !isHidden // clear any pending fallback timer if (opacityFallbackTimer.current) { clearTimeout(opacityFallbackTimer.current) opacityFallbackTimer.current = null } const animationCompleteCallback = () => { if (opacityFallbackTimer.current) { clearTimeout(opacityFallbackTimer.current) opacityFallbackTimer.current = null } // use openRef (live) not open (stale closure) — if the sheet // was reopened before this callback fires (e.g. cancelled close // animation), we must not hide it if (!isOpenAnimation && !openRef.current) { setOpacity(0) } onAnimationComplete?.({ open: isOpenAnimation }) // also notify the SheetController so a parent (e.g. Dialog adapt) // can hold the sheet's children mounted until the slide-out is done controller?.onAnimationComplete?.({ open: isOpenAnimation }) } // safety fallback: if animation callback never fires, still hide the sheet if (!isOpenAnimation) { opacityFallbackTimer.current = setTimeout(() => { opacityFallbackTimer.current = null // check live open state via ref — sheet may have reopened (e.g. adapt handoff) if (!openRef.current) { setOpacity(0) } }, 1000) } // skip animation when adapting from dialog to sheet if (skipAdaptAnimation.current) { skipAdaptAnimation.current = false animatedNumber.setValue( toValue, { type: 'timing', duration: 0 }, animationCompleteCallback ) return } animatedNumber.setValue( toValue, animationOverride || { type: 'spring', ...transitionConfig, }, animationCompleteCallback ) }) useIsomorphicLayoutEffect(() => { // we need to do a *three* step process for the css driver // first render off screen for ssr safety (hiddenSize) // then render to bottom of screen without animation (screenSize) // then add the animation as it animates from screenSize to position if (hasntMeasured && screenSize && frameSize) { at.current = screenSize animatedNumber.setValue( screenSize, { type: 'timing', duration: 0, }, () => { // imperfect but struggling to render properly here setTimeout(() => { setDisableAnimation(false) }, 10) } ) return } if (disableAnimation) { return } if (!frameSize || !screenSize || isHidden || (hasntMeasured && !open)) { return } // finally, animate animateTo(position) // reset scroll bridge if (position === -1) { scrollBridge.scrollLock = false scrollBridge.scrollStartY = -1 } // set initial isAtTop state when sheet opens // position 0 = top snap point, so isAtTop = true if (open && position >= 0) { const isTopPosition = position === 0 scrollBridge.isAtTop = isTopPosition if (isTopPosition) { scrollBridge.scrollLockY = undefined scrollBridge.setScrollEnabled?.(true) } else { scrollBridge.scrollLockY = 0 scrollBridge.setScrollEnabled?.(false) } } }, [hasntMeasured, disableAnimation, isHidden, frameSize, screenSize, open, position]) const disableDrag = props.disableDrag ?? controller?.disableDrag const themeName = useThemeName() const [blockPan, setBlockPan] = React.useState(false) const panResponder = React.useMemo(() => { if (disableDrag) return if (!frameSize) return if (isShowingInnerSheet) return const minY = positions[0] scrollBridge.paneMinY = minY let startY = at.current function setPanning(val: boolean) { setIsDragging(val) // make unselectable: if (process.env.TAMAGUI_TARGET === 'web') { if (!sheetHiddenStyleSheet) { sheetHiddenStyleSheet = document.createElement('style') if (typeof document.head !== 'undefined') { document.head.appendChild(sheetHiddenStyleSheet) } } if (!val) { sheetHiddenStyleSheet.innerText = '' } else { sheetHiddenStyleSheet.innerText = ':root * { user-select: none !important; -webkit-user-select: none !important; }' } } } const release = ({ vy }: { dragAt: number; vy: number }) => { scrollBridge.setParentDragging(false) if (scrollBridge.scrollLock) { return } isExternalDrag = false previouslyScrolling = false setPanning(false) // use the actual current animated position rather than dragAt + startY. // after mid-gesture handoffs (pan→scroll→pan), startY can be stale // causing the computed position to be wildly wrong (triggering dismiss). const currentPos = at.current // vy goes up to about 4 at most (+ is down, - is up) const end = currentPos + frameSize * vy * 0.2 let closestPoint = 0 let dist = Number.POSITIVE_INFINITY for (let i = 0; i < positions.length; i++) { const position = positions[i] const curDist = end > position ? end - position : position - end if (curDist < dist) { dist = curDist closestPoint = i } } // have to call both because state may not change but need to snap back setPosition(closestPoint) animateTo(closestPoint) } const finish = (_e: GestureResponderEvent, state: PanResponderGestureState) => { release({ vy: state.vy, dragAt: state.dy, }) } let previouslyScrolling = false const onMoveShouldSet = ( e: GestureResponderEvent, { dy }: PanResponderGestureState ): boolean => { function getShouldSet() { // if dragging handle always allow: if (e.target === providerProps.handleRef.current) { return true } if (scrollBridge.hasScrollableContent === true) { if (scrollBridge.scrollLock) { return false } const isScrolled = scrollBridge.y !== 0 // Update the dragging direction const isDraggingUp = dy < 0 // we can treat near top instead of exactly to avoid trouble with springs const isNearTop = scrollBridge.paneY - 5 <= scrollBridge.paneMinY if (isScrolled) { previouslyScrolling = true return false } // prevent drag once at top and pulling up if (isNearTop) { if (hasScrollView.current && isDraggingUp) { return false } } } // we could do some detection of other touchables and cancel here.. return Math.abs(dy) > 10 } const granted = getShouldSet() if (granted) { scrollBridge.setParentDragging(true) } return granted } const grant = () => { setPanning(true) stopSpring() startY = at.current } let isExternalDrag = false scrollBridge.drag = (dy) => { if (!isExternalDrag) { isExternalDrag = true grant() } const to = dy + startY animatedNumber.setValue(resisted(to, minY), { type: 'direct' }) } scrollBridge.release = release // direct snap to position without release calculation (for handoff cases) scrollBridge.snapToPosition = (positionIndex: number) => { isExternalDrag = false previouslyScrolling = false setPanning(false) setPosition(positionIndex) animateTo(positionIndex) } return PanResponder.create({ onMoveShouldSetPanResponder: onMoveShouldSet, onPanResponderGrant: grant, onPanResponderMove: (_e, { dy }) => { const toFull = dy + startY const to = resisted(toFull, minY) // handles the case where you hand off back and forth more than once const isAtTop = to <= minY if (isAtTop) { scrollBridge.setParentDragging(false) } else { scrollBridge.setParentDragging(true) } animatedNumber.setValue(to, { type: 'direct' }) }, onPanResponderEnd: finish, onPanResponderTerminate: finish, onPanResponderRelease: finish, }) }, [disableDrag, isShowingInnerSheet, animateTo, frameSize, positions, setPosition]) // animate to keyboard-adjusted position when keyboard state changes React.useEffect(() => { if (isDragging || isHidden || !open || disableAnimation) return if (!frameSize || !screenSize) return // use timing animation to match iOS keyboard animation (~250ms) animateTo(position, { type: 'timing', duration: 250 }) }, [isKeyboardVisible, keyboardHeight]) // reconcile position after drag ends — if keyboard dismissed during drag // (e.g., input blur), activePositions reverted but onEnd used frozen positions // for snap index. This effect ensures the sheet animates to the correct // non-keyboard-adjusted position for the chosen snap index. const wasDragging = React.useRef(false) React.useEffect(() => { if (isDragging) { wasDragging.current = true return } if (!wasDragging.current) return wasDragging.current = false // drag just ended — reconcile position with latest activePositions if (!frameSize || !screenSize || isHidden || !open) return animateTo(position) }, [isDragging]) // dismiss keyboard when sheet closes React.useEffect(() => { if (!open && isKeyboardVisible) { dismissKeyboard() } }, [open]) // gesture handler hook for RNGH-based gesture coordination const { panGesture, panGestureRef, gestureHandlerEnabled } = useGestureHandlerPan({ positions: activePositions, frameSize, setPosition, animateTo, stopSpring, scrollBridge, setIsDragging, getCurrentPosition: () => at.current, resisted, disableDrag, isShowingInnerSheet, setAnimatedPosition: (val: number) => { // directly set the animated value for smooth dragging at.current = val animatedNumber.setValue(val, { type: 'direct' }) }, pauseKeyboardHandler, }) const handleAnimationViewLayout = useEvent((e: LayoutChangeEvent) => { // don't update frameSize during exit animation to prevent position jumps if (!open && stableFrameSize.current !== 0) { return } // avoid bugs where it grows forever for whatever reason // For inline mode (non-modal), don't cap at window height - use actual layout const layoutHeight = e.nativeEvent?.layout.height const next = modal ? Math.min(layoutHeight, Dimensions.get(relativeDimensionTo).height) : layoutHeight if (!next) return setFrameSize(next) }) const handleMaxContentViewLayout = React.useCallback((e: LayoutChangeEvent) => { // avoid bugs where it grows forever for whatever reason const next = Math.min( e.nativeEvent?.layout.height, Dimensions.get(relativeDimensionTo).height ) if (!next) return setMaxContentSize(next) }, []) const getAnimatedNumberStyle = React.useCallback( (val: number) => { 'worklet' const translateY = frameSize === 0 ? hiddenSize : val return { transform: [{ translateY }], } }, [frameSize] ) const animatedStyle = useAnimatedNumberStyle(animatedNumber, getAnimatedNumberStyle) // we need to set this *after* fully closed to 0, to avoid it overlapping // the page when resizing quickly on web for example const [opacity, setOpacity] = React.useState(open ? 1 : 0) if (open && opacity === 0) { setOpacity(1) // cancel any pending close fallback — sheet is reopening if (opacityFallbackTimer.current) { clearTimeout(opacityFallbackTimer.current) opacityFallbackTimer.current = null } } const forcedContentHeight = hasFit ? undefined : snapPointsMode === 'percent' ? // Use dvh for modal (viewport-relative), % for inline (container-relative) `${maxSnapPoint}${isWeb ? (modal ? 'dvh' : '%') : '%'}` : maxSnapPoint const setHasScrollView = React.useCallback((val: boolean) => { hasScrollView.current = val }, []) // const id = useId() // const { AdaptProvider, when, children } = useAdaptParent({ // scope: `${id}Sheet`, // portal: true, // }) let contents = ( {shouldHideParentSheet || !open ? null : overlayComponent} {snapPointsMode !== 'percent' && ( )} {/* wrap children with plain RN View for panResponder - tamagui views no longer handle responder events on web */} {gestureHandlerEnabled && panGesture ? ( {props.children} ) : ( {props.children} )} ) if (process.env.TAMAGUI_TARGET === 'native' && needsPortalRepropagation()) { // TODO alongside sheet scope="" need to pass scope here const adaptContext = useAdaptContext() contents = ( {/* @ts-ignore */} {contents} ) } // start mounted so we get an accurate measurement the first time const shouldMountChildren = unmountChildrenWhenHidden ? !!opacity : true if (modal) { const modalContents = ( {shouldMountChildren && {contents}} ) if (isWeb) { return modalContents } // on native we don't support multiple modals yet... fix for now is to hide outer one return ( {modalContents} ) } return contents } ) function getYPositions( mode: SnapPointsMode, point: string | number, screenSize?: number, frameSize?: number ) { if (!screenSize || !frameSize) { return 0 } if (mode === 'mixed') { if (typeof point === 'number') { return screenSize - Math.min(screenSize, Math.max(0, point)) } if (point === 'fit') { return screenSize - Math.min(screenSize, frameSize) } if (point.endsWith('%')) { const pct = Math.min(100, Math.max(0, Number(point.slice(0, -1)))) / 100 if (Number.isNaN(pct)) { console.warn('Invalid snapPoint percentage string') return 0 } return Math.round(screenSize - pct * screenSize) } console.warn('Invalid snapPoint unknown value') return 0 } if (mode === 'fit') { if (point === 0) return screenSize return screenSize - Math.min(screenSize, frameSize) } if (mode === 'constant' && typeof point === 'number') { return screenSize - Math.min(screenSize, Math.max(0, point)) } const pct = Math.min(100, Math.max(0, Number(point))) / 100 if (Number.isNaN(pct)) { console.warn('Invalid snapPoint percentage') return 0 } return Math.round(screenSize - pct * screenSize) }