@tamagui/sheet 2.0.0-1769546410712 → 2.0.0-1769550075301

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/cjs/SheetImplementationCustom.cjs +66 -31
  2. package/dist/cjs/SheetImplementationCustom.js +70 -40
  3. package/dist/cjs/SheetImplementationCustom.js.map +1 -1
  4. package/dist/cjs/SheetImplementationCustom.native.js +73 -33
  5. package/dist/cjs/SheetImplementationCustom.native.js.map +1 -1
  6. package/dist/cjs/SheetScrollView.cjs +2 -0
  7. package/dist/cjs/SheetScrollView.js +2 -0
  8. package/dist/cjs/SheetScrollView.js.map +1 -1
  9. package/dist/cjs/SheetScrollView.native.js +2 -0
  10. package/dist/cjs/SheetScrollView.native.js.map +1 -1
  11. package/dist/cjs/useGestureHandlerPan.cjs +21 -11
  12. package/dist/cjs/useGestureHandlerPan.js +20 -11
  13. package/dist/cjs/useGestureHandlerPan.js.map +1 -1
  14. package/dist/cjs/useGestureHandlerPan.native.js +21 -11
  15. package/dist/cjs/useGestureHandlerPan.native.js.map +1 -1
  16. package/dist/cjs/useKeyboardControllerSheet.cjs +38 -0
  17. package/dist/cjs/useKeyboardControllerSheet.js +34 -0
  18. package/dist/cjs/useKeyboardControllerSheet.js.map +6 -0
  19. package/dist/cjs/useKeyboardControllerSheet.native.js +114 -0
  20. package/dist/cjs/useKeyboardControllerSheet.native.js.map +1 -0
  21. package/dist/esm/SheetImplementationCustom.js +73 -41
  22. package/dist/esm/SheetImplementationCustom.js.map +1 -1
  23. package/dist/esm/SheetImplementationCustom.mjs +68 -33
  24. package/dist/esm/SheetImplementationCustom.mjs.map +1 -1
  25. package/dist/esm/SheetImplementationCustom.native.js +75 -35
  26. package/dist/esm/SheetImplementationCustom.native.js.map +1 -1
  27. package/dist/esm/SheetScrollView.js +2 -0
  28. package/dist/esm/SheetScrollView.js.map +1 -1
  29. package/dist/esm/SheetScrollView.mjs +2 -0
  30. package/dist/esm/SheetScrollView.mjs.map +1 -1
  31. package/dist/esm/SheetScrollView.native.js +2 -0
  32. package/dist/esm/SheetScrollView.native.js.map +1 -1
  33. package/dist/esm/useGestureHandlerPan.js +20 -11
  34. package/dist/esm/useGestureHandlerPan.js.map +1 -1
  35. package/dist/esm/useGestureHandlerPan.mjs +21 -11
  36. package/dist/esm/useGestureHandlerPan.mjs.map +1 -1
  37. package/dist/esm/useGestureHandlerPan.native.js +21 -11
  38. package/dist/esm/useGestureHandlerPan.native.js.map +1 -1
  39. package/dist/esm/useKeyboardControllerSheet.js +18 -0
  40. package/dist/esm/useKeyboardControllerSheet.js.map +6 -0
  41. package/dist/esm/useKeyboardControllerSheet.mjs +15 -0
  42. package/dist/esm/useKeyboardControllerSheet.mjs.map +1 -0
  43. package/dist/esm/useKeyboardControllerSheet.native.js +88 -0
  44. package/dist/esm/useKeyboardControllerSheet.native.js.map +1 -0
  45. package/dist/jsx/SheetImplementationCustom.js +73 -41
  46. package/dist/jsx/SheetImplementationCustom.js.map +1 -1
  47. package/dist/jsx/SheetImplementationCustom.mjs +68 -33
  48. package/dist/jsx/SheetImplementationCustom.mjs.map +1 -1
  49. package/dist/jsx/SheetImplementationCustom.native.js +73 -33
  50. package/dist/jsx/SheetImplementationCustom.native.js.map +1 -1
  51. package/dist/jsx/SheetScrollView.js +2 -0
  52. package/dist/jsx/SheetScrollView.js.map +1 -1
  53. package/dist/jsx/SheetScrollView.mjs +2 -0
  54. package/dist/jsx/SheetScrollView.mjs.map +1 -1
  55. package/dist/jsx/SheetScrollView.native.js +2 -0
  56. package/dist/jsx/SheetScrollView.native.js.map +1 -1
  57. package/dist/jsx/useGestureHandlerPan.js +20 -11
  58. package/dist/jsx/useGestureHandlerPan.js.map +1 -1
  59. package/dist/jsx/useGestureHandlerPan.mjs +21 -11
  60. package/dist/jsx/useGestureHandlerPan.mjs.map +1 -1
  61. package/dist/jsx/useGestureHandlerPan.native.js +21 -11
  62. package/dist/jsx/useGestureHandlerPan.native.js.map +1 -1
  63. package/dist/jsx/useKeyboardControllerSheet.js +18 -0
  64. package/dist/jsx/useKeyboardControllerSheet.js.map +6 -0
  65. package/dist/jsx/useKeyboardControllerSheet.mjs +15 -0
  66. package/dist/jsx/useKeyboardControllerSheet.mjs.map +1 -0
  67. package/dist/jsx/useKeyboardControllerSheet.native.js +114 -0
  68. package/dist/jsx/useKeyboardControllerSheet.native.js.map +1 -0
  69. package/package.json +20 -20
  70. package/src/SheetImplementationCustom.tsx +138 -46
  71. package/src/SheetScrollView.tsx +2 -0
  72. package/src/types.tsx +47 -0
  73. package/src/useGestureHandlerPan.tsx +57 -10
  74. package/src/useKeyboardControllerSheet.native.ts +136 -0
  75. package/src/useKeyboardControllerSheet.ts +26 -0
  76. package/types/SheetImplementationCustom.d.ts.map +1 -1
  77. package/types/SheetScrollView.d.ts.map +1 -1
  78. package/types/types.d.ts +38 -0
  79. package/types/types.d.ts.map +1 -1
  80. package/types/useGestureHandlerPan.d.ts +2 -1
  81. package/types/useGestureHandlerPan.d.ts.map +1 -1
  82. package/types/useKeyboardControllerSheet.d.ts +7 -0
  83. package/types/useKeyboardControllerSheet.d.ts.map +1 -0
  84. package/types/useKeyboardControllerSheet.native.d.ts +13 -0
  85. package/types/useKeyboardControllerSheet.native.d.ts.map +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tamagui/sheet",
3
- "version": "2.0.0-1769546410712",
3
+ "version": "2.0.0-1769550075301",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
6
  "*.css"
@@ -72,24 +72,24 @@
72
72
  }
73
73
  },
74
74
  "dependencies": {
75
- "@tamagui/adapt": "2.0.0-1769546410712",
76
- "@tamagui/animate-presence": "2.0.0-1769546410712",
77
- "@tamagui/animations-react-native": "2.0.0-1769546410712",
78
- "@tamagui/compose-refs": "2.0.0-1769546410712",
79
- "@tamagui/constants": "2.0.0-1769546410712",
80
- "@tamagui/core": "2.0.0-1769546410712",
81
- "@tamagui/create-context": "2.0.0-1769546410712",
82
- "@tamagui/helpers": "2.0.0-1769546410712",
83
- "@tamagui/native": "2.0.0-1769546410712",
84
- "@tamagui/portal": "2.0.0-1769546410712",
85
- "@tamagui/remove-scroll": "2.0.0-1769546410712",
86
- "@tamagui/scroll-view": "2.0.0-1769546410712",
87
- "@tamagui/stacks": "2.0.0-1769546410712",
88
- "@tamagui/use-constant": "2.0.0-1769546410712",
89
- "@tamagui/use-controllable-state": "2.0.0-1769546410712",
90
- "@tamagui/use-did-finish-ssr": "2.0.0-1769546410712",
91
- "@tamagui/use-keyboard-visible": "2.0.0-1769546410712",
92
- "@tamagui/z-index-stack": "2.0.0-1769546410712"
75
+ "@tamagui/adapt": "2.0.0-1769550075301",
76
+ "@tamagui/animate-presence": "2.0.0-1769550075301",
77
+ "@tamagui/animations-react-native": "2.0.0-1769550075301",
78
+ "@tamagui/compose-refs": "2.0.0-1769550075301",
79
+ "@tamagui/constants": "2.0.0-1769550075301",
80
+ "@tamagui/core": "2.0.0-1769550075301",
81
+ "@tamagui/create-context": "2.0.0-1769550075301",
82
+ "@tamagui/helpers": "2.0.0-1769550075301",
83
+ "@tamagui/native": "2.0.0-1769550075301",
84
+ "@tamagui/portal": "2.0.0-1769550075301",
85
+ "@tamagui/remove-scroll": "2.0.0-1769550075301",
86
+ "@tamagui/scroll-view": "2.0.0-1769550075301",
87
+ "@tamagui/stacks": "2.0.0-1769550075301",
88
+ "@tamagui/use-constant": "2.0.0-1769550075301",
89
+ "@tamagui/use-controllable-state": "2.0.0-1769550075301",
90
+ "@tamagui/use-did-finish-ssr": "2.0.0-1769550075301",
91
+ "@tamagui/use-keyboard-visible": "2.0.0-1769550075301",
92
+ "@tamagui/z-index-stack": "2.0.0-1769550075301"
93
93
  },
94
94
  "peerDependencies": {
95
95
  "react": "*",
@@ -102,7 +102,7 @@
102
102
  }
103
103
  },
104
104
  "devDependencies": {
105
- "@tamagui/build": "2.0.0-1769546410712",
105
+ "@tamagui/build": "2.0.0-1769550075301",
106
106
  "react": "*",
107
107
  "react-native": "0.81.5",
108
108
  "react-native-gesture-handler": "~2.28.0"
@@ -19,19 +19,41 @@ import type {
19
19
  LayoutChangeEvent,
20
20
  PanResponderGestureState,
21
21
  } from 'react-native'
22
- import { Dimensions, Keyboard, PanResponder, View } from 'react-native'
22
+ import { Dimensions, PanResponder, View } from 'react-native'
23
23
  import { ParentSheetContext, SheetInsideSheetContext } from './contexts'
24
24
  import { GestureDetectorWrapper } from './GestureDetectorWrapper'
25
25
  import { GestureSheetProvider } from './GestureSheetContext'
26
+ import { getSafeArea } from '@tamagui/native'
26
27
  import { resisted } from './helpers'
27
28
  import { SheetProvider } from './SheetContext'
28
29
  import type { SheetProps, SnapPointsMode } from './types'
29
30
  import { useGestureHandlerPan } from './useGestureHandlerPan'
31
+ import { useKeyboardControllerSheet } from './useKeyboardControllerSheet'
30
32
  import { useSheetOpenState } from './useSheetOpenState'
31
33
  import { useSheetProviderProps } from './useSheetProviderProps'
32
34
 
33
35
  const hiddenSize = 10_000.1
34
36
 
37
+ // safe area top inset, cached per-session (device-constant value)
38
+ let _cachedSafeAreaTop: number | undefined
39
+ function getSafeAreaTopInset(): number {
40
+ if (_cachedSafeAreaTop !== undefined) return _cachedSafeAreaTop
41
+ // try tamagui native safe area state first
42
+ const sa = getSafeArea()
43
+ if (sa.isEnabled) {
44
+ _cachedSafeAreaTop = sa.getInsets().top
45
+ return _cachedSafeAreaTop
46
+ }
47
+ // fallback: react-native-safe-area-context initialWindowMetrics (no provider needed)
48
+ try {
49
+ const sac = require('react-native-safe-area-context')
50
+ _cachedSafeAreaTop = sac.initialWindowMetrics?.insets?.top ?? 0
51
+ } catch {
52
+ _cachedSafeAreaTop = 0
53
+ }
54
+ return _cachedSafeAreaTop ?? 0
55
+ }
56
+
35
57
  let sheetHiddenStyleSheet: HTMLStyleElement | null = null
36
58
 
37
59
  // on web we are always relative to window, on to screen
@@ -141,6 +163,74 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
141
163
  [screenSize, effectiveFrameSize, snapPoints, snapPointsMode]
142
164
  )
143
165
 
166
+ // keyboard state tracking — just tracks height/visibility, no position animation.
167
+ // Position animation is handled via keyboard-adjusted positions below,
168
+ // matching the react-native-actions-sheet pattern.
169
+ const {
170
+ keyboardHeight,
171
+ isKeyboardVisible,
172
+ dismissKeyboard,
173
+ pauseKeyboardHandler,
174
+ flushPendingHide,
175
+ } = useKeyboardControllerSheet({
176
+ enabled: !isWeb && Boolean(moveOnKeyboardChange),
177
+ })
178
+
179
+ const [isDragging, setIsDragging_] = React.useState(false)
180
+
181
+ // synchronous dragging ref — set BEFORE async state commits.
182
+ // RNGH onBegin fires before keyboard hide event reaches JS,
183
+ // so the ref is true by the time activePositions memo re-evaluates.
184
+ // Also controls pauseKeyboardHandler to freeze keyboard state during drag.
185
+ const isDraggingRef = React.useRef(false)
186
+ const setIsDragging = React.useCallback(
187
+ (val: boolean) => {
188
+ isDraggingRef.current = val
189
+ pauseKeyboardHandler.current = val
190
+ setIsDragging_(val)
191
+ // when drag ends, flush any keyboard hide that was suppressed during drag
192
+ // so isKeyboardVisible/keyboardHeight reflect actual state
193
+ if (!val) {
194
+ flushPendingHide()
195
+ }
196
+ },
197
+ [pauseKeyboardHandler, flushPendingHide]
198
+ )
199
+
200
+ // keyboard-adjusted positions: shift snap points up by keyboard height
201
+ // when keyboard is visible. This drives both gesture snap calculation
202
+ // and animation targets — keyboard never dismissed during drag.
203
+ // Capped at safe area top inset so the sheet never goes above the notch/status bar
204
+ // (matching the react-native-actions-sheet pattern).
205
+ //
206
+ // IMPORTANT: frozen during drag to prevent gesture handler recreation.
207
+ // When user drags, TextInput may blur → keyboard dismisses → positions would revert,
208
+ // causing the gesture useMemo to recreate and cancel the active drag.
209
+ // The post-drag reconciliation effect handles animating to correct position after drag ends.
210
+ const activePositionsRef = React.useRef(positions)
211
+ const activePositions = React.useMemo(() => {
212
+ // during drag, return frozen positions to prevent gesture handler recreation.
213
+ // check both state (for re-render trigger) and ref (for synchronous check
214
+ // when keyboard hide event fires before isDragging state commits)
215
+ if (isDragging || isDraggingRef.current) return activePositionsRef.current
216
+
217
+ let result: number[]
218
+ if (!isKeyboardVisible || keyboardHeight <= 0) {
219
+ result = positions
220
+ } else {
221
+ const safeAreaTop = isWeb ? 0 : getSafeAreaTopInset()
222
+ result = positions.map((p) => {
223
+ // don't adjust the off-screen/close position (from dismissOnSnapToBottom's 0% snap)
224
+ // — it must stay at screenSize so the user can drag between real snap points
225
+ // without accidentally closing the sheet
226
+ if (screenSize && p >= screenSize) return p
227
+ return Math.max(safeAreaTop, p - keyboardHeight)
228
+ })
229
+ }
230
+ activePositionsRef.current = result
231
+ return result
232
+ }, [positions, isKeyboardVisible, keyboardHeight, screenSize, isDragging])
233
+
144
234
  const { useAnimatedNumber, useAnimatedNumberStyle, useAnimatedNumberReaction } =
145
235
  animationDriver
146
236
  const AnimatedView = (animationDriver.View ?? TamaguiView) as typeof Animated.View
@@ -179,8 +269,8 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
179
269
  at.current = value
180
270
  scrollBridge.paneY = value
181
271
  // update isAtTop for scroll enable/disable
182
- // positions[0] is the top snap point (minY)
183
- const minY = positions[0]
272
+ // activePositions[0] is the top snap point (keyboard-adjusted minY)
273
+ const minY = activePositions[0]
184
274
  const wasAtTop = scrollBridge.isAtTop
185
275
  const nowAtTop = value <= minY + 5
186
276
  if (wasAtTop !== nowAtTop) {
@@ -196,7 +286,7 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
196
286
  }
197
287
  }
198
288
  },
199
- [animationDriver, positions]
289
+ [animationDriver, activePositions]
200
290
  )
201
291
  )
202
292
 
@@ -208,20 +298,23 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
208
298
  }
209
299
  }
210
300
 
211
- const animateTo = useEvent((position: number) => {
301
+ const animateTo = useEvent((position: number, animationOverride?: any) => {
212
302
  if (frameSize === 0) return
213
303
 
214
- let toValue = isHidden || position === -1 ? screenSize : positions[position]
304
+ let toValue = isHidden || position === -1 ? screenSize : activePositions[position]
215
305
 
216
306
  if (at.current === toValue) return
217
307
 
218
308
  at.current = toValue
219
309
  stopSpring()
220
310
 
221
- animatedNumber.setValue(toValue, {
222
- type: 'spring',
223
- ...transitionConfig,
224
- })
311
+ animatedNumber.setValue(
312
+ toValue,
313
+ animationOverride || {
314
+ type: 'spring',
315
+ ...transitionConfig,
316
+ }
317
+ )
225
318
  })
226
319
 
227
320
  useIsomorphicLayoutEffect(() => {
@@ -282,7 +375,6 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
282
375
 
283
376
  const disableDrag = props.disableDrag ?? controller?.disableDrag
284
377
  const themeName = useThemeName()
285
- const [isDragging, setIsDragging] = React.useState(false)
286
378
  const [blockPan, setBlockPan] = React.useState(false)
287
379
 
288
380
  const panResponder = React.useMemo(() => {
@@ -442,9 +534,41 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
442
534
  })
443
535
  }, [disableDrag, isShowingInnerSheet, animateTo, frameSize, positions, setPosition])
444
536
 
537
+ // animate to keyboard-adjusted position when keyboard state changes
538
+ React.useEffect(() => {
539
+ if (isDragging || isHidden || !open || disableAnimation) return
540
+ if (!frameSize || !screenSize) return
541
+ // use timing animation to match iOS keyboard animation (~250ms)
542
+ animateTo(position, { type: 'timing', duration: 250 })
543
+ }, [isKeyboardVisible, keyboardHeight])
544
+
545
+ // reconcile position after drag ends — if keyboard dismissed during drag
546
+ // (e.g., input blur), activePositions reverted but onEnd used frozen positions
547
+ // for snap index. This effect ensures the sheet animates to the correct
548
+ // non-keyboard-adjusted position for the chosen snap index.
549
+ const wasDragging = React.useRef(false)
550
+ React.useEffect(() => {
551
+ if (isDragging) {
552
+ wasDragging.current = true
553
+ return
554
+ }
555
+ if (!wasDragging.current) return
556
+ wasDragging.current = false
557
+ // drag just ended — reconcile position with latest activePositions
558
+ if (!frameSize || !screenSize || isHidden || !open) return
559
+ animateTo(position)
560
+ }, [isDragging])
561
+
562
+ // dismiss keyboard when sheet closes
563
+ React.useEffect(() => {
564
+ if (!open && isKeyboardVisible) {
565
+ dismissKeyboard()
566
+ }
567
+ }, [open])
568
+
445
569
  // gesture handler hook for RNGH-based gesture coordination
446
570
  const { panGesture, panGestureRef, gestureHandlerEnabled } = useGestureHandlerPan({
447
- positions,
571
+ positions: activePositions,
448
572
  frameSize,
449
573
  setPosition,
450
574
  animateTo,
@@ -457,9 +581,10 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
457
581
  isShowingInnerSheet,
458
582
  setAnimatedPosition: (val: number) => {
459
583
  // directly set the animated value for smooth dragging
460
- // console.warn('[RNGH-Sheet] setAnimatedPosition:', val.toFixed(1))
584
+ at.current = val
461
585
  animatedNumber.setValue(val, { type: 'direct' })
462
586
  },
587
+ pauseKeyboardHandler,
463
588
  })
464
589
 
465
590
  const handleAnimationViewLayout = React.useCallback(
@@ -500,39 +625,6 @@ export const SheetImplementationCustom = React.forwardRef<View, SheetProps>(
500
625
  }
501
626
  })
502
627
 
503
- const sizeBeforeKeyboard = React.useRef<number | null>(null)
504
- React.useEffect(() => {
505
- if (isWeb || !moveOnKeyboardChange) return
506
- const keyboardShowListener = Keyboard.addListener(
507
- currentPlatform === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow',
508
- (e) => {
509
- if (sizeBeforeKeyboard.current !== null) return
510
- sizeBeforeKeyboard.current =
511
- isHidden || position === -1 ? screenSize : positions[position]
512
- animatedNumber.setValue(
513
- Math.max(sizeBeforeKeyboard.current - e.endCoordinates.height, 0),
514
- {
515
- type: 'timing',
516
- duration: 250,
517
- }
518
- )
519
- }
520
- )
521
- const keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', () => {
522
- if (sizeBeforeKeyboard.current === null) return
523
- animatedNumber.setValue(sizeBeforeKeyboard.current, {
524
- type: 'timing',
525
- duration: 250,
526
- })
527
- sizeBeforeKeyboard.current = null
528
- })
529
-
530
- return () => {
531
- keyboardDidHideListener.remove()
532
- keyboardShowListener.remove()
533
- }
534
- }, [moveOnKeyboardChange, positions, position, isHidden])
535
-
536
628
  // we need to set this *after* fully closed to 0, to avoid it overlapping
537
629
  // the page when resizing quickly on web for example
538
630
  const [opacity, setOpacity] = React.useState(open ? 1 : 0)
@@ -279,6 +279,8 @@ export const SheetScrollView = React.forwardRef<
279
279
  }}
280
280
  contentContainerStyle={{ minHeight: '100%' }}
281
281
  bounces={false}
282
+ keyboardShouldPersistTaps="always"
283
+ keyboardDismissMode="none"
282
284
  {...props}
283
285
  >
284
286
  {/* wrapper to measure actual content height (not min-height expanded) */}
package/src/types.tsx CHANGED
@@ -125,3 +125,50 @@ export type ScrollBridge = {
125
125
  // whether sheet is at top position (for scroll enable/disable)
126
126
  isAtTop?: boolean
127
127
  }
128
+
129
+ // keyboard controller sheet types
130
+
131
+ export interface KeyboardControllerSheetOptions {
132
+ /**
133
+ * Whether keyboard handling is enabled.
134
+ * When false, the hook is a no-op.
135
+ */
136
+ enabled: boolean
137
+ }
138
+
139
+ export interface KeyboardControllerSheetResult {
140
+ /**
141
+ * Whether keyboard-controller is available and enabled.
142
+ */
143
+ keyboardControllerEnabled: boolean
144
+
145
+ /**
146
+ * Current keyboard height (0 when hidden).
147
+ * On web or when keyboard-controller is not available, always 0.
148
+ */
149
+ keyboardHeight: number
150
+
151
+ /**
152
+ * Whether the keyboard is currently visible.
153
+ */
154
+ isKeyboardVisible: boolean
155
+
156
+ /**
157
+ * Dismiss the keyboard programmatically.
158
+ * Called when sheet closes to dismiss the keyboard.
159
+ */
160
+ dismissKeyboard: () => void
161
+
162
+ /**
163
+ * Ref to pause keyboard hide state updates (action-sheet pattern).
164
+ * When true, keyboard hide events are ignored — keeps isKeyboardVisible=true
165
+ * and keyboardHeight at their last values during drag.
166
+ */
167
+ pauseKeyboardHandler: React.RefObject<boolean>
168
+
169
+ /**
170
+ * Flush any keyboard hide event that was suppressed while paused.
171
+ * Call after drag ends to reconcile actual keyboard state.
172
+ */
173
+ flushPendingHide: () => void
174
+ }
@@ -10,7 +10,7 @@ interface GesturePanConfig {
10
10
  positions: number[]
11
11
  frameSize: number
12
12
  setPosition: (pos: number) => void
13
- animateTo: (pos: number) => void
13
+ animateTo: (pos: number, animationOverride?: any) => void
14
14
  stopSpring: () => void
15
15
  scrollBridge: ScrollBridge
16
16
  setIsDragging: (val: boolean) => void
@@ -22,6 +22,8 @@ interface GesturePanConfig {
22
22
  setAnimatedPosition: (val: number) => void
23
23
  // ref to scroll gesture for simultaneousWithExternalGesture
24
24
  scrollGestureRef?: RefObject<any> | null
25
+ // ref to pause keyboard hide events during gesture (action-sheet pattern)
26
+ pauseKeyboardHandler?: RefObject<boolean>
25
27
  }
26
28
 
27
29
  interface GesturePanResult {
@@ -80,21 +82,26 @@ export function useGestureHandlerPan(config: GesturePanConfig): GesturePanResult
80
82
  prevTranslationY: 0,
81
83
  // track if scroll was engaged (scrollY > 0) at some point
82
84
  scrollEngaged: false,
85
+ // positions frozen at gesture start — keyboard may dismiss during drag (input blur),
86
+ // causing positions to revert. Frozen positions ensure stable snap calculation.
87
+ frozenPositions: [] as number[],
88
+ frozenMinY: 0,
89
+ // whether pan gesture actually started (vs just a tap in onBegin)
90
+ panStarted: false,
83
91
  })
84
92
 
85
93
  const onStart = useCallback(() => {
86
94
  stopSpring()
87
- setIsDragging(true)
88
- }, [stopSpring, setIsDragging])
95
+ }, [stopSpring])
89
96
 
90
97
  const onEnd = useCallback(
91
- (closestPoint: number) => {
98
+ (closestPoint: number, animationOverride?: any) => {
92
99
  setIsDragging(false)
93
100
  scrollBridge.setParentDragging(false)
94
101
  // re-enable scroll when gesture ends
95
102
  scrollBridge.setScrollEnabled?.(true)
96
103
  setPosition(closestPoint)
97
- animateTo(closestPoint)
104
+ animateTo(closestPoint, animationOverride)
98
105
  },
99
106
  [setIsDragging, scrollBridge, setPosition, animateTo]
100
107
  )
@@ -122,6 +129,18 @@ export function useGestureHandlerPan(config: GesturePanConfig): GesturePanResult
122
129
  .failOffsetX([-20, 20])
123
130
  .shouldCancelWhenOutside(false)
124
131
  .onBegin(() => {
132
+ // onBegin fires on ANY touch (including taps to focus inputs).
133
+ // We do NOT set isDragging here — that would block keyboard animation.
134
+ // Instead, isDragging is set in onStart (actual pan gesture recognized).
135
+ gs.panStarted = false
136
+
137
+ // lightweight: pause keyboard handler to suppress hide events during gesture.
138
+ // This prevents keyboard flicker if input blurs before onStart.
139
+ // If this is just a tap, onFinalize un-pauses without setting isDragging.
140
+ if (config.pauseKeyboardHandler) {
141
+ config.pauseKeyboardHandler.current = true
142
+ }
143
+
125
144
  // check position at gesture begin - before direction is known
126
145
  const pos = getCurrentPosition()
127
146
  const atTop = pos <= minY + AT_TOP_THRESHOLD
@@ -132,6 +151,10 @@ export function useGestureHandlerPan(config: GesturePanConfig): GesturePanResult
132
151
  gs.accumulatedOffset = 0
133
152
  gs.prevTranslationY = 0
134
153
  gs.scrollEngaged = currentScrollY > 0 // track if scroll is already engaged
154
+ // freeze positions at gesture start so keyboard dismiss during drag
155
+ // doesn't change snap targets mid-gesture
156
+ gs.frozenPositions = [...positions]
157
+ gs.frozenMinY = minY
135
158
 
136
159
  // if sheet not at top, DISABLE SCROLL immediately and lock to 0
137
160
  // this prevents scroll from firing before pan takes over
@@ -140,6 +163,11 @@ export function useGestureHandlerPan(config: GesturePanConfig): GesturePanResult
140
163
  }
141
164
  })
142
165
  .onStart(() => {
166
+ // onStart fires only when pan gesture is recognized (finger moved enough).
167
+ // Safe to set isDragging here — this won't fire for taps to focus inputs.
168
+ gs.panStarted = true
169
+ setIsDragging(true)
170
+
143
171
  // console.warn('[RNGH-Pan] onStart', { startY: gs.startY, minY })
144
172
  scrollBridge.initialPosition = gs.startY
145
173
  onStart()
@@ -250,21 +278,30 @@ export function useGestureHandlerPan(config: GesturePanConfig): GesturePanResult
250
278
  // clear scroll lock
251
279
  scrollBridge.scrollLockY = undefined
252
280
 
281
+ // use frozen positions from gesture start — keyboard may have dismissed
282
+ // during drag (input blur), reverting activePositions. Frozen positions
283
+ // ensure the snap index reflects the user's intent at drag start.
284
+ // The onEnd callback's animateTo uses latest activePositions for the
285
+ // actual animation target, so the sheet ends up at the right place.
286
+ const snapPositions =
287
+ gs.frozenPositions.length > 0 ? gs.frozenPositions : positions
288
+ const snapMinY = gs.frozenPositions.length > 0 ? gs.frozenMinY : minY
289
+
253
290
  // if sheet is at top and scroll is engaged, just stay at top
254
- if (currentPos <= minY + AT_TOP_THRESHOLD && scrollBridge.y > 0) {
291
+ if (currentPos <= snapMinY + AT_TOP_THRESHOLD && scrollBridge.y > 0) {
255
292
  onEnd(0)
256
293
  return
257
294
  }
258
295
 
259
- // find closest snap point using current position and velocity
296
+ // snap calculation using frozen positions
260
297
  const velocity = velocityY / 1000
261
298
  const projectedEnd = currentPos + frameSize * velocity * 0.2
262
299
 
263
300
  let closestPoint = 0
264
301
  let minDist = Number.POSITIVE_INFINITY
265
302
 
266
- for (let i = 0; i < positions.length; i++) {
267
- const pos = positions[i]
303
+ for (let i = 0; i < snapPositions.length; i++) {
304
+ const pos = snapPositions[i]
268
305
  const dist = Math.abs(projectedEnd - pos)
269
306
  if (dist < minDist) {
270
307
  minDist = dist
@@ -275,9 +312,19 @@ export function useGestureHandlerPan(config: GesturePanConfig): GesturePanResult
275
312
  onEnd(closestPoint)
276
313
  })
277
314
  .onFinalize(() => {
278
- // console.warn('[RNGH-Pan] onFinalize')
315
+ // console.warn('[RNGH-Pan] onFinalize', { panStarted: gs.panStarted })
279
316
  // clear scroll lock on finalize too (safety)
280
317
  scrollBridge.scrollLockY = undefined
318
+ if (gs.panStarted) {
319
+ // real pan gesture — reset isDragging (also un-pauses keyboard handler + flushes)
320
+ setIsDragging(false)
321
+ } else {
322
+ // just a tap (onBegin fired but onStart never did) — un-pause keyboard handler
323
+ // without setting isDragging (which would block keyboard animation effect)
324
+ if (config.pauseKeyboardHandler) {
325
+ config.pauseKeyboardHandler.current = false
326
+ }
327
+ }
281
328
  })
282
329
  .runOnJS(true)
283
330
 
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Native implementation of keyboard controller sheet hook.
3
+ *
4
+ * Simplified to just track keyboard state (height, visibility).
5
+ * Position animation is handled by SheetImplementationCustom via
6
+ * keyboard-adjusted positions — matching the react-native-actions-sheet pattern.
7
+ *
8
+ * Uses react-native-keyboard-controller events when available,
9
+ * falls back to basic Keyboard API otherwise.
10
+ */
11
+
12
+ import { useCallback, useEffect, useRef, useState } from 'react'
13
+ import { Keyboard, Platform } from 'react-native'
14
+ import type {
15
+ KeyboardControllerSheetOptions,
16
+ KeyboardControllerSheetResult,
17
+ } from './types'
18
+
19
+ // lazy import state accessors
20
+ let isKeyboardControllerEnabled: () => boolean = () => false
21
+ let getKeyboardControllerState: () => any = () => ({})
22
+
23
+ try {
24
+ const nativeModule = require('@tamagui/native')
25
+ isKeyboardControllerEnabled = nativeModule.isKeyboardControllerEnabled
26
+ getKeyboardControllerState = nativeModule.getKeyboardControllerState
27
+ } catch {
28
+ // @tamagui/native not available
29
+ }
30
+
31
+ export function useKeyboardControllerSheet(
32
+ options: KeyboardControllerSheetOptions
33
+ ): KeyboardControllerSheetResult {
34
+ const { enabled } = options
35
+
36
+ const [keyboardHeight, setKeyboardHeight] = useState(0)
37
+ const [isKeyboardVisible, setIsKeyboardVisible] = useState(false)
38
+ const keyboardControllerEnabled = isKeyboardControllerEnabled()
39
+
40
+ // action-sheet pattern: pause keyboard hide events during drag
41
+ // when true, keyboard hide events are ignored so isKeyboardVisible stays true
42
+ // and activePositions don't revert mid-gesture
43
+ const pauseKeyboardHandler = useRef(false)
44
+ // tracks if a keyboard hide event was suppressed while paused
45
+ const pendingHide = useRef(false)
46
+
47
+ // dismiss keyboard helper
48
+ const dismissKeyboard = useCallback(() => {
49
+ Keyboard.dismiss()
50
+ if (keyboardControllerEnabled) {
51
+ try {
52
+ const { KeyboardController } = getKeyboardControllerState()
53
+ KeyboardController?.dismiss?.()
54
+ } catch {
55
+ // ignore errors from keyboard-controller
56
+ }
57
+ }
58
+ }, [keyboardControllerEnabled])
59
+
60
+ // flush any keyboard hide that was suppressed during drag.
61
+ // called when drag ends to reconcile actual keyboard state.
62
+ const flushPendingHide = useCallback(() => {
63
+ if (pendingHide.current) {
64
+ pendingHide.current = false
65
+ setIsKeyboardVisible(false)
66
+ setKeyboardHeight(0)
67
+ }
68
+ }, [])
69
+
70
+ // keyboard-controller event listeners (preferred when available)
71
+ useEffect(() => {
72
+ if (!enabled || !keyboardControllerEnabled) return
73
+
74
+ const { KeyboardEvents } = getKeyboardControllerState()
75
+ if (!KeyboardEvents?.addListener) return
76
+
77
+ const showSub = KeyboardEvents.addListener('keyboardWillShow', (e: any) => {
78
+ const height = e?.height ?? 0
79
+ if (height > 0) {
80
+ setKeyboardHeight(height)
81
+ }
82
+ setIsKeyboardVisible(true)
83
+ })
84
+
85
+ const hideSub = KeyboardEvents.addListener('keyboardWillHide', () => {
86
+ if (pauseKeyboardHandler.current) {
87
+ pendingHide.current = true
88
+ return
89
+ }
90
+ setIsKeyboardVisible(false)
91
+ setKeyboardHeight(0)
92
+ })
93
+
94
+ return () => {
95
+ showSub?.remove?.()
96
+ hideSub?.remove?.()
97
+ }
98
+ }, [enabled, keyboardControllerEnabled])
99
+
100
+ // fallback to basic Keyboard API when keyboard-controller not available
101
+ useEffect(() => {
102
+ if (!enabled) return
103
+ if (keyboardControllerEnabled) return
104
+
105
+ const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
106
+ const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'
107
+
108
+ const showListener = Keyboard.addListener(showEvent, (e) => {
109
+ setKeyboardHeight(e.endCoordinates.height)
110
+ setIsKeyboardVisible(true)
111
+ })
112
+
113
+ const hideListener = Keyboard.addListener(hideEvent, () => {
114
+ if (pauseKeyboardHandler.current) {
115
+ pendingHide.current = true
116
+ return
117
+ }
118
+ setIsKeyboardVisible(false)
119
+ setKeyboardHeight(0)
120
+ })
121
+
122
+ return () => {
123
+ showListener.remove()
124
+ hideListener.remove()
125
+ }
126
+ }, [enabled, keyboardControllerEnabled])
127
+
128
+ return {
129
+ keyboardControllerEnabled,
130
+ keyboardHeight,
131
+ isKeyboardVisible,
132
+ dismissKeyboard,
133
+ pauseKeyboardHandler,
134
+ flushPendingHide,
135
+ }
136
+ }