panelui-native 0.73.0 → 0.75.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 +3 -1
- package/lib/module/components/avatar/avatar-group.js +64 -0
- package/lib/module/components/avatar/avatar-group.js.map +1 -0
- package/lib/module/components/avatar/index.js +84 -2
- package/lib/module/components/avatar/index.js.map +1 -1
- package/lib/module/components/color-picker/color-picker-accessibility.js +12 -0
- package/lib/module/components/color-picker/color-picker-accessibility.js.map +1 -0
- package/lib/module/components/color-picker/index.js +9 -5
- package/lib/module/components/color-picker/index.js.map +1 -1
- package/lib/module/components/date-time-picker/index.js +11 -3
- package/lib/module/components/date-time-picker/index.js.map +1 -1
- package/lib/module/components/drawer/index.js +58 -13
- package/lib/module/components/drawer/index.js.map +1 -1
- package/lib/module/components/input/index.js +7 -4
- package/lib/module/components/input/index.js.map +1 -1
- package/lib/module/components/input/input-content-padding.js +6 -0
- package/lib/module/components/input/input-content-padding.js.map +1 -0
- package/lib/module/components/input-group/index.js +14 -9
- package/lib/module/components/input-group/index.js.map +1 -1
- package/lib/module/components/input-group/input-group-measurements.js +60 -0
- package/lib/module/components/input-group/input-group-measurements.js.map +1 -0
- package/lib/module/components/otp-input/index.js +3 -1
- package/lib/module/components/otp-input/index.js.map +1 -1
- package/lib/module/components/otp-input/otp-input-state.js +7 -0
- package/lib/module/components/otp-input/otp-input-state.js.map +1 -0
- package/lib/module/components/planner/index.js +20 -6
- package/lib/module/components/planner/index.js.map +1 -1
- package/lib/module/components/planner/planner-today.js +8 -0
- package/lib/module/components/planner/planner-today.js.map +1 -0
- package/lib/module/components/plot/index.js +8 -11
- package/lib/module/components/plot/index.js.map +1 -1
- package/lib/module/components/plot/plot-series-registry.js +32 -0
- package/lib/module/components/plot/plot-series-registry.js.map +1 -0
- package/lib/module/components/radio-group/index.js +2 -1
- package/lib/module/components/radio-group/index.js.map +1 -1
- package/lib/module/components/radio-group/radio-group-value.js +8 -0
- package/lib/module/components/radio-group/radio-group-value.js.map +1 -0
- package/lib/module/components/rating/index.js +7 -4
- package/lib/module/components/rating/index.js.map +1 -1
- package/lib/module/components/rating/rating-inputs.js +15 -0
- package/lib/module/components/rating/rating-inputs.js.map +1 -0
- package/lib/module/components/search-bar/index.js +7 -7
- package/lib/module/components/search-bar/index.js.map +1 -1
- package/lib/module/components/search-bar/search-bar-debounce.js +23 -0
- package/lib/module/components/search-bar/search-bar-debounce.js.map +1 -0
- package/lib/module/components/select/index.js +9 -1
- package/lib/module/components/select/index.js.map +1 -1
- package/lib/module/components/select/native-select-contract.js +11 -0
- package/lib/module/components/select/native-select-contract.js.map +1 -0
- package/lib/module/components/split-view/index.js +439 -0
- package/lib/module/components/split-view/index.js.map +1 -0
- package/lib/module/components/split-view/split-view-math.js +132 -0
- package/lib/module/components/split-view/split-view-math.js.map +1 -0
- package/lib/module/components/splitter/index.js +433 -0
- package/lib/module/components/splitter/index.js.map +1 -0
- package/lib/module/components/splitter/splitter-math.js +206 -0
- package/lib/module/components/splitter/splitter-math.js.map +1 -0
- package/lib/module/components/steps/index.js +16 -5
- package/lib/module/components/steps/index.js.map +1 -1
- package/lib/module/components/steps/steps-trigger.js +6 -0
- package/lib/module/components/steps/steps-trigger.js.map +1 -0
- package/lib/module/components/tag-input/index.js +15 -10
- package/lib/module/components/tag-input/index.js.map +1 -1
- package/lib/module/components/tag-input/tag-input-state.js +14 -0
- package/lib/module/components/tag-input/tag-input-state.js.map +1 -0
- package/lib/module/components/textarea/index.js +9 -4
- package/lib/module/components/textarea/index.js.map +1 -1
- package/lib/module/components/textarea/textarea-disabled.js +6 -0
- package/lib/module/components/textarea/textarea-disabled.js.map +1 -0
- package/lib/module/components/timeline/index.js +83 -27
- package/lib/module/components/timeline/index.js.map +1 -1
- package/lib/module/components/timeline/timeline-geometry.js +11 -0
- package/lib/module/components/timeline/timeline-geometry.js.map +1 -0
- package/lib/module/components/toast/index.js +18 -1
- package/lib/module/components/toast/index.js.map +1 -1
- package/lib/module/components/toast/toast-store.js +36 -4
- package/lib/module/components/toast/toast-store.js.map +1 -1
- package/lib/module/components/tour/index.js +4 -2
- package/lib/module/components/tour/index.js.map +1 -1
- package/lib/module/components/tour/tour-card-measurement.js +24 -0
- package/lib/module/components/tour/tour-card-measurement.js.map +1 -0
- package/lib/module/index.js +2 -0
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/components/avatar/avatar-group.d.ts +46 -0
- package/lib/typescript/src/components/avatar/avatar-group.d.ts.map +1 -0
- package/lib/typescript/src/components/avatar/index.d.ts +23 -1
- package/lib/typescript/src/components/avatar/index.d.ts.map +1 -1
- package/lib/typescript/src/components/color-picker/color-picker-accessibility.d.ts +8 -0
- package/lib/typescript/src/components/color-picker/color-picker-accessibility.d.ts.map +1 -0
- package/lib/typescript/src/components/color-picker/index.d.ts.map +1 -1
- package/lib/typescript/src/components/date-time-picker/index.d.ts.map +1 -1
- package/lib/typescript/src/components/drawer/index.d.ts.map +1 -1
- package/lib/typescript/src/components/input/index.d.ts.map +1 -1
- package/lib/typescript/src/components/input/input-content-padding.d.ts +2 -0
- package/lib/typescript/src/components/input/input-content-padding.d.ts.map +1 -0
- package/lib/typescript/src/components/input-group/index.d.ts.map +1 -1
- package/lib/typescript/src/components/input-group/input-group-measurements.d.ts +16 -0
- package/lib/typescript/src/components/input-group/input-group-measurements.d.ts.map +1 -0
- package/lib/typescript/src/components/otp-input/index.d.ts.map +1 -1
- package/lib/typescript/src/components/otp-input/otp-input-state.d.ts +3 -0
- package/lib/typescript/src/components/otp-input/otp-input-state.d.ts.map +1 -0
- package/lib/typescript/src/components/planner/index.d.ts.map +1 -1
- package/lib/typescript/src/components/planner/planner-today.d.ts +2 -0
- package/lib/typescript/src/components/planner/planner-today.d.ts.map +1 -0
- package/lib/typescript/src/components/plot/index.d.ts +1 -1
- package/lib/typescript/src/components/plot/index.d.ts.map +1 -1
- package/lib/typescript/src/components/plot/plot-series-registry.d.ts +8 -0
- package/lib/typescript/src/components/plot/plot-series-registry.d.ts.map +1 -0
- package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
- package/lib/typescript/src/components/radio-group/radio-group-value.d.ts +2 -0
- package/lib/typescript/src/components/radio-group/radio-group-value.d.ts.map +1 -0
- package/lib/typescript/src/components/rating/index.d.ts.map +1 -1
- package/lib/typescript/src/components/rating/rating-inputs.d.ts +4 -0
- package/lib/typescript/src/components/rating/rating-inputs.d.ts.map +1 -0
- package/lib/typescript/src/components/search-bar/index.d.ts.map +1 -1
- package/lib/typescript/src/components/search-bar/search-bar-debounce.d.ts +7 -0
- package/lib/typescript/src/components/search-bar/search-bar-debounce.d.ts.map +1 -0
- package/lib/typescript/src/components/select/index.d.ts.map +1 -1
- package/lib/typescript/src/components/select/native-select-contract.d.ts +11 -0
- package/lib/typescript/src/components/select/native-select-contract.d.ts.map +1 -0
- package/lib/typescript/src/components/split-view/index.d.ts +159 -0
- package/lib/typescript/src/components/split-view/index.d.ts.map +1 -0
- package/lib/typescript/src/components/split-view/split-view-math.d.ts +54 -0
- package/lib/typescript/src/components/split-view/split-view-math.d.ts.map +1 -0
- package/lib/typescript/src/components/splitter/index.d.ts +78 -0
- package/lib/typescript/src/components/splitter/index.d.ts.map +1 -0
- package/lib/typescript/src/components/splitter/splitter-math.d.ts +67 -0
- package/lib/typescript/src/components/splitter/splitter-math.d.ts.map +1 -0
- package/lib/typescript/src/components/steps/index.d.ts +2 -2
- package/lib/typescript/src/components/steps/index.d.ts.map +1 -1
- package/lib/typescript/src/components/steps/steps-trigger.d.ts +2 -0
- package/lib/typescript/src/components/steps/steps-trigger.d.ts.map +1 -0
- package/lib/typescript/src/components/tag-input/index.d.ts.map +1 -1
- package/lib/typescript/src/components/tag-input/tag-input-state.d.ts +12 -0
- package/lib/typescript/src/components/tag-input/tag-input-state.d.ts.map +1 -0
- package/lib/typescript/src/components/textarea/index.d.ts.map +1 -1
- package/lib/typescript/src/components/textarea/textarea-disabled.d.ts +2 -0
- package/lib/typescript/src/components/textarea/textarea-disabled.d.ts.map +1 -0
- package/lib/typescript/src/components/timeline/index.d.ts +1 -0
- package/lib/typescript/src/components/timeline/index.d.ts.map +1 -1
- package/lib/typescript/src/components/timeline/timeline-geometry.d.ts +4 -0
- package/lib/typescript/src/components/timeline/timeline-geometry.d.ts.map +1 -0
- package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
- package/lib/typescript/src/components/toast/toast-store.d.ts +7 -2
- package/lib/typescript/src/components/toast/toast-store.d.ts.map +1 -1
- package/lib/typescript/src/components/tour/index.d.ts.map +1 -1
- package/lib/typescript/src/components/tour/tour-card-measurement.d.ts +14 -0
- package/lib/typescript/src/components/tour/tour-card-measurement.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +3 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/avatar/avatar-group.ts +78 -0
- package/src/components/avatar/index.tsx +121 -1
- package/src/components/color-picker/color-picker-accessibility.ts +12 -0
- package/src/components/color-picker/index.tsx +27 -8
- package/src/components/date-time-picker/index.tsx +11 -3
- package/src/components/drawer/index.tsx +42 -14
- package/src/components/input/index.tsx +6 -2
- package/src/components/input/input-content-padding.ts +3 -0
- package/src/components/input-group/index.tsx +38 -10
- package/src/components/input-group/input-group-measurements.ts +98 -0
- package/src/components/otp-input/index.tsx +3 -1
- package/src/components/otp-input/otp-input-state.ts +7 -0
- package/src/components/planner/index.tsx +24 -6
- package/src/components/planner/planner-today.ts +5 -0
- package/src/components/plot/index.tsx +16 -12
- package/src/components/plot/plot-series-registry.ts +41 -0
- package/src/components/radio-group/index.tsx +4 -1
- package/src/components/radio-group/radio-group-value.ts +9 -0
- package/src/components/rating/index.tsx +11 -4
- package/src/components/rating/rating-inputs.ts +19 -0
- package/src/components/search-bar/index.tsx +12 -7
- package/src/components/search-bar/search-bar-debounce.ts +35 -0
- package/src/components/select/index.tsx +10 -1
- package/src/components/select/native-select-contract.ts +12 -0
- package/src/components/split-view/index.tsx +565 -0
- package/src/components/split-view/split-view-math.ts +146 -0
- package/src/components/splitter/index.tsx +589 -0
- package/src/components/splitter/splitter-math.ts +244 -0
- package/src/components/steps/index.tsx +35 -9
- package/src/components/steps/steps-trigger.ts +6 -0
- package/src/components/tag-input/index.tsx +13 -11
- package/src/components/tag-input/tag-input-state.ts +18 -0
- package/src/components/textarea/index.tsx +8 -4
- package/src/components/textarea/textarea-disabled.ts +6 -0
- package/src/components/timeline/index.tsx +89 -26
- package/src/components/timeline/timeline-geometry.ts +9 -0
- package/src/components/toast/index.tsx +26 -1
- package/src/components/toast/toast-store.ts +43 -8
- package/src/components/tour/index.tsx +11 -3
- package/src/components/tour/tour-card-measurement.ts +29 -0
- package/src/index.ts +17 -0
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SplitView — two stacked panes whose seam settles on one of a few named
|
|
3
|
+
* heights rather than wherever the finger stopped.
|
|
4
|
+
*
|
|
5
|
+
* ```tsx
|
|
6
|
+
* <SplitView className="h-96" snapPoints={[0.25, 0.6]} minHeight={80}>
|
|
7
|
+
* <SplitView.Top>{map}</SplitView.Top>
|
|
8
|
+
* <SplitView.DragArea>
|
|
9
|
+
* <SplitView.Handle />
|
|
10
|
+
* </SplitView.DragArea>
|
|
11
|
+
* <SplitView.Bottom>{list}</SplitView.Bottom>
|
|
12
|
+
* </SplitView>
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* The snapping is the difference between this and a free-resize split. A layout
|
|
16
|
+
* with a few right answers — a map over a list, a preview over an editor — is
|
|
17
|
+
* better served by a control that lands on one of them than by one that lets
|
|
18
|
+
* the reader stop three points short of it and live with the result.
|
|
19
|
+
*
|
|
20
|
+
* Snap points are ratios of the room the two panes share, so they mean the same
|
|
21
|
+
* thing on any screen and a rotation costs no re-measuring. That room is the
|
|
22
|
+
* container minus the drag area, which takes real layout height: half means
|
|
23
|
+
* half of what is actually divisible rather than half of a number the seam then
|
|
24
|
+
* eats into.
|
|
25
|
+
*
|
|
26
|
+
* The split view has no height of its own — give it one, or put it in something
|
|
27
|
+
* that has one, or it collapses and takes its panes with it.
|
|
28
|
+
*
|
|
29
|
+
* Dragging runs on the UI thread. `onSnap` fires once the pane has settled
|
|
30
|
+
* rather than on every frame, because a layout that round-trips through React
|
|
31
|
+
* sixty times a second is the one thing that makes this feel slow.
|
|
32
|
+
*/
|
|
33
|
+
import {
|
|
34
|
+
createContext,
|
|
35
|
+
useCallback,
|
|
36
|
+
useContext,
|
|
37
|
+
useEffect,
|
|
38
|
+
useMemo,
|
|
39
|
+
useRef,
|
|
40
|
+
useState,
|
|
41
|
+
type ReactNode,
|
|
42
|
+
} from 'react';
|
|
43
|
+
import { View, type LayoutChangeEvent, type ViewProps } from 'react-native';
|
|
44
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
45
|
+
import Animated, {
|
|
46
|
+
runOnJS,
|
|
47
|
+
useAnimatedStyle,
|
|
48
|
+
useReducedMotion,
|
|
49
|
+
useSharedValue,
|
|
50
|
+
withSpring,
|
|
51
|
+
type SharedValue,
|
|
52
|
+
} from 'react-native-reanimated';
|
|
53
|
+
import { tv } from 'tailwind-variants';
|
|
54
|
+
import { selectionTick } from '../../utils/haptics';
|
|
55
|
+
import {
|
|
56
|
+
DEFAULT_MIN_HEIGHT,
|
|
57
|
+
clamp,
|
|
58
|
+
nearestSnapIndex,
|
|
59
|
+
normalizeSnapIndex,
|
|
60
|
+
resolveLength,
|
|
61
|
+
resolveSnapPoints,
|
|
62
|
+
} from './split-view-math';
|
|
63
|
+
|
|
64
|
+
/** Settles the pane onto a snap point without overshooting past its limits. */
|
|
65
|
+
const SPRING = {
|
|
66
|
+
damping: 25,
|
|
67
|
+
stiffness: 300,
|
|
68
|
+
mass: 0.8,
|
|
69
|
+
overshootClamping: false,
|
|
70
|
+
restDisplacementThreshold: 0.01,
|
|
71
|
+
restSpeedThreshold: 0.01,
|
|
72
|
+
} as const;
|
|
73
|
+
|
|
74
|
+
/** How much the grip grows while it is being dragged. */
|
|
75
|
+
const GRIP_SPRING = { damping: 18, stiffness: 300, mass: 0.8 } as const;
|
|
76
|
+
|
|
77
|
+
const splitViewVariants = tv({
|
|
78
|
+
slots: {
|
|
79
|
+
root: 'w-full flex-col overflow-hidden',
|
|
80
|
+
top: 'w-full overflow-hidden',
|
|
81
|
+
bottom: 'w-full flex-1 overflow-hidden',
|
|
82
|
+
dragArea: 'w-full items-center justify-center py-2',
|
|
83
|
+
handle: 'h-1 w-10 rounded-full bg-muted-foreground/30',
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
interface SplitViewContextValue {
|
|
88
|
+
/** The top pane's height in points. Written by drags, read by both panes. */
|
|
89
|
+
topHeight: SharedValue<number>;
|
|
90
|
+
/** True while a finger is on the seam. */
|
|
91
|
+
dragging: SharedValue<boolean>;
|
|
92
|
+
/** Snap heights in points, in order. Empty until the container is measured. */
|
|
93
|
+
points: number[];
|
|
94
|
+
minPx: number;
|
|
95
|
+
maxPx: number;
|
|
96
|
+
room: number;
|
|
97
|
+
measured: boolean;
|
|
98
|
+
disabled: boolean;
|
|
99
|
+
snapIndex: number;
|
|
100
|
+
animate: boolean;
|
|
101
|
+
snapTo: (index: number) => void;
|
|
102
|
+
measureDragArea: (height: number) => void;
|
|
103
|
+
dragAreaHeight: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const SplitViewContext = createContext<SplitViewContextValue | null>(null);
|
|
107
|
+
|
|
108
|
+
function useSplitViewContext(component: string): SplitViewContextValue {
|
|
109
|
+
const context = useContext(SplitViewContext);
|
|
110
|
+
if (!context) throw new Error(`${component} must be used within a <SplitView>`);
|
|
111
|
+
return context;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Reads the live layout from inside a split view, and moves the seam.
|
|
116
|
+
*
|
|
117
|
+
* `topHeight` is a shared value on the UI thread — read it in a worklet, not in
|
|
118
|
+
* render, where it is only ever the number the last commit happened to see.
|
|
119
|
+
*/
|
|
120
|
+
export function useSplitView() {
|
|
121
|
+
return useSplitViewContext('useSplitView');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface SplitViewProps extends ViewProps {
|
|
125
|
+
className?: string;
|
|
126
|
+
/**
|
|
127
|
+
* Heights the seam settles on. A number at or below `1` is a fraction of the
|
|
128
|
+
* room the panes share; anything larger is points. Defaults to
|
|
129
|
+
* `[0.2, 0.5, 0.8]`.
|
|
130
|
+
*/
|
|
131
|
+
snapPoints?: readonly number[];
|
|
132
|
+
/** Smallest the top pane may get, as a fraction or in points. Defaults to `100`. */
|
|
133
|
+
minHeight?: number;
|
|
134
|
+
/**
|
|
135
|
+
* Largest the top pane may get, as a fraction or in points. A negative number
|
|
136
|
+
* is measured back from the bottom — `-80` leaves eighty points for the other
|
|
137
|
+
* pane. Defaults to all the room there is.
|
|
138
|
+
*/
|
|
139
|
+
maxHeight?: number;
|
|
140
|
+
/** Which snap point the seam starts at when uncontrolled. Defaults to `1`. */
|
|
141
|
+
defaultSnapIndex?: number;
|
|
142
|
+
/** Controlled snap index. Pair it with `onSnapIndexChange`. */
|
|
143
|
+
snapIndex?: number;
|
|
144
|
+
/** Called with the index the seam settled on. */
|
|
145
|
+
onSnapIndexChange?: (index: number) => void;
|
|
146
|
+
/** Called once the pane has settled, with the index and its height in points. */
|
|
147
|
+
onSnap?: (index: number, topHeight: number) => void;
|
|
148
|
+
/** Freezes the seam. The panes keep the heights they have. */
|
|
149
|
+
disabled?: boolean;
|
|
150
|
+
/** Springs to the starting snap point on mount instead of opening at it. */
|
|
151
|
+
animateOnMount?: boolean;
|
|
152
|
+
children?: ReactNode;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function SplitViewRoot({
|
|
156
|
+
className,
|
|
157
|
+
snapPoints,
|
|
158
|
+
minHeight,
|
|
159
|
+
maxHeight,
|
|
160
|
+
defaultSnapIndex = 1,
|
|
161
|
+
snapIndex: snapIndexProp,
|
|
162
|
+
onSnapIndexChange,
|
|
163
|
+
onSnap,
|
|
164
|
+
disabled = false,
|
|
165
|
+
animateOnMount = false,
|
|
166
|
+
children,
|
|
167
|
+
onLayout,
|
|
168
|
+
...props
|
|
169
|
+
}: SplitViewProps) {
|
|
170
|
+
const { root } = splitViewVariants();
|
|
171
|
+
const reducedMotion = useReducedMotion();
|
|
172
|
+
const animate = !reducedMotion;
|
|
173
|
+
|
|
174
|
+
const [container, setContainer] = useState(0);
|
|
175
|
+
const [dragAreaHeight, setDragAreaHeight] = useState(0);
|
|
176
|
+
|
|
177
|
+
const topHeight = useSharedValue(0);
|
|
178
|
+
const dragging = useSharedValue(false);
|
|
179
|
+
const settled = useRef(false);
|
|
180
|
+
|
|
181
|
+
const room = Math.max(container - dragAreaHeight, 0);
|
|
182
|
+
const minPx = clamp(resolveLength(minHeight, room, DEFAULT_MIN_HEIGHT), 0, room);
|
|
183
|
+
const maxPx = clamp(resolveLength(maxHeight, room, room), minPx, room);
|
|
184
|
+
|
|
185
|
+
const pointsKey = JSON.stringify([snapPoints ?? null, room, minPx, maxPx]);
|
|
186
|
+
const points = useMemo(
|
|
187
|
+
() => (room > 0 ? resolveSnapPoints(snapPoints, room, minPx, maxPx) : []),
|
|
188
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
189
|
+
[pointsKey]
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const controlled = snapIndexProp !== undefined;
|
|
193
|
+
const [internalIndex, setInternalIndex] = useState(() =>
|
|
194
|
+
normalizeSnapIndex(defaultSnapIndex, 1)
|
|
195
|
+
);
|
|
196
|
+
const snapIndex = normalizeSnapIndex(
|
|
197
|
+
controlled ? snapIndexProp : internalIndex,
|
|
198
|
+
points.length || 1
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const onSnapRef = useRef(onSnap);
|
|
202
|
+
onSnapRef.current = onSnap;
|
|
203
|
+
|
|
204
|
+
/** Reports a settled pane once, from the spring's own completion. */
|
|
205
|
+
const report = useCallback((index: number, height: number) => {
|
|
206
|
+
onSnapRef.current?.(index, height);
|
|
207
|
+
}, []);
|
|
208
|
+
|
|
209
|
+
/*
|
|
210
|
+
* Moving the seam is one function whether a drag, a press or a screen reader
|
|
211
|
+
* asked for it — the alternative is three places that each have to remember
|
|
212
|
+
* to spring, to report, and to leave a controlled caller's index alone.
|
|
213
|
+
*/
|
|
214
|
+
const moveTo = useCallback(
|
|
215
|
+
(index: number, options?: { animated?: boolean }) => {
|
|
216
|
+
const target = points[index];
|
|
217
|
+
if (target === undefined) return;
|
|
218
|
+
|
|
219
|
+
if (!controlled) setInternalIndex(index);
|
|
220
|
+
onSnapIndexChange?.(index);
|
|
221
|
+
|
|
222
|
+
const finish = () => report(index, target);
|
|
223
|
+
if (options?.animated === false || !animate) {
|
|
224
|
+
topHeight.value = target;
|
|
225
|
+
finish();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
topHeight.value = withSpring(target, SPRING, (finished) => {
|
|
229
|
+
'worklet';
|
|
230
|
+
if (finished) runOnJS(finish)();
|
|
231
|
+
});
|
|
232
|
+
},
|
|
233
|
+
[animate, controlled, onSnapIndexChange, points, report, topHeight]
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const snapTo = useCallback((index: number) => moveTo(index), [moveTo]);
|
|
237
|
+
|
|
238
|
+
/*
|
|
239
|
+
* The first frame with a measurement is the only one that may place the pane
|
|
240
|
+
* without animating: there is nothing on screen yet to animate from, and a
|
|
241
|
+
* spring out of zero reads as the layout arriving broken and correcting
|
|
242
|
+
* itself. `animateOnMount` is for when that arrival is the point.
|
|
243
|
+
*/
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
const target = points[snapIndex];
|
|
246
|
+
if (target === undefined) return;
|
|
247
|
+
|
|
248
|
+
if (!settled.current) {
|
|
249
|
+
settled.current = true;
|
|
250
|
+
if (!animateOnMount) {
|
|
251
|
+
topHeight.value = target;
|
|
252
|
+
report(snapIndex, target);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (dragging.value) return;
|
|
258
|
+
if (!animate) {
|
|
259
|
+
topHeight.value = target;
|
|
260
|
+
report(snapIndex, target);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
topHeight.value = withSpring(target, SPRING);
|
|
264
|
+
// Only when the resolved layout moves — a re-render that changes nothing
|
|
265
|
+
// must not restart a spring the reader is watching.
|
|
266
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
267
|
+
}, [points, snapIndex, animate, animateOnMount]);
|
|
268
|
+
|
|
269
|
+
const handleLayout = useCallback(
|
|
270
|
+
(event: LayoutChangeEvent) => {
|
|
271
|
+
setContainer(event.nativeEvent.layout.height);
|
|
272
|
+
onLayout?.(event);
|
|
273
|
+
},
|
|
274
|
+
[onLayout]
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
const measureDragArea = useCallback((height: number) => {
|
|
278
|
+
setDragAreaHeight((current) => (Math.abs(current - height) < 0.5 ? current : height));
|
|
279
|
+
}, []);
|
|
280
|
+
|
|
281
|
+
const context = useMemo<SplitViewContextValue>(
|
|
282
|
+
() => ({
|
|
283
|
+
topHeight,
|
|
284
|
+
dragging,
|
|
285
|
+
points,
|
|
286
|
+
minPx,
|
|
287
|
+
maxPx,
|
|
288
|
+
room,
|
|
289
|
+
measured: room > 0,
|
|
290
|
+
disabled,
|
|
291
|
+
snapIndex,
|
|
292
|
+
animate,
|
|
293
|
+
snapTo,
|
|
294
|
+
measureDragArea,
|
|
295
|
+
dragAreaHeight,
|
|
296
|
+
}),
|
|
297
|
+
[
|
|
298
|
+
topHeight,
|
|
299
|
+
dragging,
|
|
300
|
+
points,
|
|
301
|
+
minPx,
|
|
302
|
+
maxPx,
|
|
303
|
+
room,
|
|
304
|
+
disabled,
|
|
305
|
+
snapIndex,
|
|
306
|
+
animate,
|
|
307
|
+
snapTo,
|
|
308
|
+
measureDragArea,
|
|
309
|
+
dragAreaHeight,
|
|
310
|
+
]
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
return (
|
|
314
|
+
<SplitViewContext.Provider value={context}>
|
|
315
|
+
<View className={root({ className })} onLayout={handleLayout} {...props}>
|
|
316
|
+
{children}
|
|
317
|
+
</View>
|
|
318
|
+
</SplitViewContext.Provider>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export interface SplitViewPaneProps extends ViewProps {
|
|
323
|
+
className?: string;
|
|
324
|
+
children?: ReactNode;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The upper pane.
|
|
329
|
+
*
|
|
330
|
+
* It clips what is inside it, so a pane dragged short hides its content rather
|
|
331
|
+
* than pushing it through the seam. Content that outgrows it is the caller's to
|
|
332
|
+
* scroll — put a `ScrollView` in here and it behaves like any other scroller in
|
|
333
|
+
* a box whose height changes.
|
|
334
|
+
*/
|
|
335
|
+
function SplitViewTop({ className, children, style, ...props }: SplitViewPaneProps) {
|
|
336
|
+
const context = useSplitViewContext('SplitView.Top');
|
|
337
|
+
const { top } = splitViewVariants();
|
|
338
|
+
|
|
339
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
340
|
+
height: context.topHeight.value,
|
|
341
|
+
}));
|
|
342
|
+
|
|
343
|
+
/*
|
|
344
|
+
* Before the container is measured there is no height to give, and a pane
|
|
345
|
+
* sized from a container of zero is a pane nobody can see. A flex basis of
|
|
346
|
+
* zero and a grow of one carries the first frame instead, so the pane is
|
|
347
|
+
* already the shape it will be when the measurement lands.
|
|
348
|
+
*/
|
|
349
|
+
const unmeasured = { flexGrow: 1, flexBasis: 0, flexShrink: 1 };
|
|
350
|
+
|
|
351
|
+
return (
|
|
352
|
+
<Animated.View
|
|
353
|
+
className={top({ className })}
|
|
354
|
+
style={[context.measured ? null : unmeasured, style, animatedStyle]}
|
|
355
|
+
{...props}
|
|
356
|
+
>
|
|
357
|
+
{children}
|
|
358
|
+
</Animated.View>
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The lower pane. It takes exactly the room the upper one gave up, with no
|
|
364
|
+
* second measurement — which is what keeps the two adding to the container on
|
|
365
|
+
* every frame of a drag rather than only at rest.
|
|
366
|
+
*/
|
|
367
|
+
function SplitViewBottom({ className, children, ...props }: SplitViewPaneProps) {
|
|
368
|
+
useSplitViewContext('SplitView.Bottom');
|
|
369
|
+
const { bottom } = splitViewVariants();
|
|
370
|
+
|
|
371
|
+
return (
|
|
372
|
+
<View className={bottom({ className })} {...props}>
|
|
373
|
+
{children}
|
|
374
|
+
</View>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export interface SplitViewDragAreaProps extends ViewProps {
|
|
379
|
+
className?: string;
|
|
380
|
+
/** What a screen reader calls the seam. Defaults to "Resize panes". */
|
|
381
|
+
accessibilityLabel?: string;
|
|
382
|
+
children?: ReactNode;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The seam, and the target for the drag.
|
|
387
|
+
*
|
|
388
|
+
* It takes real layout height rather than floating over the panes, because that
|
|
389
|
+
* height is what the snap points are fractions of: a finger-sized target that
|
|
390
|
+
* did not take room would make `0.5` mean half of a number the seam then ate
|
|
391
|
+
* into. Give it padding to make the target larger — the room it takes is
|
|
392
|
+
* measured, so the arithmetic follows.
|
|
393
|
+
*/
|
|
394
|
+
function SplitViewDragArea({
|
|
395
|
+
className,
|
|
396
|
+
accessibilityLabel = 'Resize panes',
|
|
397
|
+
children,
|
|
398
|
+
onLayout,
|
|
399
|
+
...props
|
|
400
|
+
}: SplitViewDragAreaProps) {
|
|
401
|
+
const context = useSplitViewContext('SplitView.DragArea');
|
|
402
|
+
const { dragArea } = splitViewVariants();
|
|
403
|
+
const { topHeight, dragging, points, minPx, maxPx, room, disabled, snapTo } = context;
|
|
404
|
+
|
|
405
|
+
const start = useSharedValue(0);
|
|
406
|
+
const landed = useSharedValue(-1);
|
|
407
|
+
|
|
408
|
+
const settle = useCallback((index: number) => snapTo(index), [snapTo]);
|
|
409
|
+
|
|
410
|
+
const pan = useMemo(
|
|
411
|
+
() =>
|
|
412
|
+
Gesture.Pan()
|
|
413
|
+
.enabled(!disabled && points.length > 0)
|
|
414
|
+
// The seam only answers to the axis it moves on, so a split view inside
|
|
415
|
+
// a scroller leaves the scroll alone.
|
|
416
|
+
.activeOffsetY([-8, 8])
|
|
417
|
+
.failOffsetX([-16, 16])
|
|
418
|
+
.onBegin(() => {
|
|
419
|
+
start.value = topHeight.value;
|
|
420
|
+
dragging.value = true;
|
|
421
|
+
landed.value = -1;
|
|
422
|
+
})
|
|
423
|
+
.onUpdate((event) => {
|
|
424
|
+
if (room <= 0) return;
|
|
425
|
+
topHeight.value = clamp(start.value + event.translationY, minPx, maxPx);
|
|
426
|
+
|
|
427
|
+
// The moment worth feeling: passing the point the pane would settle
|
|
428
|
+
// at if the finger let go now. It is the only feedback that says a
|
|
429
|
+
// drag has a destination rather than a position.
|
|
430
|
+
const next = nearestSnapIndex(topHeight.value, points, 0, room);
|
|
431
|
+
if (next !== landed.value) {
|
|
432
|
+
landed.value = next;
|
|
433
|
+
runOnJS(selectionTick)();
|
|
434
|
+
}
|
|
435
|
+
})
|
|
436
|
+
.onFinalize((event) => {
|
|
437
|
+
dragging.value = false;
|
|
438
|
+
if (room <= 0) return;
|
|
439
|
+
const index = nearestSnapIndex(
|
|
440
|
+
topHeight.value,
|
|
441
|
+
points,
|
|
442
|
+
event.velocityY ?? 0,
|
|
443
|
+
room
|
|
444
|
+
);
|
|
445
|
+
runOnJS(settle)(index);
|
|
446
|
+
}),
|
|
447
|
+
[disabled, dragging, landed, maxPx, minPx, points, room, settle, start, topHeight]
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
const handleLayout = useCallback(
|
|
451
|
+
(event: LayoutChangeEvent) => {
|
|
452
|
+
context.measureDragArea(event.nativeEvent.layout.height);
|
|
453
|
+
onLayout?.(event);
|
|
454
|
+
},
|
|
455
|
+
[context, onLayout]
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
/*
|
|
459
|
+
* A step moves to the next snap point rather than by a distance, because the
|
|
460
|
+
* snap points are the positions this control has. Announcing a percentage
|
|
461
|
+
* somebody cannot stop at would be describing a different control.
|
|
462
|
+
*/
|
|
463
|
+
const step = useCallback(
|
|
464
|
+
(direction: 1 | -1) => {
|
|
465
|
+
const next = clamp(context.snapIndex + direction, 0, points.length - 1);
|
|
466
|
+
if (next !== context.snapIndex) snapTo(next);
|
|
467
|
+
},
|
|
468
|
+
[context.snapIndex, points.length, snapTo]
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
return (
|
|
472
|
+
<GestureDetector gesture={pan}>
|
|
473
|
+
<View
|
|
474
|
+
accessible
|
|
475
|
+
accessibilityRole="adjustable"
|
|
476
|
+
accessibilityLabel={accessibilityLabel}
|
|
477
|
+
accessibilityState={{ disabled }}
|
|
478
|
+
accessibilityValue={{
|
|
479
|
+
min: 0,
|
|
480
|
+
max: Math.max(points.length - 1, 0),
|
|
481
|
+
now: context.snapIndex,
|
|
482
|
+
}}
|
|
483
|
+
accessibilityActions={
|
|
484
|
+
disabled ? undefined : [{ name: 'increment' }, { name: 'decrement' }]
|
|
485
|
+
}
|
|
486
|
+
onAccessibilityAction={
|
|
487
|
+
disabled
|
|
488
|
+
? undefined
|
|
489
|
+
: (event) => {
|
|
490
|
+
if (event.nativeEvent.actionName === 'increment') step(1);
|
|
491
|
+
else if (event.nativeEvent.actionName === 'decrement') step(-1);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
className={dragArea({ className })}
|
|
495
|
+
onLayout={handleLayout}
|
|
496
|
+
{...props}
|
|
497
|
+
>
|
|
498
|
+
{children}
|
|
499
|
+
</View>
|
|
500
|
+
</GestureDetector>
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export interface SplitViewHandleProps extends ViewProps {
|
|
505
|
+
className?: string;
|
|
506
|
+
children?: ReactNode;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* The grip inside the drag area. It grows a little while the seam is moving,
|
|
511
|
+
* which is the only thing on screen saying the gesture was received before the
|
|
512
|
+
* panes have moved far enough to say it themselves.
|
|
513
|
+
*/
|
|
514
|
+
function SplitViewHandle({ className, children, style, ...props }: SplitViewHandleProps) {
|
|
515
|
+
const context = useSplitViewContext('SplitView.Handle');
|
|
516
|
+
const { handle } = splitViewVariants();
|
|
517
|
+
const { animate } = context;
|
|
518
|
+
|
|
519
|
+
const animatedStyle = useAnimatedStyle(() => {
|
|
520
|
+
if (!animate) return { transform: [{ scaleX: 1 }] };
|
|
521
|
+
return {
|
|
522
|
+
transform: [
|
|
523
|
+
{ scaleX: withSpring(context.dragging.value ? 1.15 : 1, GRIP_SPRING) },
|
|
524
|
+
],
|
|
525
|
+
};
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
if (children) {
|
|
529
|
+
return (
|
|
530
|
+
<Animated.View style={[style, animatedStyle]} {...props}>
|
|
531
|
+
{children}
|
|
532
|
+
</Animated.View>
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return (
|
|
537
|
+
<Animated.View
|
|
538
|
+
className={handle({ className })}
|
|
539
|
+
style={[style, animatedStyle]}
|
|
540
|
+
{...props}
|
|
541
|
+
/>
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
SplitViewRoot.displayName = 'SplitView';
|
|
546
|
+
SplitViewTop.displayName = 'SplitView.Top';
|
|
547
|
+
SplitViewBottom.displayName = 'SplitView.Bottom';
|
|
548
|
+
SplitViewDragArea.displayName = 'SplitView.DragArea';
|
|
549
|
+
SplitViewHandle.displayName = 'SplitView.Handle';
|
|
550
|
+
|
|
551
|
+
export const SplitView = Object.assign(SplitViewRoot, {
|
|
552
|
+
Top: SplitViewTop,
|
|
553
|
+
Bottom: SplitViewBottom,
|
|
554
|
+
DragArea: SplitViewDragArea,
|
|
555
|
+
Handle: SplitViewHandle,
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
export {
|
|
559
|
+
DEFAULT_MIN_HEIGHT,
|
|
560
|
+
DEFAULT_SNAP_POINTS,
|
|
561
|
+
nearestSnapIndex,
|
|
562
|
+
normalizeSnapIndex,
|
|
563
|
+
resolveLength,
|
|
564
|
+
resolveSnapPoints,
|
|
565
|
+
} from './split-view-math';
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The arithmetic a split view runs while its seam is under a finger.
|
|
3
|
+
*
|
|
4
|
+
* A split view differs from a free-resize splitter in one way that decides the
|
|
5
|
+
* whole of this file: it does not stop wherever the finger stopped. It settles
|
|
6
|
+
* on one of a few heights the caller named, so the numbers a drag produces are
|
|
7
|
+
* only ever on the way to one of those — and picking which one, from a position
|
|
8
|
+
* and the speed it arrived at, is the part with the edge cases.
|
|
9
|
+
*
|
|
10
|
+
* It lives apart from the component because it runs on the UI thread, where a
|
|
11
|
+
* wrong answer is a pane that sticks rather than an exception anybody sees.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Smallest top pane, in points, when the caller names none. */
|
|
15
|
+
export const DEFAULT_MIN_HEIGHT = 100;
|
|
16
|
+
|
|
17
|
+
/** Where the panes sit when the caller names no snap points, as ratios. */
|
|
18
|
+
export const DEFAULT_SNAP_POINTS = [0.2, 0.5, 0.8] as const;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How much of a flick counts toward the next snap point, in points per point of
|
|
22
|
+
* velocity. A release is at the height the finger left *plus* where the throw
|
|
23
|
+
* was going, so a fast flick carries past a midpoint the finger never crossed —
|
|
24
|
+
* which is the difference between a control that follows a gesture and one that
|
|
25
|
+
* merely records where it ended.
|
|
26
|
+
*/
|
|
27
|
+
const VELOCITY_REACH = 0.12;
|
|
28
|
+
|
|
29
|
+
/** How far a flick may reach on its own, as a fraction of the room available. */
|
|
30
|
+
const VELOCITY_CEILING = 0.4;
|
|
31
|
+
|
|
32
|
+
/** The nearest entry to a height, by index. */
|
|
33
|
+
function closestIndex(height: number, points: readonly number[]): number {
|
|
34
|
+
'worklet';
|
|
35
|
+
let best = 0;
|
|
36
|
+
let bestDistance = Math.abs(points[0]! - height);
|
|
37
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
38
|
+
const distance = Math.abs(points[index]! - height);
|
|
39
|
+
if (distance < bestDistance) {
|
|
40
|
+
best = index;
|
|
41
|
+
bestDistance = distance;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return best;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function clamp(value: number, low: number, high: number): number {
|
|
48
|
+
'worklet';
|
|
49
|
+
return Math.min(Math.max(value, low), high);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One length, in points, from the two ways of writing it.
|
|
54
|
+
*
|
|
55
|
+
* A number at or below 1 is a fraction of the room the panes share, which is
|
|
56
|
+
* what survives a rotation: half is half at any screen size. Anything larger is
|
|
57
|
+
* already points. A negative number is measured back from the far edge, for the
|
|
58
|
+
* case that is always written as a subtraction — "everything but the last 80".
|
|
59
|
+
*/
|
|
60
|
+
export function resolveLength(
|
|
61
|
+
value: number | undefined,
|
|
62
|
+
room: number,
|
|
63
|
+
fallback: number
|
|
64
|
+
): number {
|
|
65
|
+
'worklet';
|
|
66
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
|
|
67
|
+
if (value < 0) return room + value;
|
|
68
|
+
return value <= 1 ? value * room : value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The heights a seam may settle at, in points, in order and without repeats.
|
|
73
|
+
*
|
|
74
|
+
* Every point is clamped into the range the panes are allowed, which is what
|
|
75
|
+
* lets a caller name ratios without also checking them against a minimum they
|
|
76
|
+
* did not choose. Two points that clamp onto each other become one, because a
|
|
77
|
+
* list with the same height twice makes a flick land on a snap that looks like
|
|
78
|
+
* it did nothing.
|
|
79
|
+
*/
|
|
80
|
+
export function resolveSnapPoints(
|
|
81
|
+
points: readonly number[] | undefined,
|
|
82
|
+
room: number,
|
|
83
|
+
minPx: number,
|
|
84
|
+
maxPx: number
|
|
85
|
+
): number[] {
|
|
86
|
+
'worklet';
|
|
87
|
+
const source = points && points.length > 0 ? points : DEFAULT_SNAP_POINTS;
|
|
88
|
+
const resolved: number[] = [];
|
|
89
|
+
|
|
90
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
91
|
+
const raw = source[index]!;
|
|
92
|
+
if (!Number.isFinite(raw)) continue;
|
|
93
|
+
const height = clamp(resolveLength(raw, room, minPx), minPx, maxPx);
|
|
94
|
+
let seen = false;
|
|
95
|
+
for (let other = 0; other < resolved.length; other += 1) {
|
|
96
|
+
if (Math.abs(resolved[other]! - height) < 0.5) seen = true;
|
|
97
|
+
}
|
|
98
|
+
if (!seen) resolved.push(height);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (resolved.length === 0) resolved.push(clamp(minPx, minPx, maxPx));
|
|
102
|
+
resolved.sort((a, b) => a - b);
|
|
103
|
+
return resolved;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Which snap point a release belongs to.
|
|
108
|
+
*
|
|
109
|
+
* The height it is measured against is where the pane is plus where the throw
|
|
110
|
+
* was going, so a fast flick carries past a midpoint the finger never crossed —
|
|
111
|
+
* the difference between a control that follows a gesture and one that merely
|
|
112
|
+
* records where it ended.
|
|
113
|
+
*
|
|
114
|
+
* The result is then held to one point either side of where the pane actually
|
|
115
|
+
* is. Capping the distance is not enough on its own: the same throw skips two
|
|
116
|
+
* points in a list packed close together and none in a list spread wide, so the
|
|
117
|
+
* guarantee has to be made in points rather than in pixels. A release that
|
|
118
|
+
* lands two snaps from where it was aimed reads as the control guessing.
|
|
119
|
+
*/
|
|
120
|
+
export function nearestSnapIndex(
|
|
121
|
+
height: number,
|
|
122
|
+
points: readonly number[],
|
|
123
|
+
velocity: number,
|
|
124
|
+
room: number
|
|
125
|
+
): number {
|
|
126
|
+
'worklet';
|
|
127
|
+
if (points.length === 0) return 0;
|
|
128
|
+
|
|
129
|
+
const reach = clamp(
|
|
130
|
+
velocity * VELOCITY_REACH,
|
|
131
|
+
-room * VELOCITY_CEILING,
|
|
132
|
+
room * VELOCITY_CEILING
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const resting = closestIndex(height, points);
|
|
136
|
+
const thrown = closestIndex(height + reach, points);
|
|
137
|
+
return clamp(thrown, resting - 1, resting + 1);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The index a caller asked to start at, kept inside the list that exists. */
|
|
141
|
+
export function normalizeSnapIndex(index: number | undefined, length: number): number {
|
|
142
|
+
'worklet';
|
|
143
|
+
if (length === 0) return 0;
|
|
144
|
+
if (typeof index !== 'number' || !Number.isFinite(index)) return 0;
|
|
145
|
+
return clamp(Math.round(index), 0, length - 1);
|
|
146
|
+
}
|