@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.
- package/LICENSE +22 -0
- package/NOTICE.md +12 -0
- package/README.md +104 -3
- package/dist/amount.d.ts +3 -0
- package/dist/calendar.d.ts +7 -0
- package/dist/components/action-chip.d.ts +9 -0
- package/dist/components/amount-field.d.ts +12 -0
- package/dist/components/avatar.d.ts +17 -0
- package/dist/components/back-button.d.ts +12 -0
- package/dist/components/back-icon.d.ts +3 -0
- package/dist/components/badge.d.ts +13 -0
- package/dist/components/banner.d.ts +14 -0
- package/dist/components/bottom-sheet-native.d.ts +11 -0
- package/dist/components/bottom-sheet-web.d.ts +18 -0
- package/dist/components/bottom-sheet.d.ts +3 -0
- package/dist/components/button-group.d.ts +26 -0
- package/dist/components/button.d.ts +48 -0
- package/dist/components/checkbox.d.ts +12 -0
- package/dist/components/color-picker.d.ts +21 -0
- package/dist/components/context-menu.d.ts +26 -0
- package/dist/components/date-picker.d.ts +64 -0
- package/dist/components/dialog-context.d.ts +2 -0
- package/dist/components/dialog.d.ts +20 -0
- package/dist/components/empty-state.d.ts +15 -0
- package/dist/components/expand-indicator.d.ts +27 -0
- package/dist/components/expressive-shape.d.ts +18 -0
- package/dist/components/fab-menu-legacy.d.ts +31 -0
- package/dist/components/fab-menu.d.ts +21 -0
- package/dist/components/filter-chip.d.ts +69 -0
- package/dist/components/floating-action-button.d.ts +15 -0
- package/dist/components/icon-button.d.ts +40 -0
- package/dist/components/icons.d.ts +18 -0
- package/dist/components/input.d.ts +9 -0
- package/dist/components/keyboard-inset-view.d.ts +15 -0
- package/dist/components/list-controls.d.ts +38 -0
- package/dist/components/loading-indicator.d.ts +9 -0
- package/dist/components/menu-sheet.d.ts +36 -0
- package/dist/components/panel-sheet.d.ts +8 -0
- package/dist/components/password-visibility.d.ts +5 -0
- package/dist/components/popover-layout.d.ts +25 -0
- package/dist/components/popover.d.ts +20 -0
- package/dist/components/pressable.d.ts +12 -0
- package/dist/components/radio-navigation.d.ts +11 -0
- package/dist/components/refresh-control.d.ts +16 -0
- package/dist/components/row-gap.d.ts +9 -0
- package/dist/components/search-field.d.ts +22 -0
- package/dist/components/select-field.d.ts +16 -0
- package/dist/components/settings-list.d.ts +98 -0
- package/dist/components/settings-row.d.ts +13 -0
- package/dist/components/sheet-frame.d.ts +27 -0
- package/dist/components/sheet-lifecycle.d.ts +36 -0
- package/dist/components/sheet-transition.d.ts +36 -0
- package/dist/components/slider.d.ts +21 -0
- package/dist/components/snackbar.d.ts +10 -0
- package/dist/components/split-button.d.ts +38 -0
- package/dist/components/squish.d.ts +20 -0
- package/dist/components/swipeable-row.d.ts +20 -0
- package/dist/components/switch.d.ts +8 -0
- package/dist/components/tab-bar.d.ts +20 -0
- package/dist/components/text-field.d.ts +31 -0
- package/dist/components/text.d.ts +9 -0
- package/dist/components/time-picker.d.ts +40 -0
- package/dist/components/toolbar.d.ts +77 -0
- package/dist/components/top-bar.d.ts +27 -0
- package/dist/components/undo-bar.d.ts +23 -0
- package/dist/components/wavy-progress.d.ts +21 -0
- package/dist/components/zoomable-image.d.ts +18 -0
- package/dist/expressive-shapes.d.ts +8 -0
- package/dist/index.d.ts +62 -0
- package/dist/keyboard.d.ts +2 -0
- package/dist/material.d.ts +11 -0
- package/dist/media.d.ts +11 -0
- package/dist/motion.d.ts +96 -0
- package/dist/platform.d.ts +3 -0
- package/dist/preset-tokens.d.ts +264 -0
- package/dist/provider.d.ts +59 -0
- package/dist/reduced-motion.d.ts +45 -0
- package/dist/selection.d.ts +2 -0
- package/dist/shape.d.ts +99 -0
- package/dist/sheet-physics.d.ts +37 -0
- package/dist/theme.d.ts +103 -0
- package/dist/tokens.d.ts +2 -0
- package/dist/use-keyboard-height.d.ts +10 -0
- package/dist/utils.d.ts +3 -0
- package/nativewind.cjs +68 -0
- package/package.json +63 -6
- package/src/amount.ts +53 -0
- package/src/calendar.ts +42 -0
- package/src/components/action-chip.tsx +64 -0
- package/src/components/amount-field.tsx +49 -0
- package/src/components/avatar.tsx +91 -0
- package/src/components/back-button.tsx +39 -0
- package/src/components/back-icon.tsx +25 -0
- package/src/components/badge.tsx +41 -0
- package/src/components/banner.tsx +63 -0
- package/src/components/bottom-sheet-native.tsx +52 -0
- package/src/components/bottom-sheet-web.tsx +193 -0
- package/src/components/bottom-sheet.tsx +5 -0
- package/src/components/button-group.tsx +326 -0
- package/src/components/button.tsx +337 -0
- package/src/components/checkbox.tsx +71 -0
- package/src/components/color-picker.tsx +115 -0
- package/src/components/context-menu.tsx +149 -0
- package/src/components/date-picker.tsx +466 -0
- package/src/components/dialog-context.ts +3 -0
- package/src/components/dialog.tsx +210 -0
- package/src/components/empty-state.tsx +49 -0
- package/src/components/expand-indicator.tsx +60 -0
- package/src/components/expressive-shape.tsx +37 -0
- package/src/components/fab-menu-legacy.tsx +181 -0
- package/src/components/fab-menu.tsx +90 -0
- package/src/components/filter-chip.tsx +136 -0
- package/src/components/floating-action-button.tsx +79 -0
- package/src/components/icon-button.tsx +127 -0
- package/src/components/icons.tsx +43 -0
- package/src/components/input.tsx +80 -0
- package/src/components/keyboard-inset-view.tsx +28 -0
- package/src/components/list-controls.tsx +134 -0
- package/src/components/loading-indicator.tsx +183 -0
- package/src/components/menu-sheet.tsx +133 -0
- package/src/components/panel-sheet.tsx +90 -0
- package/src/components/password-visibility.tsx +39 -0
- package/src/components/popover-layout.ts +47 -0
- package/src/components/popover.tsx +139 -0
- package/src/components/pressable.tsx +108 -0
- package/src/components/radio-navigation.ts +52 -0
- package/src/components/refresh-control.tsx +39 -0
- package/src/components/row-gap.tsx +13 -0
- package/src/components/search-field.tsx +92 -0
- package/src/components/select-field.tsx +105 -0
- package/src/components/settings-list.tsx +302 -0
- package/src/components/settings-row.tsx +61 -0
- package/src/components/sheet-frame.tsx +250 -0
- package/src/components/sheet-lifecycle.ts +233 -0
- package/src/components/sheet-transition.ts +105 -0
- package/src/components/slider.tsx +185 -0
- package/src/components/snackbar.tsx +97 -0
- package/src/components/split-button.tsx +212 -0
- package/src/components/squish.ts +41 -0
- package/src/components/swipeable-row.tsx +109 -0
- package/src/components/switch.tsx +77 -0
- package/src/components/tab-bar.tsx +195 -0
- package/src/components/text-field.tsx +264 -0
- package/src/components/text.tsx +58 -0
- package/src/components/time-picker.tsx +277 -0
- package/src/components/toolbar.tsx +406 -0
- package/src/components/top-bar.tsx +91 -0
- package/src/components/undo-bar.tsx +137 -0
- package/src/components/wavy-progress.tsx +150 -0
- package/src/components/zoomable-image.tsx +240 -0
- package/src/expressive-shapes.ts +25 -0
- package/src/index.ts +62 -0
- package/src/keyboard.ts +5 -0
- package/src/material.ts +16 -0
- package/src/media.ts +51 -0
- package/src/motion.ts +96 -0
- package/src/platform.ts +13 -0
- package/src/preset-tokens.ts +265 -0
- package/src/provider.tsx +201 -0
- package/src/reduced-motion.ts +50 -0
- package/src/selection.ts +11 -0
- package/src/shape.ts +217 -0
- package/src/sheet-physics.ts +73 -0
- package/src/theme.ts +180 -0
- package/src/tokens.ts +2 -0
- package/src/use-keyboard-height.ts +64 -0
- 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';
|