@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.
- package/README.md +40 -19
- package/package.json +36 -3
- package/src/BottomSheetContainer.tsx +2 -2
- package/src/BulkActionBar.tsx +240 -105
- package/src/Button.tsx +11 -7
- package/src/ConfirmDialog.tsx +25 -35
- package/src/Dialog.tsx +47 -35
- package/src/DividedList.tsx +11 -7
- package/src/DropdownMenu.tsx +22 -8
- package/src/FloatingSurface.tsx +5 -2
- package/src/FloatingTabBar.tsx +13 -22
- package/src/HeaderIconButton.tsx +4 -3
- package/src/Input.tsx +2 -2
- package/src/LoadingState.tsx +132 -0
- package/src/PageTitle.tsx +9 -2
- package/src/Pager.tsx +62 -0
- package/src/PasswordInput.tsx +1 -1
- package/src/PillTabs.tsx +144 -34
- package/src/Progress.tsx +9 -7
- package/src/Select.tsx +4 -4
- package/src/Sheet.tsx +29 -22
- package/src/SheetRow.tsx +27 -9
- package/src/Skeleton.tsx +91 -9
- package/src/SurfaceRow.tsx +111 -0
- package/src/SwipeableRow.tsx +43 -0
- package/src/Switch.tsx +6 -4
- package/src/Tabs.tsx +68 -13
- package/src/Toast.tsx +107 -65
- package/src/ai/activity-step-detail.tsx +1 -3
- package/src/ai/activity-step-label.ts +981 -0
- package/src/ai/activity-step-types.ts +107 -0
- package/src/ai/activity-tasks-utils.ts +199 -0
- package/src/ai/activity-timeline.ts +45 -0
- package/src/ai/attachments.tsx +3 -1
- package/src/ai/chat-text.ts +13 -7
- package/src/ai/chat-thread.tsx +34 -27
- package/src/ai/choice-question.tsx +154 -35
- package/src/ai/code-block.tsx +189 -49
- package/src/ai/collapsible.tsx +19 -13
- package/src/ai/conversation.tsx +390 -57
- package/src/ai/icon-button.tsx +41 -6
- package/src/ai/index.ts +38 -1
- package/src/ai/message-action-sheet.tsx +4 -1
- package/src/ai/message.tsx +60 -31
- package/src/ai/prompt-input-menu.tsx +197 -0
- package/src/ai/prompt-input.tsx +301 -75
- package/src/ai/shimmer.tsx +4 -6
- package/src/ai/trace.tsx +54 -17
- package/src/ai/unified-diff.tsx +182 -0
- package/src/auth/GoogleSignInButton.tsx +6 -3
- package/src/auth/SignInScreen.tsx +11 -2
- package/src/brand/ProviderIcon.tsx +136 -0
- package/src/brand/index.ts +2 -0
- package/src/configureTheme.ts +39 -11
- package/src/controlStyles.ts +46 -30
- package/src/entries/gesture-handler.ts +4 -0
- package/src/entries/keyboard-controller.ts +4 -0
- package/src/entries/markdown.ts +1 -1
- package/src/entries/primitives.ts +24 -3
- package/src/entries/theme.ts +2 -1
- package/src/entries/video.ts +4 -0
- package/src/entries/webview.ts +6 -0
- package/src/floating.ts +25 -4
- package/src/haptics.ts +30 -4
- package/src/highlight.ts +302 -1
- package/src/index.ts +27 -5
- package/src/loadingGrid.ts +75 -0
- package/src/markdown/Markdown.tsx +234 -32
- package/src/markdown/MarkdownTable.tsx +188 -0
- package/src/markdown/tableText.ts +58 -0
- package/src/marketing/UpgradeToProCard.tsx +4 -3
- package/src/media/ImageGallery.tsx +224 -0
- package/src/media/MediaCard.tsx +100 -0
- package/src/media/imageGrid.ts +51 -0
- package/src/motion.ts +33 -0
- package/src/pagerSwipe.ts +102 -0
- package/src/palette.ts +8 -1
- package/src/primitives/button.tsx +4 -1
- package/src/primitives/keyboard-controller.tsx +106 -0
- package/src/primitives/keyboard-parts.tsx +104 -0
- package/src/primitives/overlay.tsx +133 -45
- package/src/primitives/pager-gesture.tsx +131 -0
- package/src/primitives/sheet-drag.tsx +305 -0
- package/src/primitives/sheet-gesture.tsx +181 -0
- package/src/primitives/swipe-row-gesture.tsx +290 -0
- package/src/primitives/toast-drag-gesture.tsx +92 -0
- package/src/primitives/web-click-guard.ts +50 -0
- package/src/relativeTime.ts +94 -0
- package/src/router/RouterStackPageHeader.tsx +2 -2
- package/src/settings/SettingsEmptyState.tsx +20 -10
- package/src/settings/SettingsField.tsx +11 -5
- package/src/settings/SettingsSection.tsx +8 -5
- package/src/settings/SettingsToggleRow.tsx +55 -22
- package/src/settings/relaxedSizeStyle.ts +11 -0
- package/src/sheetDrag.ts +67 -0
- package/src/swipeRow.ts +169 -0
- package/src/tabBarRoutes.ts +18 -0
- package/src/tabIndicator.ts +71 -0
- package/src/tailwind-preset.cjs +13 -6
- package/src/tailwind-preset.d.cts +5 -0
- package/src/theme.tsx +6 -2
- package/src/toastDrag.ts +58 -0
- package/src/typography.ts +20 -3
- package/src/useScheme.ts +10 -1
- package/src/video/InlineVideo.tsx +91 -0
- package/src/webview/HtmlView.tsx +91 -0
- package/src/webview/HtmlView.web.tsx +21 -0
- 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
|
+
});
|