@vvedantb/native-ui 0.13.0 → 0.17.0

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 (108) hide show
  1. package/README.md +40 -19
  2. package/package.json +36 -3
  3. package/src/BottomSheetContainer.tsx +2 -2
  4. package/src/BulkActionBar.tsx +240 -105
  5. package/src/Button.tsx +11 -7
  6. package/src/ConfirmDialog.tsx +25 -35
  7. package/src/Dialog.tsx +47 -35
  8. package/src/DividedList.tsx +11 -7
  9. package/src/DropdownMenu.tsx +22 -8
  10. package/src/FloatingSurface.tsx +5 -2
  11. package/src/FloatingTabBar.tsx +13 -22
  12. package/src/HeaderIconButton.tsx +4 -3
  13. package/src/Input.tsx +2 -2
  14. package/src/LoadingState.tsx +132 -0
  15. package/src/PageTitle.tsx +9 -2
  16. package/src/Pager.tsx +62 -0
  17. package/src/PasswordInput.tsx +1 -1
  18. package/src/PillTabs.tsx +144 -34
  19. package/src/Progress.tsx +9 -7
  20. package/src/Select.tsx +4 -4
  21. package/src/Sheet.tsx +29 -22
  22. package/src/SheetRow.tsx +27 -9
  23. package/src/Skeleton.tsx +91 -9
  24. package/src/SurfaceRow.tsx +111 -0
  25. package/src/SwipeableRow.tsx +43 -0
  26. package/src/Switch.tsx +6 -4
  27. package/src/Tabs.tsx +68 -13
  28. package/src/Toast.tsx +107 -65
  29. package/src/ai/activity-step-detail.tsx +1 -3
  30. package/src/ai/activity-step-label.ts +981 -0
  31. package/src/ai/activity-step-types.ts +107 -0
  32. package/src/ai/activity-tasks-utils.ts +199 -0
  33. package/src/ai/activity-timeline.ts +45 -0
  34. package/src/ai/attachments.tsx +3 -1
  35. package/src/ai/chat-text.ts +13 -7
  36. package/src/ai/chat-thread.tsx +34 -27
  37. package/src/ai/choice-question.tsx +154 -35
  38. package/src/ai/code-block.tsx +189 -49
  39. package/src/ai/collapsible.tsx +19 -13
  40. package/src/ai/conversation.tsx +390 -57
  41. package/src/ai/icon-button.tsx +41 -6
  42. package/src/ai/index.ts +38 -1
  43. package/src/ai/message-action-sheet.tsx +4 -1
  44. package/src/ai/message.tsx +60 -31
  45. package/src/ai/prompt-input-menu.tsx +197 -0
  46. package/src/ai/prompt-input.tsx +301 -75
  47. package/src/ai/shimmer.tsx +4 -6
  48. package/src/ai/trace.tsx +54 -17
  49. package/src/ai/unified-diff.tsx +182 -0
  50. package/src/auth/GoogleSignInButton.tsx +6 -3
  51. package/src/auth/SignInScreen.tsx +11 -2
  52. package/src/brand/ProviderIcon.tsx +136 -0
  53. package/src/brand/index.ts +2 -0
  54. package/src/configureTheme.ts +39 -11
  55. package/src/controlStyles.ts +46 -30
  56. package/src/entries/gesture-handler.ts +4 -0
  57. package/src/entries/keyboard-controller.ts +4 -0
  58. package/src/entries/markdown.ts +1 -1
  59. package/src/entries/primitives.ts +24 -3
  60. package/src/entries/theme.ts +2 -1
  61. package/src/entries/video.ts +4 -0
  62. package/src/entries/webview.ts +6 -0
  63. package/src/floating.ts +25 -4
  64. package/src/haptics.ts +30 -4
  65. package/src/highlight.ts +302 -1
  66. package/src/index.ts +27 -5
  67. package/src/loadingGrid.ts +75 -0
  68. package/src/markdown/Markdown.tsx +234 -32
  69. package/src/markdown/MarkdownTable.tsx +188 -0
  70. package/src/markdown/tableText.ts +58 -0
  71. package/src/marketing/UpgradeToProCard.tsx +4 -3
  72. package/src/media/ImageGallery.tsx +224 -0
  73. package/src/media/MediaCard.tsx +100 -0
  74. package/src/media/imageGrid.ts +51 -0
  75. package/src/motion.ts +33 -0
  76. package/src/pagerSwipe.ts +102 -0
  77. package/src/palette.ts +8 -1
  78. package/src/primitives/button.tsx +4 -1
  79. package/src/primitives/keyboard-controller.tsx +106 -0
  80. package/src/primitives/keyboard-parts.tsx +104 -0
  81. package/src/primitives/overlay.tsx +133 -45
  82. package/src/primitives/pager-gesture.tsx +131 -0
  83. package/src/primitives/sheet-drag.tsx +305 -0
  84. package/src/primitives/sheet-gesture.tsx +181 -0
  85. package/src/primitives/swipe-row-gesture.tsx +290 -0
  86. package/src/primitives/toast-drag-gesture.tsx +92 -0
  87. package/src/primitives/web-click-guard.ts +50 -0
  88. package/src/relativeTime.ts +94 -0
  89. package/src/router/RouterStackPageHeader.tsx +2 -2
  90. package/src/settings/SettingsEmptyState.tsx +20 -10
  91. package/src/settings/SettingsField.tsx +11 -5
  92. package/src/settings/SettingsSection.tsx +8 -5
  93. package/src/settings/SettingsToggleRow.tsx +55 -22
  94. package/src/settings/relaxedSizeStyle.ts +11 -0
  95. package/src/sheetDrag.ts +67 -0
  96. package/src/swipeRow.ts +169 -0
  97. package/src/tabBarRoutes.ts +18 -0
  98. package/src/tabIndicator.ts +71 -0
  99. package/src/tailwind-preset.cjs +13 -6
  100. package/src/tailwind-preset.d.cts +5 -0
  101. package/src/theme.tsx +6 -2
  102. package/src/toastDrag.ts +58 -0
  103. package/src/typography.ts +20 -3
  104. package/src/useScheme.ts +10 -1
  105. package/src/video/InlineVideo.tsx +91 -0
  106. package/src/webview/HtmlView.tsx +91 -0
  107. package/src/webview/HtmlView.web.tsx +21 -0
  108. package/src/webview/htmlView.ts +45 -0
@@ -0,0 +1,131 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { Gesture, GestureDetector } from "react-native-gesture-handler";
4
+ import Animated, {
5
+ cancelAnimation,
6
+ ReduceMotion,
7
+ runOnJS,
8
+ useAnimatedStyle,
9
+ useSharedValue,
10
+ withSpring,
11
+ withTiming,
12
+ } from "react-native-reanimated";
13
+ import { selectionHaptic } from "../haptics";
14
+ import { InsidePagerContext, type PagerProps } from "../Pager";
15
+ import { PAGER_ACTIVE_OFFSET_X, PAGER_FAIL_OFFSET_Y, pagerPosition, pagerSettle, pagesBetween } from "../pagerSwipe";
16
+ import { useWebClickGuard } from "./web-click-guard";
17
+
18
+ // The pager on Gesture Handler: the track phase and the release run as
19
+ // worklets on the UI thread, so the pages never lag the finger. The rules are
20
+ // plain worklets in `pagerSwipe`.
21
+
22
+ /** The release: springs on at the finger's speed, no bounce (an overshoot would flash the next page's edge). */
23
+ const PAGER_SPRING = { duration: 250, dampingRatio: 1, reduceMotion: ReduceMotion.System } as const;
24
+
25
+ /** A page set from outside (a tab press) slides there; at once with Reduce Motion. */
26
+ const PAGER_SLIDE = { duration: 220, reduceMotion: ReduceMotion.System } as const;
27
+
28
+ export function GesturePager({ index, count, onIndexChange, progress, renderPage }: PagerProps) {
29
+ const width = useSharedValue(0);
30
+ // Where the pages were when the finger caught them, and the finger's travel then.
31
+ const start = useSharedValue(0);
32
+ const startX = useSharedValue(0);
33
+ const [mounted, setMounted] = useState<ReadonlySet<number>>(() => new Set([index]));
34
+ const [shown, setShown] = useState(index);
35
+ // A page reached from outside is drawn from then on, with every page the
36
+ // slide there passes, so it never crosses a blank page.
37
+ if (shown !== index) {
38
+ setShown(index);
39
+ setMounted(new Set([...mounted, ...pagesBetween(shown, index)]));
40
+ }
41
+ // The page a swipe settled on, until the new `index` comes back: that one must not stop the spring.
42
+ const settling = useRef<number | null>(null);
43
+ // A page set from outside slides there. An effect: a shared value is never
44
+ // written in render.
45
+ useEffect(() => {
46
+ const fromSwipe = settling.current === index;
47
+ settling.current = null;
48
+ if (fromSwipe) return;
49
+ progress.set(withTiming(index, PAGER_SLIDE));
50
+ }, [index, progress]);
51
+
52
+ const webClicks = useWebClickGuard();
53
+ const dragStarted = () => {
54
+ webClicks.swallowNext();
55
+ // Both neighbours, so the finger finds a page there.
56
+ setMounted((current) => new Set([...current, index - 1, index + 1].filter((page) => page >= 0 && page < count)));
57
+ };
58
+ const settled = (page: number) => {
59
+ settling.current = page;
60
+ selectionHaptic();
61
+ onIndexChange(page);
62
+ };
63
+
64
+ // Built on each render (for `index` and `count`): the detector keeps the
65
+ // same handler, and a drag in flight, and only takes the new config.
66
+ const pan = Gesture.Pan()
67
+ .enabled(count > 1)
68
+ // Clearly sideways only: a vertical drag is the page's scroll, and a row swipe starts sooner.
69
+ .activeOffsetX([-PAGER_ACTIVE_OFFSET_X, PAGER_ACTIVE_OFFSET_X])
70
+ .failOffsetY([-PAGER_FAIL_OFFSET_Y, PAGER_FAIL_OFFSET_Y])
71
+ .onStart((event) => {
72
+ cancelAnimation(progress);
73
+ start.set(progress.get());
74
+ // Count from here, so the pages do not jump by the slop the drag needed to start.
75
+ startX.set(event.translationX);
76
+ runOnJS(dragStarted)();
77
+ })
78
+ // Track phase: one shared value write per frame, on the UI thread.
79
+ .onUpdate((event) => {
80
+ progress.set(pagerPosition(start.get(), event.translationX - startX.get(), width.get(), count));
81
+ })
82
+ .onEnd((event) => {
83
+ const pageWidth = width.get();
84
+ const target = pagerSettle(progress.get(), event.velocityX, pageWidth, Math.round(start.get()), count);
85
+ // The finger's speed in pages per second, the unit of `progress`.
86
+ const velocity = pageWidth > 0 ? -event.velocityX / pageWidth : 0;
87
+ progress.set(withSpring(target, { ...PAGER_SPRING, velocity }));
88
+ if (target !== index) runOnJS(settled)(target);
89
+ });
90
+
91
+ const trackStyle = useAnimatedStyle(() => ({
92
+ transform: [{ translateX: `${(-progress.get() * 100) / count}%` }],
93
+ }));
94
+ return (
95
+ <InsidePagerContext.Provider value>
96
+ <View style={styles.clip} onLayout={(event) => width.set(event.nativeEvent.layout.width)}>
97
+ <GestureDetector gesture={pan}>
98
+ <Animated.View style={[styles.fill, { width: `${count * 100}%` }, trackStyle]}>
99
+ <View ref={webClicks.ref} style={styles.track}>
100
+ {Array.from({ length: count }, (_, page) => (
101
+ <View
102
+ key={page}
103
+ // Off-screen pages are hidden from screen readers; the tabs above switch pages for them.
104
+ accessibilityElementsHidden={page !== index}
105
+ importantForAccessibility={page === index ? "auto" : "no-hide-descendants"}
106
+ style={{ width: `${100 / count}%` }}
107
+ >
108
+ {mounted.has(page) ? renderPage(page) : null}
109
+ </View>
110
+ ))}
111
+ </View>
112
+ </Animated.View>
113
+ </GestureDetector>
114
+ </View>
115
+ </InsidePagerContext.Provider>
116
+ );
117
+ }
118
+
119
+ const styles = StyleSheet.create({
120
+ clip: {
121
+ flex: 1,
122
+ overflow: "hidden",
123
+ },
124
+ fill: {
125
+ flex: 1,
126
+ },
127
+ track: {
128
+ flex: 1,
129
+ flexDirection: "row",
130
+ },
131
+ });
@@ -0,0 +1,305 @@
1
+ import { createContext, useContext, useState, type ComponentType, type ReactNode } from "react";
2
+ import {
3
+ PanResponder,
4
+ ScrollView,
5
+ View,
6
+ useWindowDimensions,
7
+ type LayoutChangeEvent,
8
+ type ScrollViewProps,
9
+ type StyleProp,
10
+ type ViewProps,
11
+ type ViewStyle,
12
+ } from "react-native";
13
+ import Animated, {
14
+ cancelAnimation,
15
+ interpolate,
16
+ runOnJS,
17
+ useAnimatedReaction,
18
+ useAnimatedStyle,
19
+ useSharedValue,
20
+ withSpring,
21
+ type SharedValue,
22
+ } from "react-native-reanimated";
23
+ import { impactHaptic } from "../haptics";
24
+ import { SHEET_SNAP_SPRING, SHEET_SPRING } from "../motion";
25
+ import { isSheetDragDown, SHEET_OVERPULL, sheetDragDismisses, sheetDragOffset, sheetSnapVelocity } from "../sheetDrag";
26
+
27
+ /**
28
+ * The live state of one open overlay's motion, shared by the sheet (which
29
+ * moves), the backdrop (which fades with it) and a `DialogBody` / `SheetBody`
30
+ * inside (which reports its scroll, so a drag only takes over at the top).
31
+ * One `offset` drives the slide in, the drag, the release and the slide out,
32
+ * so each can take over from the others mid-flight. Shared values, so the
33
+ * motion runs on the UI thread and a drag re-renders nothing per move.
34
+ * `dragging` is for the `PanResponder` drag only; the Gesture Handler drag
35
+ * (`SheetGestureParts`) holds the body still on the UI thread instead.
36
+ */
37
+ export type SheetDragState = {
38
+ /** How far down the sheet sits from rest. */
39
+ offset: SharedValue<number>;
40
+ /** The sheet's height, from its layout; 0 until it is laid out. */
41
+ height: SharedValue<number>;
42
+ /** The scroll offset of the body inside, 0 at the top. */
43
+ scrollTop: SharedValue<number>;
44
+ /** Set once a release flung the sheet out: the overlay then closes. */
45
+ dismissed: SharedValue<boolean>;
46
+ /** Set while the sheet slides out after the overlay closed: a touch no longer catches it. */
47
+ closing: SharedValue<boolean>;
48
+ /** The sheet slides in from below on its first layout (a bottom sheet, Reduce Motion off). */
49
+ slides: boolean;
50
+ /** While the sheet follows a `PanResponder` finger: the body inside stops scrolling, so the native scroll does not take the touch. */
51
+ dragging: boolean;
52
+ setDragging: (dragging: boolean) => void;
53
+ };
54
+
55
+ const SheetDragContext = createContext<SheetDragState | null>(null);
56
+
57
+ /**
58
+ * Makes the motion state for one open overlay and closes it once a drag
59
+ * flung the sheet out. Call it inside the `Modal`, so each opening starts
60
+ * fresh: below the screen when `slides`, else at rest.
61
+ */
62
+ export function useSheetDragState(close: () => void, slides: boolean): SheetDragState {
63
+ const window = useWindowDimensions();
64
+ const offset = useSharedValue(slides ? window.height : 0);
65
+ const height = useSharedValue(0);
66
+ const scrollTop = useSharedValue(0);
67
+ const dismissed = useSharedValue(false);
68
+ const closing = useSharedValue(false);
69
+ const [dragging, setDragging] = useState(false);
70
+ useAnimatedReaction(
71
+ () => dismissed.get(),
72
+ (done, previous) => {
73
+ if (done && !previous) runOnJS(close)();
74
+ },
75
+ [close],
76
+ );
77
+ return { offset, height, scrollTop, dismissed, closing, slides, dragging, setDragging };
78
+ }
79
+
80
+ export const SheetDragProvider = SheetDragContext.Provider;
81
+
82
+ export type SheetDragViewProps = {
83
+ enabled: boolean;
84
+ style?: StyleProp<ViewStyle>;
85
+ children: ReactNode;
86
+ };
87
+
88
+ /**
89
+ * The Gesture Handler build of the sheet parts, from
90
+ * `@vvedantb/native-ui/gesture-handler`. Without it (the peer is optional)
91
+ * the sheets drag with `PanResponder`. A context, so only that entry imports
92
+ * `react-native-gesture-handler` and an app without it bundles as before.
93
+ */
94
+ export type SheetGestureParts = {
95
+ /** Wraps each overlay's `Modal` content: on Android a `Modal` is its own root, and gestures need a root view there. */
96
+ ModalRoot: ComponentType<{ children: ReactNode }>;
97
+ DragView: ComponentType<SheetDragViewProps & { drag: SheetDragState }>;
98
+ ScrollView: ComponentType<ScrollViewProps & { drag: SheetDragState }>;
99
+ };
100
+
101
+ export const SheetGestureContext = createContext<SheetGestureParts | null>(null);
102
+
103
+ /** The backdrop's opacity, from the sheet's offset: 1 at rest, 0 with the sheet out of sight. */
104
+ export function useSheetBackdropStyle(drag: SheetDragState) {
105
+ return useAnimatedStyle(() => {
106
+ const height = drag.height.get();
107
+ // Not laid out yet: hidden while a sliding sheet waits below the screen.
108
+ if (height === 0) return { opacity: drag.offset.get() > 0 ? 0 : 1 };
109
+ return { opacity: interpolate(drag.offset.get(), [0, height], [1, 0], "clamp") };
110
+ });
111
+ }
112
+
113
+ /** Springs a sheet that is sliding out back to rest: the overlay opened again. */
114
+ export function slideSheetIn(drag: SheetDragState) {
115
+ drag.closing.set(false);
116
+ if (drag.height.get() > 0) drag.offset.set(withSpring(0, SHEET_SPRING));
117
+ }
118
+
119
+ /** Slides the sheet down out of sight, then calls `done` (which hides the `Modal`). */
120
+ export function slideSheetOut(drag: SheetDragState, done: () => void) {
121
+ drag.closing.set(true);
122
+ drag.offset.set(
123
+ withSpring(drag.height.get(), SHEET_SPRING, (finished) => {
124
+ "worklet";
125
+ if (finished) runOnJS(done)();
126
+ }),
127
+ );
128
+ }
129
+
130
+ /**
131
+ * A scroll view that hands a downward drag to the sheet around it: the drag
132
+ * moves the sheet only once the content is scrolled to its top, and the
133
+ * content holds still while the sheet follows the finger. With
134
+ * `react-native-gesture-handler` (`SheetGestureParts`) both run on the UI
135
+ * thread; with `PanResponder`, `onScroll` reports the offset and
136
+ * `scrollEnabled` is off during a drag, so the native scroll does not take
137
+ * the touch over (Android and iOS would cancel the drag). Outside a sheet, a
138
+ * plain `ScrollView`.
139
+ */
140
+ export function SheetScrollView(props: ScrollViewProps) {
141
+ const drag = useContext(SheetDragContext);
142
+ const gestures = useContext(SheetGestureContext);
143
+ if (!drag) return <ScrollView {...props} />;
144
+ if (gestures) return <gestures.ScrollView {...props} drag={drag} />;
145
+ const { onScroll, scrollEnabled = true } = props;
146
+ return (
147
+ <ScrollView
148
+ {...props}
149
+ scrollEnabled={scrollEnabled && !drag.dragging}
150
+ onScroll={(event) => {
151
+ drag.scrollTop.set(event.nativeEvent.contentOffset.y);
152
+ onScroll?.(event);
153
+ }}
154
+ />
155
+ );
156
+ }
157
+
158
+ /**
159
+ * The part of a bottom sheet that follows the finger. A drag down from
160
+ * anywhere on it (rows included; a body only when scrolled to its top) moves
161
+ * the sheet 1:1 and fades the backdrop. A touch on a sheet still sliding in,
162
+ * springing home or flung out catches it where it is. On release the sheet
163
+ * springs on at the finger's speed: a quick flick or a drag past half the
164
+ * sheet carries it out and closes the overlay; a slow, short drag springs it
165
+ * home, with a light haptic. Pulled up past rest, it gives a little, with
166
+ * resistance. On its first layout it slides in from below when `slides`.
167
+ * With Reduce Motion on it still follows the finger, and the release settles
168
+ * at once. `enabled={false}` (a sheet that may not be dismissed) leaves it
169
+ * still under the finger. The drag runs on Gesture Handler when the app
170
+ * provides it (`SheetGestureParts`), else on `PanResponder`.
171
+ */
172
+ export function SheetDragView({ enabled, style, children }: SheetDragViewProps) {
173
+ const drag = useContext(SheetDragContext);
174
+ const gestures = useContext(SheetGestureContext);
175
+ if (!drag) return <View style={style}>{children}</View>;
176
+ if (gestures) {
177
+ return (
178
+ <gestures.DragView drag={drag} enabled={enabled} style={style}>
179
+ {children}
180
+ </gestures.DragView>
181
+ );
182
+ }
183
+ return (
184
+ <PanResponderDragView drag={drag} enabled={enabled} style={style}>
185
+ {children}
186
+ </PanResponderDragView>
187
+ );
188
+ }
189
+
190
+ /**
191
+ * The view that moves: `translateY` from the sheet's offset, and the slide in
192
+ * from below on its first layout. Both drags wrap it.
193
+ */
194
+ export function SheetMotionView({
195
+ drag,
196
+ style,
197
+ ...props
198
+ }: ViewProps & { drag: SheetDragState }) {
199
+ const dragStyle = useAnimatedStyle(() => ({ transform: [{ translateY: drag.offset.get() }] }));
200
+ return (
201
+ <Animated.View
202
+ {...props}
203
+ onLayout={(event: LayoutChangeEvent) => {
204
+ const height = event.nativeEvent.layout.height;
205
+ const first = drag.height.get() === 0;
206
+ drag.height.set(height);
207
+ // Slide in: from just below the screen edge, whatever the sheet's height.
208
+ if (first && drag.slides) {
209
+ drag.offset.set(height);
210
+ drag.offset.set(withSpring(0, SHEET_SPRING));
211
+ }
212
+ }}
213
+ style={[style, dragStyle]}
214
+ />
215
+ );
216
+ }
217
+
218
+ /** The drag on the JavaScript thread, for an app without `react-native-gesture-handler`. */
219
+ function PanResponderDragView({
220
+ drag,
221
+ enabled,
222
+ style,
223
+ children,
224
+ }: SheetDragViewProps & { drag: SheetDragState }) {
225
+ // Created once: its handlers only touch shared values and `start`, so they never go stale.
226
+ const [responder] = useState(() => {
227
+ // Where the sheet was when the finger caught it.
228
+ let start = 0;
229
+ const release = (position: number, vy: number, mayDismiss: boolean) => {
230
+ drag.setDragging(false);
231
+ if (settleSheet(drag, position, vy, mayDismiss)) impactHaptic("light");
232
+ };
233
+ return PanResponder.create({
234
+ // A touch on a sheet in motion catches it: it stops under the finger.
235
+ onStartShouldSetPanResponderCapture: () => sheetCatches(drag),
236
+ // Capture, so a downward drag wins over the rows and over a body
237
+ // scrolled to its top. Only then: a body scrolled down keeps its
238
+ // scroll both ways, and an upward drag always scrolls it.
239
+ onMoveShouldSetPanResponderCapture: (_event, gesture) => sheetTakesDrag(drag, gesture.dx, gesture.dy),
240
+ onPanResponderTerminationRequest: () => false,
241
+ onPanResponderGrant: () => {
242
+ cancelAnimation(drag.offset);
243
+ start = drag.offset.get();
244
+ drag.setDragging(true);
245
+ },
246
+ // Track phase: one shared value write per move, no React render.
247
+ onPanResponderMove: (_event, gesture) => {
248
+ drag.offset.set(sheetDragOffset(start + gesture.dy));
249
+ },
250
+ onPanResponderRelease: (_event, gesture) => release(start + gesture.dy, gesture.vy, true),
251
+ onPanResponderTerminate: (_event, gesture) => release(start + gesture.dy, gesture.vy, false),
252
+ });
253
+ });
254
+ return (
255
+ <SheetMotionView drag={drag} {...(enabled ? responder.panHandlers : null)} style={style}>
256
+ {children}
257
+ </SheetMotionView>
258
+ );
259
+ }
260
+
261
+ /** The parts of the motion state a worklet may capture (no React setter). */
262
+ export type SheetMotion = Pick<SheetDragState, "offset" | "height" | "scrollTop" | "dismissed" | "closing">;
263
+
264
+ /** A touch on a sheet in motion (sliding in, springing home, flung out) catches it; one sliding out after a close does not. */
265
+ export function sheetCatches(drag: SheetMotion): boolean {
266
+ "worklet";
267
+ return !drag.closing.get() && drag.offset.get() > 1;
268
+ }
269
+
270
+ /** A downward drag takes the sheet once the body inside is at its top. */
271
+ export function sheetTakesDrag(drag: SheetMotion, dx: number, dy: number): boolean {
272
+ "worklet";
273
+ return !drag.closing.get() && isSheetDragDown(dx, dy) && drag.scrollTop.get() <= 0;
274
+ }
275
+
276
+ /**
277
+ * Release phase, at `position` below rest with velocity `vy` (pt/ms): the
278
+ * sheet springs on at the finger's speed, out (then the overlay closes) or
279
+ * home. `mayDismiss` is false when the system took the touch away. Returns
280
+ * true when it springs home from below rest: the caller fires the light
281
+ * haptic, in the same frame.
282
+ */
283
+ export function settleSheet(drag: SheetMotion, position: number, vy: number, mayDismiss: boolean): boolean {
284
+ "worklet";
285
+ const height = drag.height.get();
286
+ const velocity = vy * 1000;
287
+ if (mayDismiss && sheetDragDismisses({ dy: position, vy, height })) {
288
+ drag.offset.set(
289
+ withSpring(height, { ...SHEET_SPRING, velocity }, (finished) => {
290
+ "worklet";
291
+ if (finished) drag.dismissed.set(true);
292
+ }),
293
+ );
294
+ return false;
295
+ }
296
+ drag.offset.set(
297
+ withSpring(0, {
298
+ ...SHEET_SNAP_SPRING,
299
+ velocity: sheetSnapVelocity(position, velocity),
300
+ // Bounce, but never so far up that the bottom edge lifts off the screen.
301
+ clamp: { min: -SHEET_OVERPULL },
302
+ }),
303
+ );
304
+ return position > 1;
305
+ }
@@ -0,0 +1,181 @@
1
+ import { createContext, useContext, useState, type ReactNode } from "react";
2
+ import { StyleSheet, type ScrollViewProps } from "react-native";
3
+ import {
4
+ Gesture,
5
+ GestureDetector,
6
+ GestureHandlerRootView,
7
+ type NativeGesture,
8
+ } from "react-native-gesture-handler";
9
+ import Animated, {
10
+ cancelAnimation,
11
+ runOnJS,
12
+ scrollTo,
13
+ useAnimatedReaction,
14
+ useAnimatedRef,
15
+ useScrollOffset,
16
+ useSharedValue,
17
+ type SharedValue,
18
+ } from "react-native-reanimated";
19
+ import { impactHaptic } from "../haptics";
20
+ import { SwipeRowGestureContext } from "../SwipeableRow";
21
+ import { sheetDragAnchor, sheetDragOffset } from "../sheetDrag";
22
+ import {
23
+ settleSheet,
24
+ sheetCatches,
25
+ sheetTakesDrag,
26
+ SheetMotionView,
27
+ type SheetDragState,
28
+ type SheetDragViewProps,
29
+ type SheetGestureParts,
30
+ type SheetMotion,
31
+ SheetGestureContext,
32
+ } from "./sheet-drag";
33
+ import { PagerGestureContext } from "../Pager";
34
+ import { ToastDragContext } from "../toastDrag";
35
+ import { GesturePager } from "./pager-gesture";
36
+ import { GestureSwipeableRow } from "./swipe-row-gesture";
37
+ import { GestureToastDrag } from "./toast-drag-gesture";
38
+
39
+ // The sheet drag on Gesture Handler: the touch, the track phase and the
40
+ // release all run as worklets on the UI thread, so a busy JavaScript thread
41
+ // (a reply streaming in behind the sheet) cannot make the sheet lag the
42
+ // finger. The rules are the same as the `PanResponder` drag's (`sheet-drag`).
43
+
44
+ /** What a body inside the sheet needs from the drag around it. */
45
+ type SheetScrollLink = {
46
+ /** The body's native scroll, run together with the sheet's pan. */
47
+ native: NativeGesture;
48
+ /** While the sheet follows the finger: the scroll offset the body holds; else `null`. */
49
+ heldTop: SharedValue<number | null>;
50
+ };
51
+
52
+ const SheetScrollLinkContext = createContext<SheetScrollLink | null>(null);
53
+
54
+ function motionOf(drag: SheetDragState): SheetMotion {
55
+ return {
56
+ offset: drag.offset,
57
+ height: drag.height,
58
+ scrollTop: drag.scrollTop,
59
+ dismissed: drag.dismissed,
60
+ closing: drag.closing,
61
+ };
62
+ }
63
+
64
+ function GestureSheetDragView({ drag, enabled, style, children }: SheetDragViewProps & { drag: SheetDragState }) {
65
+ // Shared values only: the worklets must not capture the React setter in `drag`.
66
+ const motion = motionOf(drag);
67
+ const [native] = useState(() => Gesture.Native());
68
+ const heldTop = useSharedValue<number | null>(null);
69
+ // Where the finger touched down, and the body's scroll then.
70
+ const downX = useSharedValue(0);
71
+ const downY = useSharedValue(0);
72
+ const downTop = useSharedValue(0);
73
+ // Where the sheet was when the finger caught it, and the finger position
74
+ // its travel counts from (`sheetDragAnchor`). Window positions, as the
75
+ // sheet moves under the finger.
76
+ const start = useSharedValue(0);
77
+ const anchorY = useSharedValue(0);
78
+ // Built on each render (for `enabled`): the detector keeps the same handler,
79
+ // and a drag in flight, and only takes the new config.
80
+ const pan = Gesture.Pan()
81
+ .enabled(enabled)
82
+ // The same rules as the `PanResponder` capture: a touch on a sheet in
83
+ // motion catches it; a drag down takes it once the body is at its top.
84
+ .manualActivation(true)
85
+ .simultaneousWithExternalGesture(native)
86
+ .onTouchesDown((event, manager) => {
87
+ const touch = event.allTouches[0];
88
+ if (!touch) return;
89
+ downX.set(touch.absoluteX);
90
+ downY.set(touch.absoluteY);
91
+ downTop.set(motion.scrollTop.get());
92
+ if (sheetCatches(motion)) manager.activate();
93
+ })
94
+ .onTouchesMove((event, manager) => {
95
+ const touch = event.allTouches[0];
96
+ if (touch && sheetTakesDrag(motion, touch.absoluteX - downX.get(), touch.absoluteY - downY.get())) {
97
+ manager.activate();
98
+ }
99
+ })
100
+ .onStart(() => {
101
+ cancelAnimation(motion.offset);
102
+ start.set(motion.offset.get());
103
+ anchorY.set(sheetDragAnchor(downY.get(), downTop.get(), motion.scrollTop.get()));
104
+ heldTop.set(motion.scrollTop.get());
105
+ })
106
+ // Track phase: one shared value write per frame, on the UI thread.
107
+ .onUpdate((event) => {
108
+ motion.offset.set(sheetDragOffset(start.get() + event.absoluteY - anchorY.get()));
109
+ })
110
+ .onEnd((event, success) => {
111
+ const position = start.get() + event.absoluteY - anchorY.get();
112
+ // `velocityY` is pt/s; the rules take pt/ms, as `PanResponder`'s `vy`.
113
+ if (settleSheet(motion, position, event.velocityY / 1000, success)) runOnJS(impactHaptic)("light");
114
+ })
115
+ .onFinalize(() => {
116
+ heldTop.set(null);
117
+ });
118
+ return (
119
+ <SheetScrollLinkContext.Provider value={{ native, heldTop }}>
120
+ <GestureDetector gesture={pan}>
121
+ <SheetMotionView drag={drag} style={style}>
122
+ {children}
123
+ </SheetMotionView>
124
+ </GestureDetector>
125
+ </SheetScrollLinkContext.Provider>
126
+ );
127
+ }
128
+
129
+ function GestureSheetScrollView({ drag, ...props }: ScrollViewProps & { drag: SheetDragState }) {
130
+ const link = useContext(SheetScrollLinkContext);
131
+ const heldTop = link?.heldTop;
132
+ const ref = useAnimatedRef<Animated.ScrollView>();
133
+ // The offset on the UI thread, where the pan reads it: no `onScroll` to JavaScript.
134
+ useScrollOffset(ref, drag.scrollTop);
135
+ // While the sheet follows the finger, the content stays where it was, so
136
+ // the scroll (which runs together with the pan) does not move it too.
137
+ useAnimatedReaction(
138
+ () => drag.scrollTop.get(),
139
+ (top) => {
140
+ const held = heldTop?.get();
141
+ if (typeof held === "number" && top !== held) scrollTo(ref, 0, held, false);
142
+ },
143
+ );
144
+ const scroll = <Animated.ScrollView ref={ref} {...props} />;
145
+ return link ? <GestureDetector gesture={link.native}>{scroll}</GestureDetector> : scroll;
146
+ }
147
+
148
+ function GestureModalRoot({ children }: { children: ReactNode }) {
149
+ return <GestureHandlerRootView style={styles.fill}>{children}</GestureHandlerRootView>;
150
+ }
151
+
152
+ const PARTS: SheetGestureParts = {
153
+ ModalRoot: GestureModalRoot,
154
+ DragView: GestureSheetDragView,
155
+ ScrollView: GestureSheetScrollView,
156
+ };
157
+
158
+ /**
159
+ * Runs the kit's sheet drags (`SheetContent`, the compact `DialogContent`)
160
+ * on Gesture Handler instead of `PanResponder`, and turns on the swipes of
161
+ * `SwipeableRow`, `Pager` and the `Toaster` toasts (up to dismiss). Put it
162
+ * inside the app's `GestureHandlerRootView`; each overlay adds its own root
163
+ * view inside its `Modal`.
164
+ */
165
+ export function GestureHandlerProvider({ children }: { children: ReactNode }) {
166
+ return (
167
+ <SheetGestureContext.Provider value={PARTS}>
168
+ <SwipeRowGestureContext.Provider value={GestureSwipeableRow}>
169
+ <PagerGestureContext.Provider value={GesturePager}>
170
+ <ToastDragContext.Provider value={GestureToastDrag}>{children}</ToastDragContext.Provider>
171
+ </PagerGestureContext.Provider>
172
+ </SwipeRowGestureContext.Provider>
173
+ </SheetGestureContext.Provider>
174
+ );
175
+ }
176
+
177
+ const styles = StyleSheet.create({
178
+ fill: {
179
+ flex: 1,
180
+ },
181
+ });