@isoline-ui/react-native 0.0.0-stage → 1.0.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 (167) hide show
  1. package/LICENSE +22 -0
  2. package/NOTICE.md +12 -0
  3. package/README.md +104 -3
  4. package/dist/amount.d.ts +3 -0
  5. package/dist/calendar.d.ts +7 -0
  6. package/dist/components/action-chip.d.ts +9 -0
  7. package/dist/components/amount-field.d.ts +12 -0
  8. package/dist/components/avatar.d.ts +17 -0
  9. package/dist/components/back-button.d.ts +12 -0
  10. package/dist/components/back-icon.d.ts +3 -0
  11. package/dist/components/badge.d.ts +13 -0
  12. package/dist/components/banner.d.ts +14 -0
  13. package/dist/components/bottom-sheet-native.d.ts +11 -0
  14. package/dist/components/bottom-sheet-web.d.ts +18 -0
  15. package/dist/components/bottom-sheet.d.ts +3 -0
  16. package/dist/components/button-group.d.ts +26 -0
  17. package/dist/components/button.d.ts +48 -0
  18. package/dist/components/checkbox.d.ts +12 -0
  19. package/dist/components/color-picker.d.ts +21 -0
  20. package/dist/components/context-menu.d.ts +26 -0
  21. package/dist/components/date-picker.d.ts +64 -0
  22. package/dist/components/dialog-context.d.ts +2 -0
  23. package/dist/components/dialog.d.ts +20 -0
  24. package/dist/components/empty-state.d.ts +15 -0
  25. package/dist/components/expand-indicator.d.ts +27 -0
  26. package/dist/components/expressive-shape.d.ts +18 -0
  27. package/dist/components/fab-menu-legacy.d.ts +31 -0
  28. package/dist/components/fab-menu.d.ts +21 -0
  29. package/dist/components/filter-chip.d.ts +69 -0
  30. package/dist/components/floating-action-button.d.ts +15 -0
  31. package/dist/components/icon-button.d.ts +40 -0
  32. package/dist/components/icons.d.ts +18 -0
  33. package/dist/components/input.d.ts +9 -0
  34. package/dist/components/keyboard-inset-view.d.ts +15 -0
  35. package/dist/components/list-controls.d.ts +38 -0
  36. package/dist/components/loading-indicator.d.ts +9 -0
  37. package/dist/components/menu-sheet.d.ts +36 -0
  38. package/dist/components/panel-sheet.d.ts +8 -0
  39. package/dist/components/password-visibility.d.ts +5 -0
  40. package/dist/components/popover-layout.d.ts +25 -0
  41. package/dist/components/popover.d.ts +20 -0
  42. package/dist/components/pressable.d.ts +12 -0
  43. package/dist/components/radio-navigation.d.ts +11 -0
  44. package/dist/components/refresh-control.d.ts +16 -0
  45. package/dist/components/row-gap.d.ts +9 -0
  46. package/dist/components/search-field.d.ts +22 -0
  47. package/dist/components/select-field.d.ts +16 -0
  48. package/dist/components/settings-list.d.ts +98 -0
  49. package/dist/components/settings-row.d.ts +13 -0
  50. package/dist/components/sheet-frame.d.ts +27 -0
  51. package/dist/components/sheet-lifecycle.d.ts +36 -0
  52. package/dist/components/sheet-transition.d.ts +36 -0
  53. package/dist/components/slider.d.ts +21 -0
  54. package/dist/components/snackbar.d.ts +10 -0
  55. package/dist/components/split-button.d.ts +38 -0
  56. package/dist/components/squish.d.ts +20 -0
  57. package/dist/components/swipeable-row.d.ts +20 -0
  58. package/dist/components/switch.d.ts +8 -0
  59. package/dist/components/tab-bar.d.ts +20 -0
  60. package/dist/components/text-field.d.ts +31 -0
  61. package/dist/components/text.d.ts +9 -0
  62. package/dist/components/time-picker.d.ts +40 -0
  63. package/dist/components/toolbar.d.ts +77 -0
  64. package/dist/components/top-bar.d.ts +27 -0
  65. package/dist/components/undo-bar.d.ts +23 -0
  66. package/dist/components/wavy-progress.d.ts +21 -0
  67. package/dist/components/zoomable-image.d.ts +18 -0
  68. package/dist/expressive-shapes.d.ts +8 -0
  69. package/dist/index.d.ts +62 -0
  70. package/dist/keyboard.d.ts +2 -0
  71. package/dist/material.d.ts +11 -0
  72. package/dist/media.d.ts +11 -0
  73. package/dist/motion.d.ts +96 -0
  74. package/dist/platform.d.ts +3 -0
  75. package/dist/preset-tokens.d.ts +264 -0
  76. package/dist/provider.d.ts +59 -0
  77. package/dist/reduced-motion.d.ts +45 -0
  78. package/dist/selection.d.ts +2 -0
  79. package/dist/shape.d.ts +99 -0
  80. package/dist/sheet-physics.d.ts +37 -0
  81. package/dist/theme.d.ts +103 -0
  82. package/dist/tokens.d.ts +2 -0
  83. package/dist/use-keyboard-height.d.ts +10 -0
  84. package/dist/utils.d.ts +3 -0
  85. package/nativewind.cjs +68 -0
  86. package/package.json +63 -6
  87. package/src/amount.ts +53 -0
  88. package/src/calendar.ts +42 -0
  89. package/src/components/action-chip.tsx +64 -0
  90. package/src/components/amount-field.tsx +49 -0
  91. package/src/components/avatar.tsx +91 -0
  92. package/src/components/back-button.tsx +39 -0
  93. package/src/components/back-icon.tsx +25 -0
  94. package/src/components/badge.tsx +41 -0
  95. package/src/components/banner.tsx +63 -0
  96. package/src/components/bottom-sheet-native.tsx +52 -0
  97. package/src/components/bottom-sheet-web.tsx +193 -0
  98. package/src/components/bottom-sheet.tsx +5 -0
  99. package/src/components/button-group.tsx +326 -0
  100. package/src/components/button.tsx +337 -0
  101. package/src/components/checkbox.tsx +71 -0
  102. package/src/components/color-picker.tsx +115 -0
  103. package/src/components/context-menu.tsx +149 -0
  104. package/src/components/date-picker.tsx +466 -0
  105. package/src/components/dialog-context.ts +3 -0
  106. package/src/components/dialog.tsx +210 -0
  107. package/src/components/empty-state.tsx +49 -0
  108. package/src/components/expand-indicator.tsx +60 -0
  109. package/src/components/expressive-shape.tsx +37 -0
  110. package/src/components/fab-menu-legacy.tsx +181 -0
  111. package/src/components/fab-menu.tsx +90 -0
  112. package/src/components/filter-chip.tsx +136 -0
  113. package/src/components/floating-action-button.tsx +79 -0
  114. package/src/components/icon-button.tsx +127 -0
  115. package/src/components/icons.tsx +43 -0
  116. package/src/components/input.tsx +80 -0
  117. package/src/components/keyboard-inset-view.tsx +28 -0
  118. package/src/components/list-controls.tsx +134 -0
  119. package/src/components/loading-indicator.tsx +183 -0
  120. package/src/components/menu-sheet.tsx +133 -0
  121. package/src/components/panel-sheet.tsx +90 -0
  122. package/src/components/password-visibility.tsx +39 -0
  123. package/src/components/popover-layout.ts +47 -0
  124. package/src/components/popover.tsx +139 -0
  125. package/src/components/pressable.tsx +108 -0
  126. package/src/components/radio-navigation.ts +52 -0
  127. package/src/components/refresh-control.tsx +39 -0
  128. package/src/components/row-gap.tsx +13 -0
  129. package/src/components/search-field.tsx +92 -0
  130. package/src/components/select-field.tsx +105 -0
  131. package/src/components/settings-list.tsx +302 -0
  132. package/src/components/settings-row.tsx +61 -0
  133. package/src/components/sheet-frame.tsx +250 -0
  134. package/src/components/sheet-lifecycle.ts +233 -0
  135. package/src/components/sheet-transition.ts +105 -0
  136. package/src/components/slider.tsx +185 -0
  137. package/src/components/snackbar.tsx +97 -0
  138. package/src/components/split-button.tsx +212 -0
  139. package/src/components/squish.ts +41 -0
  140. package/src/components/swipeable-row.tsx +109 -0
  141. package/src/components/switch.tsx +77 -0
  142. package/src/components/tab-bar.tsx +195 -0
  143. package/src/components/text-field.tsx +264 -0
  144. package/src/components/text.tsx +58 -0
  145. package/src/components/time-picker.tsx +277 -0
  146. package/src/components/toolbar.tsx +406 -0
  147. package/src/components/top-bar.tsx +91 -0
  148. package/src/components/undo-bar.tsx +137 -0
  149. package/src/components/wavy-progress.tsx +150 -0
  150. package/src/components/zoomable-image.tsx +240 -0
  151. package/src/expressive-shapes.ts +25 -0
  152. package/src/index.ts +62 -0
  153. package/src/keyboard.ts +5 -0
  154. package/src/material.ts +16 -0
  155. package/src/media.ts +51 -0
  156. package/src/motion.ts +96 -0
  157. package/src/platform.ts +13 -0
  158. package/src/preset-tokens.ts +265 -0
  159. package/src/provider.tsx +201 -0
  160. package/src/reduced-motion.ts +50 -0
  161. package/src/selection.ts +11 -0
  162. package/src/shape.ts +217 -0
  163. package/src/sheet-physics.ts +73 -0
  164. package/src/theme.ts +180 -0
  165. package/src/tokens.ts +2 -0
  166. package/src/use-keyboard-height.ts +64 -0
  167. package/src/utils.ts +7 -0
@@ -0,0 +1,250 @@
1
+ import * as React from 'react';
2
+ import {
3
+ ScrollView,
4
+ View,
5
+ type LayoutChangeEvent,
6
+ type NativeScrollEvent,
7
+ type NativeSyntheticEvent,
8
+ type ScrollViewProps,
9
+ type StyleProp,
10
+ type ViewStyle,
11
+ } from 'react-native';
12
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
13
+ import Animated, { type AnimatedStyle } from 'react-native-reanimated';
14
+
15
+ import { Text } from './text.tsx';
16
+ import { Pressable } from './pressable.tsx';
17
+ import type { Sheet, SheetScrollContextValue } from './sheet-transition.ts';
18
+ import {
19
+ Surface,
20
+ type MaterialOptions,
21
+ useLabels,
22
+ useIsolineTheme,
23
+ useIsolinePlatform,
24
+ } from '../provider.tsx';
25
+ import { elevation, useThemeColors } from '../provider.tsx';
26
+
27
+ /** M3's scrim: black at 32%, enough to set the sheet apart, not to hide the photo under it. */
28
+ const SCRIM = 'rgba(0, 0, 0, 0.32)';
29
+ /** M3's widest bottom sheet; on a tablet or in landscape it stands centred. */
30
+ const MAX_WIDTH = 640;
31
+
32
+ const SheetScrollContext = React.createContext<SheetScrollContextValue | null>(null);
33
+
34
+ /**
35
+ * What a sheet shows while it leaves: the content it had when it was last
36
+ * visible. A sheet's content is often built from the very thing closing it
37
+ * clears (the pin that was tapped, the spec being edited), and without this
38
+ * the panel would empty out and collapse on its way down.
39
+ */
40
+ function useKeptChildren(visible: boolean, children: React.ReactNode) {
41
+ const [kept, setKept] = React.useState(children);
42
+ if (visible && kept !== children) setKept(children);
43
+ return visible ? children : kept;
44
+ }
45
+
46
+ /**
47
+ * The scrim, the panel and its handle — everything `BottomSheet` and
48
+ * `PanelSheet` share, so the two look and move as one kind of sheet. Each
49
+ * places it differently: one in a `Modal`, one inside the screen, lifted by
50
+ * the keyboard (`lift`).
51
+ */
52
+ export function SheetFrame({
53
+ sheet,
54
+ visible,
55
+ onClose,
56
+ lift,
57
+ panelStyle,
58
+ children,
59
+ dragFrom,
60
+ material = 'solid',
61
+ glassStyle,
62
+ tintColor,
63
+ fallbackColor,
64
+ interactive = false,
65
+ }: {
66
+ dragFrom?: 'handle' | 'panel';
67
+ sheet: Sheet;
68
+ visible: boolean;
69
+ onClose: () => void;
70
+ lift?: StyleProp<AnimatedStyle<ViewStyle>>;
71
+ panelStyle?: StyleProp<AnimatedStyle<ViewStyle>>;
72
+ children: React.ReactNode;
73
+ } & MaterialOptions) {
74
+ const labels = useLabels();
75
+ const theme = useIsolineTheme();
76
+ const ios = useIsolinePlatform() === 'ios';
77
+ const glass = ios && material !== 'solid';
78
+ const colors = useThemeColors();
79
+ const shown = useKeptChildren(visible, children);
80
+
81
+ const wholePanel = (dragFrom ?? theme.sheetGesture ?? 'handle') === 'panel';
82
+ return (
83
+ // Once closed it takes no more touches, though it is still sliding away.
84
+ <View className="flex-1" pointerEvents={visible ? 'auto' : 'none'}>
85
+ <Animated.View
86
+ style={[sheet.scrimStyle, { backgroundColor: SCRIM }]}
87
+ className="absolute inset-0"
88
+ >
89
+ <Pressable className="flex-1" onPress={onClose} accessibilityLabel={labels.close} />
90
+ </Animated.View>
91
+
92
+ {/* The scrim is a sibling, never a parent: its opacity would fade the panel with it. */}
93
+ <Animated.View
94
+ style={lift}
95
+ pointerEvents="box-none"
96
+ className="absolute inset-0 items-center justify-end"
97
+ >
98
+ <GestureDetector gesture={wholePanel ? sheet.pull : Gesture.Native()}>
99
+ <Animated.View
100
+ onLayout={sheet.onPanelLayout}
101
+ accessibilityViewIsModal
102
+ onAccessibilityEscape={onClose}
103
+ className={
104
+ ios
105
+ ? 'w-full rounded-t-[20px] bg-surfaceContainerLow px-4 pt-2'
106
+ : 'w-full rounded-t-4xl bg-surfaceContainerLow px-4 pt-3'
107
+ }
108
+ style={[
109
+ sheet.panelStyle,
110
+ {
111
+ maxWidth: MAX_WIDTH,
112
+ elevation: elevation.level1,
113
+ ...(glass ? { backgroundColor: 'transparent' } : {}),
114
+ },
115
+ panelStyle,
116
+ ]}
117
+ >
118
+ {glass ? (
119
+ <Surface
120
+ material={material}
121
+ glassStyle={glassStyle}
122
+ tintColor={tintColor}
123
+ fallbackColor={fallbackColor}
124
+ interactive={interactive}
125
+ pointerEvents="none"
126
+ style={{
127
+ position: 'absolute',
128
+ top: 0,
129
+ left: 0,
130
+ right: 0,
131
+ bottom: 0,
132
+ borderTopLeftRadius: ios ? 20 : 32,
133
+ borderTopRightRadius: ios ? 20 : 32,
134
+ }}
135
+ />
136
+ ) : null}
137
+ {/* Below the panel, out of sight until a pull up lifts it off the edge. */}
138
+ <View
139
+ pointerEvents="none"
140
+ className="absolute left-0 right-0 top-full h-24 bg-surfaceContainerLow"
141
+ />
142
+
143
+ {/* The handle closes the sheet when tapped, and is how TalkBack does. */}
144
+ <GestureDetector gesture={wholePanel ? Gesture.Native() : sheet.pull}>
145
+ <Pressable
146
+ onPress={onClose}
147
+ accessibilityRole="button"
148
+ accessibilityLabel={labels.close}
149
+ className="-mx-4 -mt-3 items-center py-3"
150
+ >
151
+ <View
152
+ className="h-1 w-8 rounded-full"
153
+ style={{ backgroundColor: colors.onSurfaceVariant, opacity: 0.4 }}
154
+ />
155
+ </Pressable>
156
+ </GestureDetector>
157
+
158
+ {ios ? (
159
+ <Pressable
160
+ onPress={onClose}
161
+ accessibilityRole="button"
162
+ accessibilityLabel={labels.close}
163
+ className="min-h-11 self-end justify-center px-2"
164
+ >
165
+ <Text style={{ color: colors.primary, fontWeight: '600' }}>{labels.close}</Text>
166
+ </Pressable>
167
+ ) : null}
168
+ <SheetScrollContext.Provider value={sheet.scrollContext}>
169
+ {shown}
170
+ </SheetScrollContext.Provider>
171
+ </Animated.View>
172
+ </GestureDetector>
173
+ </Animated.View>
174
+ </View>
175
+ );
176
+ }
177
+
178
+ /**
179
+ * A vertical list inside a sheet. It scrolls first and lets the sheet's pull
180
+ * take over at its top, as Android's own sheets do; a plain `ScrollView`
181
+ * would fight the pull for every vertical swipe. Sideways rows of chips can
182
+ * stay plain `ScrollView`s: the pull gives way to a sideways swipe.
183
+ */
184
+ export function SheetScrollView({
185
+ onScroll,
186
+ onLayout,
187
+ onContentSizeChange,
188
+ ...props
189
+ }: ScrollViewProps) {
190
+ const context = React.useContext(SheetScrollContext);
191
+ const id = React.useId();
192
+ const viewport = React.useRef(0);
193
+ const content = React.useRef(0);
194
+ const native = React.useMemo(
195
+ () =>
196
+ context
197
+ ? Gesture.Native().simultaneousWithExternalGesture(context.pull)
198
+ : Gesture.Native(),
199
+ [context],
200
+ );
201
+
202
+ const report = React.useCallback(
203
+ (patch: Partial<{ y: number; scrollable: boolean }>) => {
204
+ if (!context) return;
205
+ context.scrolls.modify((scrolls) => {
206
+ 'worklet';
207
+ const previous = scrolls[id] ?? { y: 0, scrollable: false };
208
+ scrolls[id] = { ...previous, ...patch };
209
+ return scrolls;
210
+ });
211
+ },
212
+ [context, id],
213
+ );
214
+
215
+ React.useEffect(() => {
216
+ if (!context) return;
217
+ return () => {
218
+ context.scrolls.modify((scrolls) => {
219
+ 'worklet';
220
+ delete scrolls[id];
221
+ return scrolls;
222
+ });
223
+ };
224
+ }, [context, id]);
225
+
226
+ return (
227
+ <GestureDetector gesture={native}>
228
+ <ScrollView
229
+ scrollEventThrottle={16}
230
+ // The sheet moving is the feedback; a stretch on the list as well would be two.
231
+ overScrollMode="never"
232
+ onScroll={(event: NativeSyntheticEvent<NativeScrollEvent>) => {
233
+ report({ y: event.nativeEvent.contentOffset.y });
234
+ onScroll?.(event);
235
+ }}
236
+ onLayout={(event: LayoutChangeEvent) => {
237
+ viewport.current = event.nativeEvent.layout.height;
238
+ report({ scrollable: content.current > viewport.current + 1 });
239
+ onLayout?.(event);
240
+ }}
241
+ onContentSizeChange={(width: number, height: number) => {
242
+ content.current = height;
243
+ report({ scrollable: content.current > viewport.current + 1 });
244
+ onContentSizeChange?.(width, height);
245
+ }}
246
+ {...props}
247
+ />
248
+ </GestureDetector>
249
+ );
250
+ }
@@ -0,0 +1,233 @@
1
+ import * as React from 'react';
2
+ import { Dimensions, type ViewStyle, type LayoutChangeEvent } from 'react-native';
3
+ import { Gesture } from 'react-native-gesture-handler';
4
+ import {
5
+ runOnJS,
6
+ useAnimatedReaction,
7
+ useAnimatedStyle,
8
+ useSharedValue,
9
+ withSpring,
10
+ type SharedValue,
11
+ type AnimatedStyle,
12
+ } from 'react-native-reanimated';
13
+
14
+ import { spatial } from '../motion.ts';
15
+ import { useReducedMotion } from '../reduced-motion.ts';
16
+ import {
17
+ anyScrollable,
18
+ listsAtTop,
19
+ releaseCloses,
20
+ rubberBand,
21
+ scrimShare,
22
+ type SheetScroll,
23
+ } from '../sheet-physics.ts';
24
+
25
+ /** Arrives without overshooting: see `spatialStandard`. */
26
+ const SHEET_SPRING = { ...spatial.default, overshootClamping: true };
27
+
28
+ /** What a `SheetScrollView` needs from the sheet it scrolls in. */
29
+ export interface SheetScrollContextValue {
30
+ pull: ReturnType<typeof Gesture.Pan>;
31
+ scrolls: SharedValue<Record<string, SheetScroll>>;
32
+ }
33
+
34
+ /**
35
+ * A bottom sheet's whole life: in, under the finger, and out again.
36
+ *
37
+ * Driven by shared values rather than Reanimated's `entering` layout
38
+ * animations, and that is the point. On Android a layout animation on a view
39
+ * inside a `Modal` leaves the view's *layout* where the animation started even
40
+ * though it is drawn where the animation ended, so presses inside the panel
41
+ * are hit-tested against whatever is behind it — here the scrim, whose one job
42
+ * is to close the panel. A picker built that way looks like it snaps back the
43
+ * instant an option is tapped (software-mansion/react-native-reanimated#4422).
44
+ * An animated transform moves the native view, and Android hit-tests against
45
+ * where the view actually is.
46
+ *
47
+ * The sheet stays `mounted` after `visible` turns false, until the panel has
48
+ * slid out: however it was closed — the scrim, back, a row that acts, a pull —
49
+ * it leaves the way it came. The scrim is not on a clock of its own; it is
50
+ * worked out from where the panel is, so the two always move as one.
51
+ */
52
+ export interface Sheet {
53
+ mounted: boolean;
54
+ scrimStyle: AnimatedStyle<ViewStyle>;
55
+ panelStyle: AnimatedStyle<ViewStyle>;
56
+ onPanelLayout: (event: LayoutChangeEvent) => void;
57
+ pull: ReturnType<typeof Gesture.Pan>;
58
+ scrollContext: SheetScrollContextValue;
59
+ }
60
+ export function useSheet(visible: boolean, onClose: () => void): Sheet {
61
+ /*
62
+ * Under "Remove animations" the panel still travels — it has to, for the
63
+ * hit-testing reason above — but it gets there at once.
64
+ */
65
+ const reducedMotion = useReducedMotion();
66
+ const [mounted, setMounted] = React.useState(visible);
67
+ if (visible && !mounted) setMounted(true);
68
+
69
+ // How far below its rest the panel is: parked off-screen until it has been
70
+ // measured, so the first frame never shows it before the spring has begun.
71
+ const offset = useSharedValue(Dimensions.get('window').height);
72
+ // How far a finger has moved it on top of that (negative: lifted).
73
+ const drag = useSharedValue(0);
74
+ // The panel's height, 0 until measured.
75
+ const height = useSharedValue(0);
76
+ // Set once a pull has sent the panel on its way out, so the close that
77
+ // follows does not start the leaving over from rest.
78
+ const leaving = useSharedValue(false);
79
+ // Where the finger was when every list reached its top; NaN while a list
80
+ // still has room to scroll back up.
81
+ const anchor = useSharedValue(0);
82
+ const scrolls = useSharedValue<Record<string, SheetScroll>>({});
83
+
84
+ // Set by a pull that closed the sheet; the reaction below tells the owner.
85
+ const pulledShut = useSharedValue(false);
86
+
87
+ /** Rearms everything for the next time the sheet opens. */
88
+ const rearm = React.useCallback(() => {
89
+ offset.set(Dimensions.get('window').height);
90
+ drag.set(0);
91
+ height.set(0);
92
+ leaving.set(false);
93
+ pulledShut.set(false);
94
+ }, [drag, height, leaving, offset, pulledShut]);
95
+
96
+ /** Ends the sheet's life, once the panel is out of sight. */
97
+ const unmount = React.useCallback(() => {
98
+ setMounted(false);
99
+ rearm();
100
+ }, [rearm]);
101
+
102
+ // With nothing to watch leave, the sheet is gone as soon as it is closed.
103
+ if (!visible && mounted && reducedMotion) setMounted(false);
104
+
105
+ useAnimatedReaction(
106
+ () => pulledShut.value,
107
+ (shut, before) => {
108
+ if (shut && !before) runOnJS(onClose)();
109
+ },
110
+ [onClose],
111
+ );
112
+
113
+ React.useEffect(() => {
114
+ if (visible) {
115
+ leaving.set(false);
116
+ pulledShut.set(false);
117
+ // Opened again while it was still leaving: back up from where it is.
118
+ if (height.get() > 0) offset.set(reducedMotion ? 0 : withSpring(0, SHEET_SPRING));
119
+ return;
120
+ }
121
+ if (reducedMotion) {
122
+ rearm();
123
+ return;
124
+ }
125
+ if (!mounted || leaving.get()) return;
126
+ leaving.set(true);
127
+ offset.set(offset.get() + drag.get());
128
+ drag.set(0);
129
+ // Not measured yet, it is still parked off-screen and only has to stop.
130
+ const out = height.get() > 0 ? height.get() : offset.get();
131
+ offset.set(
132
+ withSpring(out, SHEET_SPRING, (finished) => {
133
+ if (finished) runOnJS(unmount)();
134
+ }),
135
+ );
136
+ }, [
137
+ drag,
138
+ height,
139
+ leaving,
140
+ mounted,
141
+ offset,
142
+ pulledShut,
143
+ reducedMotion,
144
+ rearm,
145
+ unmount,
146
+ visible,
147
+ ]);
148
+
149
+ /**
150
+ * Starts the slide, once the panel's height is known: the travel is the
151
+ * panel's own height, so it rises from the screen edge rather than flying in
152
+ * from an arbitrary distance. Later layouts only keep the height current.
153
+ */
154
+ const onPanelLayout = React.useCallback(
155
+ (event: LayoutChangeEvent) => {
156
+ const measured = Math.max(1, event.nativeEvent.layout.height);
157
+ const first = height.get() === 0;
158
+ height.set(measured);
159
+ if (!first) return;
160
+ offset.set(measured);
161
+ offset.set(reducedMotion ? 0 : withSpring(0, SHEET_SPRING));
162
+ },
163
+ [height, offset, reducedMotion],
164
+ );
165
+
166
+ /*
167
+ * The whole panel takes the pull, as Android's own sheets do: it starts
168
+ * only once a finger has moved, so a tap still reaches the row under it;
169
+ * it gives way to a sideways swipe (a row of chips, a calendar's months);
170
+ * and a list inside scrolls first, handing over to the sheet once it is at
171
+ * its top. Letting go carries the finger's speed into the spring.
172
+ */
173
+ const pull = React.useMemo(
174
+ () =>
175
+ Gesture.Pan()
176
+ .activeOffsetY([-10, 10])
177
+ .failOffsetX([-12, 12])
178
+ .onStart(() => {
179
+ anchor.set(listsAtTop(scrolls.get()) ? 0 : NaN);
180
+ })
181
+ .onUpdate((event) => {
182
+ if (leaving.get()) return;
183
+ if (Number.isNaN(anchor.get())) {
184
+ if (!listsAtTop(scrolls.get())) return;
185
+ anchor.set(event.translationY);
186
+ }
187
+ const moved = event.translationY - anchor.get();
188
+ if (moved >= 0) drag.set(moved);
189
+ else drag.set(anyScrollable(scrolls.get()) ? 0 : -rubberBand(-moved));
190
+ })
191
+ .onFinalize((_event, success) => {
192
+ if (!success && !leaving.get())
193
+ drag.set(reducedMotion ? 0 : withSpring(0, SHEET_SPRING));
194
+ })
195
+ .onEnd((event) => {
196
+ if (leaving.get()) return;
197
+ const pulled = drag.get();
198
+ if (!releaseCloses(pulled, event.velocityY, height.get())) {
199
+ drag.set(withSpring(0, { ...SHEET_SPRING, velocity: event.velocityY }));
200
+ return;
201
+ }
202
+ leaving.set(true);
203
+ offset.set(offset.get() + pulled);
204
+ drag.set(0);
205
+ pulledShut.set(true);
206
+ if (reducedMotion) return;
207
+ offset.set(
208
+ withSpring(
209
+ height.get(),
210
+ { ...SHEET_SPRING, velocity: event.velocityY },
211
+ (finished) => {
212
+ if (finished) runOnJS(unmount)();
213
+ },
214
+ ),
215
+ );
216
+ }),
217
+ [anchor, drag, height, leaving, offset, pulledShut, reducedMotion, scrolls, unmount],
218
+ );
219
+
220
+ const scrimStyle = useAnimatedStyle(() => ({
221
+ opacity: scrimShare(offset.value + drag.value, height.value),
222
+ }));
223
+ const panelStyle = useAnimatedStyle(() => ({
224
+ transform: [{ translateY: offset.value + drag.value }],
225
+ }));
226
+
227
+ const scrollContext = React.useMemo<SheetScrollContextValue>(
228
+ () => ({ pull, scrolls }),
229
+ [pull, scrolls],
230
+ );
231
+
232
+ return { mounted, scrimStyle, panelStyle, onPanelLayout, pull, scrollContext };
233
+ }
@@ -0,0 +1,105 @@
1
+ import * as React from 'react';
2
+ import { Dimensions, type LayoutChangeEvent } from 'react-native';
3
+ import {
4
+ type AnimatedStyle,
5
+ type SharedValue,
6
+ useAnimatedStyle,
7
+ useSharedValue,
8
+ withSpring,
9
+ withTiming,
10
+ } from 'react-native-reanimated';
11
+
12
+ import { spatial } from '../motion.ts';
13
+ import { useReducedMotion } from '../reduced-motion.ts';
14
+
15
+ /** How long the scrim takes to dim — the same 120ms the sheets faded in over before. */
16
+ const SCRIM_FADE_MS = 120;
17
+
18
+ /**
19
+ * The open transition for a panel presented inside a `Modal`: the scrim dims,
20
+ * and the panel springs up from the edge it is anchored to.
21
+ *
22
+ * Driven by shared values rather than Reanimated's `entering` layout
23
+ * animations, and that is the entire point of this hook. On Android a layout
24
+ * animation on a view inside a `Modal` leaves the view's *layout* where the
25
+ * animation started even though the view is drawn where the animation ended,
26
+ * so presses inside the panel are hit-tested against whatever is behind it —
27
+ * here the scrim, whose one job is to close the panel. A picker built that way
28
+ * looks like it snaps back the instant an option is tapped: the tap closed the
29
+ * sheet and never reached the row it landed on. Only Android is affected, and
30
+ * only inside a `Modal`, which is why it survived review twice
31
+ * (software-mansion/react-native-reanimated#4422).
32
+ *
33
+ * An animated transform has no such problem: it moves the native view, and
34
+ * Android hit-tests against where the view actually is.
35
+ */
36
+ export interface SheetTransition {
37
+ scrimStyle: AnimatedStyle<{ opacity: number }>;
38
+ panelStyle: AnimatedStyle<{ transform: { translateY: number }[] }>;
39
+ onPanelLayout: (event: LayoutChangeEvent) => void;
40
+ drag: SharedValue<number>;
41
+ height: SharedValue<number>;
42
+ reducedMotion: boolean;
43
+ }
44
+
45
+ export function useSheetTransition(visible: boolean): SheetTransition {
46
+ /*
47
+ * Under "Remove animations" the panel still travels — it has to, for the
48
+ * hit-testing reason above — but it gets there at once. The scrim stops
49
+ * fading for the same reason: a sheet appearing is orientation, not
50
+ * decoration, and the reader still needs to see where it came from. What
51
+ * they no longer get is the journey.
52
+ */
53
+ const reducedMotion = useReducedMotion();
54
+ const dim = useSharedValue(0);
55
+ // Parked off-screen until the panel has been measured, so the first frame
56
+ // never shows it at its resting place before the spring has begun.
57
+ const offset = useSharedValue(Dimensions.get('window').height);
58
+ // How far a finger has pulled the panel down from where it rests, and the
59
+ // panel's height to weigh that against — the scrim lightens as it goes.
60
+ const drag = useSharedValue(0);
61
+ const height = useSharedValue(1);
62
+ const measured = React.useRef(false);
63
+
64
+ React.useEffect(() => {
65
+ if (visible) {
66
+ dim.value = reducedMotion ? 1 : withTiming(1, { duration: SCRIM_FADE_MS });
67
+ return;
68
+ }
69
+
70
+ // `Modal` unmounts its children as it hides, so there is nothing left to
71
+ // animate out — this only rearms the transition for the next open.
72
+ measured.current = false;
73
+ dim.value = 0;
74
+ drag.value = 0;
75
+ offset.value = Dimensions.get('window').height;
76
+ }, [dim, drag, offset, reducedMotion, visible]);
77
+
78
+ /**
79
+ * Starts the slide, once the panel's height is known: the travel is the
80
+ * panel's own height, so it rises from the screen edge rather than flying in
81
+ * from an arbitrary distance.
82
+ */
83
+ const onPanelLayout = React.useCallback(
84
+ (event: LayoutChangeEvent) => {
85
+ // Keyboard and window-size changes also change the dismiss distance.
86
+ height.set(Math.max(1, event.nativeEvent.layout.height));
87
+ if (measured.current) return;
88
+ measured.current = true;
89
+ offset.set(event.nativeEvent.layout.height);
90
+ offset.set(reducedMotion ? 0 : withSpring(0, spatial.default));
91
+ },
92
+ [height, offset, reducedMotion],
93
+ );
94
+
95
+ const scrimStyle = useAnimatedStyle(() => ({
96
+ opacity: dim.value * (1 - Math.min(1, drag.value / height.value)),
97
+ }));
98
+ const panelStyle = useAnimatedStyle(() => ({
99
+ transform: [{ translateY: offset.value + drag.value }],
100
+ }));
101
+
102
+ return { scrimStyle, panelStyle, onPanelLayout, drag, height, reducedMotion };
103
+ }
104
+
105
+ export { useSheet, type Sheet, type SheetScrollContextValue } from './sheet-lifecycle.ts';