panelui-native 0.59.0 → 0.61.1
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/area-chart/index.js +86 -1
- package/lib/module/components/area-chart/index.js.map +1 -1
- package/lib/module/components/bar-chart/index.js +112 -2
- package/lib/module/components/bar-chart/index.js.map +1 -1
- package/lib/module/components/fab/index.js +63 -30
- package/lib/module/components/fab/index.js.map +1 -1
- package/lib/module/components/selection-mode/index.js +805 -0
- package/lib/module/components/selection-mode/index.js.map +1 -0
- package/lib/module/components/tabs/index.js +384 -341
- package/lib/module/components/tabs/index.js.map +1 -1
- package/lib/module/components/treemap-chart/index.js +957 -0
- package/lib/module/components/treemap-chart/index.js.map +1 -0
- package/lib/module/index.js +2 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/utils/chart.js +131 -4
- package/lib/module/utils/chart.js.map +1 -1
- package/lib/typescript/src/components/area-chart/index.d.ts +28 -1
- package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/bar-chart/index.d.ts +33 -3
- package/lib/typescript/src/components/bar-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/fab/index.d.ts +11 -5
- package/lib/typescript/src/components/fab/index.d.ts.map +1 -1
- package/lib/typescript/src/components/selection-mode/index.d.ts +498 -0
- package/lib/typescript/src/components/selection-mode/index.d.ts.map +1 -0
- package/lib/typescript/src/components/tabs/index.d.ts +64 -46
- package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
- package/lib/typescript/src/components/treemap-chart/index.d.ts +317 -0
- package/lib/typescript/src/components/treemap-chart/index.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/lib/typescript/src/utils/chart.d.ts +26 -1
- package/lib/typescript/src/utils/chart.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/area-chart/index.tsx +86 -1
- package/src/components/bar-chart/index.tsx +115 -4
- package/src/components/fab/index.tsx +66 -31
- package/src/components/selection-mode/index.tsx +1027 -0
- package/src/components/tabs/index.tsx +452 -413
- package/src/components/treemap-chart/index.tsx +1175 -0
- package/src/index.ts +30 -0
- package/src/utils/chart.ts +158 -4
|
@@ -16,27 +16,38 @@
|
|
|
16
16
|
* </Tabs>
|
|
17
17
|
* ```
|
|
18
18
|
*
|
|
19
|
-
* **Swiping
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
19
|
+
* **Swiping puts the panels in a row.** With `swipeable`, the panels are laid
|
|
20
|
+
* out side by side in a strip as wide as all of them, inside a viewport that
|
|
21
|
+
* shows one at a time, and moving between tabs is that strip translating. The
|
|
22
|
+
* neighbours are therefore already built and already the right size before the
|
|
23
|
+
* finger arrives at them, which is the whole point: a panel that has to be
|
|
24
|
+
* mounted and measured at the moment it becomes visible is a panel that stalls
|
|
25
|
+
* there, and it stalls for exactly as long as it takes to build.
|
|
26
|
+
*
|
|
27
|
+
* One shared value carries the strip's position, in panels rather than points,
|
|
28
|
+
* and it is the only thing that decides where the strip is. A press springs it,
|
|
29
|
+
* a drag sets it, and neither waits for React: the value the tab set reports is
|
|
30
|
+
* updated alongside the movement, not ahead of it.
|
|
31
|
+
*
|
|
32
|
+
* A swipeable tab set therefore needs a height to fill, the same as any pager.
|
|
33
|
+
* Give it one — `flex-1` on the tab set, or a fixed height — or the strip has
|
|
34
|
+
* nothing to lay its panels out in.
|
|
27
35
|
*/
|
|
28
36
|
import {
|
|
37
|
+
Children,
|
|
29
38
|
createContext,
|
|
39
|
+
Fragment,
|
|
40
|
+
isValidElement,
|
|
30
41
|
useCallback,
|
|
31
42
|
useContext,
|
|
32
43
|
useEffect,
|
|
33
44
|
useMemo,
|
|
34
45
|
useRef,
|
|
35
46
|
useState,
|
|
47
|
+
type ReactElement,
|
|
36
48
|
type ReactNode,
|
|
37
49
|
} from 'react';
|
|
38
50
|
import {
|
|
39
|
-
Platform,
|
|
40
51
|
Pressable,
|
|
41
52
|
ScrollView,
|
|
42
53
|
View,
|
|
@@ -55,7 +66,6 @@ import Animated, {
|
|
|
55
66
|
useSharedValue,
|
|
56
67
|
withSpring,
|
|
57
68
|
withTiming,
|
|
58
|
-
type SharedValue,
|
|
59
69
|
} from 'react-native-reanimated';
|
|
60
70
|
import { tv } from 'tailwind-variants';
|
|
61
71
|
import { useDirectionSign } from '../../hooks/use-direction';
|
|
@@ -73,50 +83,19 @@ const SPRING = { damping: 24, stiffness: 300, mass: 0.7 } as const;
|
|
|
73
83
|
const SWIPE_ACTIVATE_X = 12;
|
|
74
84
|
const SWIPE_FAIL_Y = 8;
|
|
75
85
|
|
|
76
|
-
/** A swipe past this share of
|
|
86
|
+
/** A swipe past this share of a panel's width changes tab on release. */
|
|
77
87
|
const SWIPE_DISTANCE_RATIO = 0.25;
|
|
78
|
-
/** …or past this speed, however short it was. */
|
|
88
|
+
/** …or past this speed, however short it was, in points per second. */
|
|
79
89
|
const SWIPE_VELOCITY = 500;
|
|
80
90
|
|
|
81
91
|
/**
|
|
82
|
-
* How much of the finger's travel the
|
|
83
|
-
* where there is
|
|
92
|
+
* How much of the finger's travel the strip follows at the ends of the row,
|
|
93
|
+
* where there is no further panel to bring on. Everywhere else it follows all
|
|
94
|
+
* of it.
|
|
84
95
|
*/
|
|
85
96
|
const SWIPE_RESISTANCE_AT_END = 0.16;
|
|
86
97
|
|
|
87
|
-
/**
|
|
88
|
-
* How far the panel fades as it is dragged, at a full panel's width.
|
|
89
|
-
*
|
|
90
|
-
* Tracking the finger one to one leaves the space behind the panel empty,
|
|
91
|
-
* because the panels are separate views and only the visible one is mounted.
|
|
92
|
-
* Dimming as it goes is what keeps that from reading as a hole punched in the
|
|
93
|
-
* card: a panel on its way out is on its way out, and the one arriving comes
|
|
94
|
-
* back up through the same fade. The two together read as a dissolve carried
|
|
95
|
-
* by the movement, rather than as a gap between two slides.
|
|
96
|
-
*
|
|
97
|
-
* **iOS only, and that is not a compromise.** A transform is a property of the
|
|
98
|
-
* layer the panel is drawn into, so moving it costs nothing per frame however
|
|
99
|
-
* much is inside it. An opacity that changes every frame is not: on Android the
|
|
100
|
-
* panel's view group has no offscreen buffer to fade, so the alpha is pushed
|
|
101
|
-
* down into its children and the whole visible subtree is re-drawn on every
|
|
102
|
-
* frame of the drag. Behind a list that is thirty rows of text and images,
|
|
103
|
-
* which is what a tab panel usually is, that is the difference between a swipe
|
|
104
|
-
* that tracks the finger and one that stutters. iOS fades the layer itself and
|
|
105
|
-
* is unaffected.
|
|
106
|
-
*/
|
|
107
|
-
const SWIPE_FADE = Platform.OS === 'ios' ? 0.75 : 0;
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* How far off its resting place a panel arriving by a *press* starts, as a
|
|
111
|
-
* share of the panel's width.
|
|
112
|
-
*
|
|
113
|
-
* A press has no finger travel to continue from, so it is given a throw of its
|
|
114
|
-
* own. A swipe does not use this: the panel it hands over to picks up exactly
|
|
115
|
-
* one width from wherever the outgoing one has travelled to — see `setValue`.
|
|
116
|
-
*/
|
|
117
|
-
const SWIPE_ENTER = 0.3;
|
|
118
|
-
|
|
119
|
-
/** The arriving panel is springier than the one being dragged back into place. */
|
|
98
|
+
/** The spring the strip settles on, whether it was thrown or pressed. */
|
|
120
99
|
const ENTER_SPRING = { damping: 22, stiffness: 240, mass: 0.6 } as const;
|
|
121
100
|
|
|
122
101
|
/** How far the label's reveal is from its own width, in points — the gap after the icon. */
|
|
@@ -143,13 +122,25 @@ export type TabsVariant = 'segmented' | 'underline' | 'pill' | 'expanding';
|
|
|
143
122
|
/**
|
|
144
123
|
* How much of an inactive panel survives a switch away from it.
|
|
145
124
|
*
|
|
146
|
-
* `false` unmounts it. `true` keeps it mounted
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
125
|
+
* `false` unmounts it. `true` keeps it mounted.
|
|
126
|
+
*
|
|
127
|
+
* `'measured'` meant "keep it mounted *and* laid out at a real size", which was
|
|
128
|
+
* a distinction only a tab set of separately hidden panels had to make. In a
|
|
129
|
+
* swipeable tab set every panel in the strip is laid out at a real size
|
|
130
|
+
* already, so it is the same as `true` there and is kept only so that passing
|
|
131
|
+
* it does not break.
|
|
132
|
+
*
|
|
133
|
+
* @see TabsProps.keepMounted
|
|
150
134
|
*/
|
|
151
135
|
export type TabsKeepMounted = boolean | 'measured';
|
|
152
136
|
|
|
137
|
+
/**
|
|
138
|
+
* `'disable-all'` turns off every animation in the tab set — the indicator, the
|
|
139
|
+
* strip, and an expanding tab's reveal — including the ones its parts run
|
|
140
|
+
* themselves.
|
|
141
|
+
*/
|
|
142
|
+
export type TabsAnimation = 'disable-all';
|
|
143
|
+
|
|
153
144
|
const tabsVariants = tv({
|
|
154
145
|
slots: {
|
|
155
146
|
list: 'flex-row',
|
|
@@ -228,48 +219,24 @@ interface TabLayout {
|
|
|
228
219
|
width: number;
|
|
229
220
|
}
|
|
230
221
|
|
|
231
|
-
/**
|
|
232
|
-
* What a swipe hands over to the tab it commits to: where the finger left the
|
|
233
|
-
* outgoing panel, and how fast it was still going. Absent when the tab was
|
|
234
|
-
* changed by pressing a trigger, which has neither.
|
|
235
|
-
*/
|
|
236
|
-
interface SwipeHandover {
|
|
237
|
-
/** Points per second at the moment of release, in the panel's own axis. */
|
|
238
|
-
velocity: number;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
222
|
interface TabsContextValue {
|
|
242
223
|
value: string;
|
|
243
|
-
setValue: (value: string
|
|
224
|
+
setValue: (value: string) => void;
|
|
244
225
|
registerLayout: (value: string, layout: TabLayout) => void;
|
|
245
226
|
layouts: Record<string, TabLayout>;
|
|
246
|
-
/**
|
|
247
|
-
* Every tab in the order it was declared, which is the order a swipe moves
|
|
248
|
-
* through them. Kept separately from `layouts` because that is a map keyed
|
|
249
|
-
* by value and its order is whatever the layout pass happened to produce —
|
|
250
|
-
* and because under RTL the leftmost trigger is the last one, so a position
|
|
251
|
-
* cannot stand in for a place in the sequence either.
|
|
252
|
-
*/
|
|
253
|
-
tabs: string[];
|
|
254
|
-
registerTab: (value: string) => void;
|
|
255
|
-
unregisterTab: (value: string) => void;
|
|
256
227
|
variant: TabsVariant;
|
|
257
228
|
scrollable: boolean;
|
|
258
229
|
setScrollable: (scrollable: boolean) => void;
|
|
259
230
|
keepMounted: TabsKeepMounted;
|
|
260
|
-
swipeable: boolean;
|
|
261
231
|
/**
|
|
262
|
-
*
|
|
232
|
+
* Whether the panels are in a strip rather than stacked in place.
|
|
263
233
|
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
* displacement survives it: the panel being let go and the panel arriving
|
|
268
|
-
* are one continuous movement, drawn by two views in turn.
|
|
234
|
+
* A panel in a strip is positioned by the strip and sized by the box it is
|
|
235
|
+
* put in, so it does no hiding of its own — which is the whole difference
|
|
236
|
+
* between the two modes as far as `Tabs.Content` is concerned.
|
|
269
237
|
*/
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
panelWidth: SharedValue<number>;
|
|
238
|
+
pager: boolean;
|
|
239
|
+
animationDisabled: boolean;
|
|
273
240
|
}
|
|
274
241
|
|
|
275
242
|
const TabsContext = createContext<TabsContextValue | null>(null);
|
|
@@ -299,34 +266,24 @@ export interface TabsProps extends ViewProps {
|
|
|
299
266
|
*/
|
|
300
267
|
variant?: TabsVariant;
|
|
301
268
|
/**
|
|
302
|
-
*
|
|
303
|
-
* scroll position or a half-filled form
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
* `true` hides a kept panel with `display: none`, which also takes it out of
|
|
307
|
-
* layout: it is mounted, but it has no size. That is what makes it cheap, and
|
|
308
|
-
* it is enough for a panel whose content sizes itself — a column of views, a
|
|
309
|
-
* form, a `ScrollView` of known children.
|
|
269
|
+
* Mount every panel up front instead of only the ones that have been
|
|
270
|
+
* reached, so a scroll position or a half-filled form is there from the
|
|
271
|
+
* start rather than from the first visit.
|
|
310
272
|
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
273
|
+
* Usually unnecessary. A panel that has been shown once stays mounted for
|
|
274
|
+
* the life of the tab set either way, and with `swipeable` the panels on
|
|
275
|
+
* each side of the active one are mounted before you get to them. What this
|
|
276
|
+
* adds is the panels you have *not* been near — the fourth tab of four —
|
|
277
|
+
* which costs their render at startup and buys nothing until somebody opens
|
|
278
|
+
* them.
|
|
316
279
|
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
* settles while it is still hidden, so becoming visible costs nothing.
|
|
321
|
-
*
|
|
322
|
-
* The trade is real and is why it is not the default — every kept panel lays
|
|
323
|
-
* out and draws, up front and on every size change, so a five-tab set builds
|
|
324
|
-
* five panels' worth of rows to show one. Reach for it when a panel is slow
|
|
325
|
-
* to appear and its content is virtualised; leave it at `true` otherwise.
|
|
280
|
+
* Turn it on when a panel has to be live while it is off screen: a form that
|
|
281
|
+
* must validate as another tab is edited, a chart that has to be ready to
|
|
282
|
+
* print, a subscription that must not miss a message.
|
|
326
283
|
*/
|
|
327
284
|
keepMounted?: TabsKeepMounted;
|
|
328
285
|
/**
|
|
329
|
-
* Move between tabs by dragging sideways on the
|
|
286
|
+
* Move between tabs by dragging sideways on the panels, as well as by
|
|
330
287
|
* pressing the triggers.
|
|
331
288
|
*
|
|
332
289
|
* Off by default, because a panel is allowed to contain something that
|
|
@@ -334,21 +291,72 @@ export interface TabsProps extends ViewProps {
|
|
|
334
291
|
* open — and the two cannot both have it. Turn it on for panels of ordinary
|
|
335
292
|
* scrolling content, where it is the gesture people try first.
|
|
336
293
|
*
|
|
337
|
-
* **It
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
294
|
+
* **It changes how the panels are laid out.** They go side by side in a strip
|
|
295
|
+
* that is as wide as all of them, and the tab set shows one panel of it at a
|
|
296
|
+
* time. So the panel on each side of the active one is built and sized before
|
|
297
|
+
* you swipe to it, which is what stops a heavy panel — a virtualised list, a
|
|
298
|
+
* chart — from stalling on the frame it becomes visible.
|
|
299
|
+
*
|
|
300
|
+
* **It needs a height to fill**, the same as any pager: `flex-1` on the tab
|
|
301
|
+
* set, or a fixed height. Without one the strip has no room to lay its panels
|
|
302
|
+
* out in, and a list inside a panel of no height renders no rows. In
|
|
303
|
+
* development the tab set says so rather than rendering nothing.
|
|
347
304
|
*/
|
|
348
305
|
swipeable?: boolean;
|
|
306
|
+
/**
|
|
307
|
+
* Turn the tab set's animations off — the indicator, the strip, and an
|
|
308
|
+
* expanding tab's reveal.
|
|
309
|
+
*
|
|
310
|
+
* For a screen that is already animating something more important, and as a
|
|
311
|
+
* blunt instrument on a device that cannot afford them. The system's own
|
|
312
|
+
* reduce-motion setting is honoured without this.
|
|
313
|
+
*/
|
|
314
|
+
animation?: TabsAnimation;
|
|
349
315
|
children: ReactNode;
|
|
350
316
|
}
|
|
351
317
|
|
|
318
|
+
/**
|
|
319
|
+
* Pulls the panels out of the children, keeping everything else where it was.
|
|
320
|
+
*
|
|
321
|
+
* A pager has to lay its panels out together, and they are written wherever
|
|
322
|
+
* they read best — usually after the list, sometimes inside a fragment from a
|
|
323
|
+
* `map`. So they are found rather than required to be somewhere: fragments are
|
|
324
|
+
* flattened through, `Tabs.Content` elements are collected in the order they
|
|
325
|
+
* appear, and every other child is left exactly where it was written.
|
|
326
|
+
*
|
|
327
|
+
* A panel that is *not* reachable this way — wrapped in a component of your own
|
|
328
|
+
* — is not found, and the tab set falls back to showing one panel at a time.
|
|
329
|
+
* Silently losing it would be worse than not paging it.
|
|
330
|
+
*/
|
|
331
|
+
function collectPanels(children: ReactNode): {
|
|
332
|
+
rest: ReactNode[];
|
|
333
|
+
panels: ReactElement<TabsContentProps>[];
|
|
334
|
+
} {
|
|
335
|
+
const rest: ReactNode[] = [];
|
|
336
|
+
const panels: ReactElement<TabsContentProps>[] = [];
|
|
337
|
+
|
|
338
|
+
const walk = (nodes: ReactNode) => {
|
|
339
|
+
Children.forEach(nodes, (child) => {
|
|
340
|
+
if (!isValidElement(child)) {
|
|
341
|
+
if (child !== null && child !== undefined && child !== false) rest.push(child);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (child.type === Fragment) {
|
|
345
|
+
walk((child.props as { children?: ReactNode }).children);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (child.type === TabsContent) {
|
|
349
|
+
panels.push(child as ReactElement<TabsContentProps>);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
rest.push(child);
|
|
353
|
+
});
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
walk(children);
|
|
357
|
+
return { rest, panels };
|
|
358
|
+
}
|
|
359
|
+
|
|
352
360
|
function TabsRoot({
|
|
353
361
|
className,
|
|
354
362
|
value,
|
|
@@ -357,84 +365,25 @@ function TabsRoot({
|
|
|
357
365
|
variant = 'segmented',
|
|
358
366
|
keepMounted = false,
|
|
359
367
|
swipeable = false,
|
|
368
|
+
animation,
|
|
360
369
|
children,
|
|
361
370
|
...props
|
|
362
371
|
}: TabsProps) {
|
|
363
372
|
const [internalValue, setInternalValue] = useState(defaultValue);
|
|
364
373
|
const [layouts, setLayouts] = useState<Record<string, TabLayout>>({});
|
|
365
|
-
const [tabs, setTabs] = useState<string[]>([]);
|
|
366
|
-
const swipeOffset = useSharedValue(0);
|
|
367
|
-
const panelWidth = useSharedValue(0);
|
|
368
|
-
const sign = useDirectionSign();
|
|
369
374
|
// Published by the List rather than the root, because it is the List that
|
|
370
375
|
// decides whether it scrolls — but the Triggers below it need to know.
|
|
371
376
|
const [scrollable, setScrollable] = useState(false);
|
|
372
377
|
const isControlled = value !== undefined;
|
|
373
378
|
const resolvedValue = isControlled ? value : internalValue;
|
|
374
|
-
|
|
375
|
-
/*
|
|
376
|
-
* The two things `setValue` reads that change on every switch, held where
|
|
377
|
-
* reading them does not make it a new function.
|
|
378
|
-
*
|
|
379
|
-
* With them in the dependency list, changing tab produced a new `setValue`,
|
|
380
|
-
* therefore a new context, therefore a new `step`, therefore a new pan
|
|
381
|
-
* gesture — which detaches and re-attaches the native recogniser on the same
|
|
382
|
-
* commit that mounts the arriving panel's contents. The busiest frame of the
|
|
383
|
-
* interaction was doing the one piece of work that had no reason to be there.
|
|
384
|
-
*/
|
|
385
|
-
const tabsRef = useRef(tabs);
|
|
386
|
-
tabsRef.current = tabs;
|
|
387
|
-
const valueRef = useRef(resolvedValue);
|
|
388
|
-
valueRef.current = resolvedValue;
|
|
379
|
+
const animationDisabled = animation === 'disable-all';
|
|
389
380
|
|
|
390
381
|
const setValue = useCallback(
|
|
391
|
-
(next: string
|
|
392
|
-
const tabs = tabsRef.current;
|
|
393
|
-
const resolvedValue = valueRef.current;
|
|
394
|
-
|
|
395
|
-
/*
|
|
396
|
-
* The arriving panel starts on the side it is arriving from, and travels
|
|
397
|
-
* in. Done here rather than in the panel because only the root knows
|
|
398
|
-
* both the tab being left and the tab being gone to — a panel knows
|
|
399
|
-
* which one it is, not which one it replaced — and because a tab changed
|
|
400
|
-
* by pressing a trigger deserves the same movement as one changed by
|
|
401
|
-
* swiping to it. Without it the two read as different features.
|
|
402
|
-
*/
|
|
403
|
-
if (swipeable) {
|
|
404
|
-
const from = tabs.indexOf(resolvedValue);
|
|
405
|
-
const to = tabs.indexOf(next);
|
|
406
|
-
if (from !== -1 && to !== -1 && from !== to) {
|
|
407
|
-
const direction = to > from ? 1 : -1;
|
|
408
|
-
|
|
409
|
-
if (handover) {
|
|
410
|
-
/*
|
|
411
|
-
* A swipe hands over mid-movement, so the arriving panel starts a
|
|
412
|
-
* whole panel's width from wherever the outgoing one has got to —
|
|
413
|
-
* which is where it *would* have been all along, had both been
|
|
414
|
-
* mounted. Read live rather than captured, because the outgoing
|
|
415
|
-
* panel is still travelling while this runs: adding to the current
|
|
416
|
-
* displacement rather than replacing it is the whole difference
|
|
417
|
-
* between one continuous movement and a jump at the moment the
|
|
418
|
-
* arriving panel finishes mounting.
|
|
419
|
-
*/
|
|
420
|
-
swipeOffset.value += direction * sign * panelWidth.value;
|
|
421
|
-
swipeOffset.value = withSpring(0, {
|
|
422
|
-
...ENTER_SPRING,
|
|
423
|
-
// Carried across too, so a flick keeps its speed instead of
|
|
424
|
-
// stopping dead and starting again from rest.
|
|
425
|
-
velocity: handover.velocity,
|
|
426
|
-
});
|
|
427
|
-
} else {
|
|
428
|
-
swipeOffset.value = direction * sign * panelWidth.value * SWIPE_ENTER;
|
|
429
|
-
swipeOffset.value = withSpring(0, ENTER_SPRING);
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
|
|
382
|
+
(next: string) => {
|
|
434
383
|
if (!isControlled) setInternalValue(next);
|
|
435
384
|
onValueChange?.(next);
|
|
436
385
|
},
|
|
437
|
-
[isControlled, onValueChange
|
|
386
|
+
[isControlled, onValueChange]
|
|
438
387
|
);
|
|
439
388
|
|
|
440
389
|
const registerLayout = useCallback((tab: string, layout: TabLayout) => {
|
|
@@ -448,16 +397,16 @@ function TabsRoot({
|
|
|
448
397
|
}, []);
|
|
449
398
|
|
|
450
399
|
/*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
400
|
+
* The panels are the sequence.
|
|
401
|
+
*
|
|
402
|
+
* They are read straight out of the children, in the order they are written,
|
|
403
|
+
* which is available on the first render and cannot disagree with what is on
|
|
404
|
+
* screen. The triggers used to register themselves to build this, which meant
|
|
405
|
+
* the order arrived a commit late and every mount and unmount of a trigger
|
|
406
|
+
* re-rendered every panel.
|
|
453
407
|
*/
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
}, []);
|
|
457
|
-
|
|
458
|
-
const unregisterTab = useCallback((tab: string) => {
|
|
459
|
-
setTabs((current) => current.filter((entry) => entry !== tab));
|
|
460
|
-
}, []);
|
|
408
|
+
const { rest, panels } = useMemo(() => collectPanels(children), [children]);
|
|
409
|
+
const paged = swipeable && panels.length > 0;
|
|
461
410
|
|
|
462
411
|
const context = useMemo(
|
|
463
412
|
() => ({
|
|
@@ -465,45 +414,303 @@ function TabsRoot({
|
|
|
465
414
|
setValue,
|
|
466
415
|
registerLayout,
|
|
467
416
|
layouts,
|
|
468
|
-
tabs,
|
|
469
|
-
registerTab,
|
|
470
|
-
unregisterTab,
|
|
471
417
|
variant,
|
|
472
418
|
scrollable,
|
|
473
419
|
setScrollable,
|
|
474
420
|
keepMounted,
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
panelWidth,
|
|
421
|
+
pager: paged,
|
|
422
|
+
animationDisabled,
|
|
478
423
|
}),
|
|
479
424
|
[
|
|
480
425
|
resolvedValue,
|
|
481
426
|
setValue,
|
|
482
427
|
registerLayout,
|
|
483
428
|
layouts,
|
|
484
|
-
tabs,
|
|
485
|
-
registerTab,
|
|
486
|
-
unregisterTab,
|
|
487
429
|
variant,
|
|
488
430
|
scrollable,
|
|
489
431
|
keepMounted,
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
panelWidth,
|
|
432
|
+
paged,
|
|
433
|
+
animationDisabled,
|
|
493
434
|
]
|
|
494
435
|
);
|
|
495
436
|
|
|
496
437
|
return (
|
|
497
438
|
<TabsContext.Provider value={context}>
|
|
498
439
|
<View className={cn('gap-3', className)} {...props}>
|
|
499
|
-
{textChildren(children)}
|
|
440
|
+
{textChildren(paged ? rest : children)}
|
|
441
|
+
{paged ? (
|
|
442
|
+
<TabsPager
|
|
443
|
+
panels={panels}
|
|
444
|
+
value={resolvedValue}
|
|
445
|
+
setValue={setValue}
|
|
446
|
+
keepMounted={!!keepMounted}
|
|
447
|
+
animationDisabled={animationDisabled}
|
|
448
|
+
/>
|
|
449
|
+
) : null}
|
|
500
450
|
</View>
|
|
501
451
|
</TabsContext.Provider>
|
|
502
452
|
);
|
|
503
453
|
}
|
|
504
454
|
|
|
455
|
+
/**
|
|
456
|
+
* The panels, side by side, behind a window one panel wide.
|
|
457
|
+
*
|
|
458
|
+
* Everything about the movement lives on `position`, measured in panels rather
|
|
459
|
+
* than points: the strip is at `-position × width`, a drag sets it, a press
|
|
460
|
+
* springs it, and it is the only thing that says where the strip is. Nothing
|
|
461
|
+
* here waits for React to commit before moving, which is what the old
|
|
462
|
+
* arrangement did — it mounted the arriving panel and animated it in on the
|
|
463
|
+
* same frame, so the movement was only as smooth as the mount was quick.
|
|
464
|
+
*/
|
|
465
|
+
function TabsPager({
|
|
466
|
+
panels,
|
|
467
|
+
value,
|
|
468
|
+
setValue,
|
|
469
|
+
keepMounted,
|
|
470
|
+
animationDisabled,
|
|
471
|
+
}: {
|
|
472
|
+
panels: ReactElement<TabsContentProps>[];
|
|
473
|
+
value: string;
|
|
474
|
+
setValue: (value: string) => void;
|
|
475
|
+
keepMounted: boolean;
|
|
476
|
+
animationDisabled: boolean;
|
|
477
|
+
}) {
|
|
478
|
+
const sign = useDirectionSign();
|
|
479
|
+
const reducedMotion = useReducedMotion();
|
|
480
|
+
const still = animationDisabled || reducedMotion;
|
|
481
|
+
|
|
482
|
+
const order = useMemo(() => panels.map((panel) => panel.props.value), [panels]);
|
|
483
|
+
const count = panels.length;
|
|
484
|
+
// An unknown value shows the first panel rather than none of them: a tab set
|
|
485
|
+
// with nothing in it is a harder thing to debug than one showing the wrong tab.
|
|
486
|
+
const active = Math.max(0, order.indexOf(value));
|
|
487
|
+
|
|
488
|
+
const [width, setWidth] = useState(0);
|
|
489
|
+
const position = useSharedValue(active);
|
|
490
|
+
const widthValue = useSharedValue(0);
|
|
491
|
+
const countValue = useSharedValue(count);
|
|
492
|
+
const start = useSharedValue(active);
|
|
493
|
+
/** 1 between a drag activating and it being finalised, 0 otherwise. */
|
|
494
|
+
const dragging = useSharedValue(0);
|
|
495
|
+
|
|
496
|
+
useEffect(() => {
|
|
497
|
+
widthValue.value = width;
|
|
498
|
+
countValue.value = count;
|
|
499
|
+
}, [width, count, widthValue, countValue]);
|
|
500
|
+
|
|
501
|
+
/*
|
|
502
|
+
* Which panels have been built, and it only ever grows.
|
|
503
|
+
*
|
|
504
|
+
* A panel that has been reached stays mounted for the life of the tab set,
|
|
505
|
+
* so a tab is slow at most once. That is what `keepMounted` was reached for
|
|
506
|
+
* and could not deliver, because it decided mounting and hiding together and
|
|
507
|
+
* the hiding took the panel's size away.
|
|
508
|
+
*/
|
|
509
|
+
const [reached, setReached] = useState<number[]>(() => [active]);
|
|
510
|
+
if (!reached.includes(active)) {
|
|
511
|
+
// During render, not in an effect: a press on a far tab has to have its
|
|
512
|
+
// panel in this commit, or the strip travels to an empty box.
|
|
513
|
+
setReached((current) => (current.includes(active) ? current : [...current, active]));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
useEffect(() => {
|
|
517
|
+
const wanted = keepMounted
|
|
518
|
+
? Array.from({ length: count }, (_, index) => index)
|
|
519
|
+
: [active - 1, active + 1].filter((index) => index >= 0 && index < count);
|
|
520
|
+
|
|
521
|
+
/*
|
|
522
|
+
* The neighbours arrive a tick late, on purpose.
|
|
523
|
+
*
|
|
524
|
+
* They are what makes a swipe cost nothing — the panel you are swiping
|
|
525
|
+
* towards is already built — but mounting them in the same commit as the
|
|
526
|
+
* active one puts three panels' worth of work on the frame the tab set
|
|
527
|
+
* first appears. A timeout of zero is enough to let that frame out.
|
|
528
|
+
*/
|
|
529
|
+
const timer = setTimeout(() => {
|
|
530
|
+
setReached((current) => {
|
|
531
|
+
const missing = wanted.filter((index) => !current.includes(index));
|
|
532
|
+
return missing.length > 0 ? [...current, ...missing] : current;
|
|
533
|
+
});
|
|
534
|
+
}, 0);
|
|
535
|
+
|
|
536
|
+
return () => clearTimeout(timer);
|
|
537
|
+
}, [active, count, keepMounted]);
|
|
538
|
+
|
|
539
|
+
/*
|
|
540
|
+
* Which tab the strip has already been sprung to by a swipe.
|
|
541
|
+
*
|
|
542
|
+
* A swipe moves the strip and *then* reports the change, so by the time the
|
|
543
|
+
* value arrives the movement is under way with the flick's speed in it.
|
|
544
|
+
* Springing again from the effect below would restart it from rest, which is
|
|
545
|
+
* the flick visibly losing its throw halfway across.
|
|
546
|
+
*
|
|
547
|
+
* The index rather than a flag, so a change that never came back — a
|
|
548
|
+
* controlled parent that ignored the swipe — cannot swallow the next press.
|
|
549
|
+
*/
|
|
550
|
+
const sprungTo = useRef<number | null>(null);
|
|
551
|
+
const orderRef = useRef(order);
|
|
552
|
+
orderRef.current = order;
|
|
553
|
+
const valueRef = useRef(value);
|
|
554
|
+
valueRef.current = value;
|
|
555
|
+
|
|
556
|
+
useEffect(() => {
|
|
557
|
+
const already = sprungTo.current;
|
|
558
|
+
sprungTo.current = null;
|
|
559
|
+
if (already === active) return;
|
|
560
|
+
|
|
561
|
+
if (still || width === 0) {
|
|
562
|
+
position.value = active;
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
position.value = withSpring(active, ENTER_SPRING);
|
|
566
|
+
}, [active, still, width, position]);
|
|
567
|
+
|
|
568
|
+
const commit = useCallback(
|
|
569
|
+
(index: number) => {
|
|
570
|
+
const next = orderRef.current[index];
|
|
571
|
+
if (next === undefined || next === valueRef.current) return;
|
|
572
|
+
sprungTo.current = index;
|
|
573
|
+
setValue(next);
|
|
574
|
+
},
|
|
575
|
+
[setValue]
|
|
576
|
+
);
|
|
577
|
+
|
|
578
|
+
const pan = useMemo(
|
|
579
|
+
() =>
|
|
580
|
+
Gesture.Pan()
|
|
581
|
+
// Sideways past the threshold takes the gesture; any real vertical
|
|
582
|
+
// travel hands it back, so a panel that scrolls still scrolls.
|
|
583
|
+
.activeOffsetX([-SWIPE_ACTIVATE_X, SWIPE_ACTIVATE_X])
|
|
584
|
+
.failOffsetY([-SWIPE_FAIL_Y, SWIPE_FAIL_Y])
|
|
585
|
+
.onStart(() => {
|
|
586
|
+
// On activation rather than on touch-down, so a tap that never
|
|
587
|
+
// becomes a drag never claims a starting point. Rounded, so a drag
|
|
588
|
+
// begun while the last one is still settling starts from the tab it
|
|
589
|
+
// is settling on.
|
|
590
|
+
dragging.value = 1;
|
|
591
|
+
start.value = Math.round(position.value);
|
|
592
|
+
})
|
|
593
|
+
.onUpdate((event) => {
|
|
594
|
+
const span = widthValue.value;
|
|
595
|
+
if (span === 0) return;
|
|
596
|
+
const last = countValue.value - 1;
|
|
597
|
+
const raw = start.value - (event.translationX * sign) / span;
|
|
598
|
+
|
|
599
|
+
// Past either end there is no panel to bring on, so the strip gives
|
|
600
|
+
// a little and then stops, rather than pulling a blank into view.
|
|
601
|
+
if (raw < 0) position.value = raw * SWIPE_RESISTANCE_AT_END;
|
|
602
|
+
else if (raw > last) position.value = last + (raw - last) * SWIPE_RESISTANCE_AT_END;
|
|
603
|
+
else position.value = raw;
|
|
604
|
+
})
|
|
605
|
+
.onEnd((event) => {
|
|
606
|
+
const span = widthValue.value;
|
|
607
|
+
if (span === 0) return;
|
|
608
|
+
const last = countValue.value - 1;
|
|
609
|
+
const from = start.value;
|
|
610
|
+
const moved = position.value - from;
|
|
611
|
+
// Points per second becomes panels per second, which is the unit the
|
|
612
|
+
// spring that finishes the movement is working in.
|
|
613
|
+
const speed = (-event.velocityX * sign) / span;
|
|
614
|
+
|
|
615
|
+
let target = from;
|
|
616
|
+
// Speed first: distance and speed can disagree, and a flick back the
|
|
617
|
+
// way it came reads as a cancel however far it had already got.
|
|
618
|
+
if (Math.abs(event.velocityX) > SWIPE_VELOCITY) {
|
|
619
|
+
target = from + (speed > 0 ? 1 : -1);
|
|
620
|
+
} else if (Math.abs(moved) > SWIPE_DISTANCE_RATIO) {
|
|
621
|
+
target = from + (moved > 0 ? 1 : -1);
|
|
622
|
+
}
|
|
623
|
+
if (target < 0) target = 0;
|
|
624
|
+
if (target > last) target = last;
|
|
625
|
+
|
|
626
|
+
position.value = withSpring(target, { ...ENTER_SPRING, velocity: speed });
|
|
627
|
+
if (target !== from) runOnJS(commit)(target);
|
|
628
|
+
})
|
|
629
|
+
.onFinalize((_event, success) => {
|
|
630
|
+
// A cancelled gesture never reaches `onEnd`, and would otherwise
|
|
631
|
+
// leave the strip wherever the finger abandoned it. Only for a drag
|
|
632
|
+
// that actually started: this also runs for every touch that never
|
|
633
|
+
// became one, and springing to the rounded position there would
|
|
634
|
+
// interrupt a press's own movement with a tap on the panel.
|
|
635
|
+
if (dragging.value === 1 && !success) {
|
|
636
|
+
position.value = withSpring(start.value, ENTER_SPRING);
|
|
637
|
+
}
|
|
638
|
+
dragging.value = 0;
|
|
639
|
+
}),
|
|
640
|
+
[sign, commit, position, start, dragging, widthValue, countValue]
|
|
641
|
+
);
|
|
642
|
+
|
|
643
|
+
const strip = useAnimatedStyle(() => ({
|
|
644
|
+
transform: [{ translateX: -position.value * widthValue.value * sign }],
|
|
645
|
+
}));
|
|
646
|
+
|
|
647
|
+
const onLayout = useCallback((event: LayoutChangeEvent) => {
|
|
648
|
+
const measured = event.nativeEvent.layout;
|
|
649
|
+
if (measured.width > 0) setWidth(measured.width);
|
|
650
|
+
|
|
651
|
+
if (__DEV__ && measured.width > 0 && measured.height === 0) {
|
|
652
|
+
console.warn(
|
|
653
|
+
'[PanelUI] <Tabs swipeable> has no height to fill, so its panels have nowhere ' +
|
|
654
|
+
'to be laid out. Give the tab set a height — `className="flex-1"` on <Tabs>, ' +
|
|
655
|
+
'or a fixed height — the same as any pager needs.'
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
}, []);
|
|
659
|
+
|
|
660
|
+
/*
|
|
661
|
+
* A strip cannot be laid out before the width of one panel is known, so the
|
|
662
|
+
* first render is the active panel on its own, filling the window. The strip
|
|
663
|
+
* takes over on the next frame, and every frame after it.
|
|
664
|
+
*/
|
|
665
|
+
if (width === 0) {
|
|
666
|
+
return (
|
|
667
|
+
<View style={PAGER_VIEWPORT} onLayout={onLayout}>
|
|
668
|
+
{panels[active]}
|
|
669
|
+
</View>
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
return (
|
|
674
|
+
<View style={PAGER_VIEWPORT} onLayout={onLayout}>
|
|
675
|
+
<GestureDetector gesture={pan}>
|
|
676
|
+
<Animated.View
|
|
677
|
+
style={[FILL, { flexDirection: 'row', width: width * count }, strip]}
|
|
678
|
+
>
|
|
679
|
+
{panels.map((panel, index) => (
|
|
680
|
+
<View key={order[index]} style={[FILL, { width }]}>
|
|
681
|
+
{reached.includes(index) ? panel : null}
|
|
682
|
+
</View>
|
|
683
|
+
))}
|
|
684
|
+
</Animated.View>
|
|
685
|
+
</GestureDetector>
|
|
686
|
+
</View>
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Fill a height that is offered, and take the content's own when none is.
|
|
692
|
+
*
|
|
693
|
+
* `flexBasis: 'auto'` rather than the `0` that `flex: 1` sets, and it is on
|
|
694
|
+
* every box between the tab set and a panel — viewport, strip, panel — because
|
|
695
|
+
* one `flex: 1` anywhere in that chain breaks both cases at once. A `flex: 1`
|
|
696
|
+
* box inside a parent of indefinite height resolves to *nothing*: its basis is
|
|
697
|
+
* zero and there is no free space to grow into. So the tab set that had not
|
|
698
|
+
* been given a height would collapse, and take every panel with it — which is
|
|
699
|
+
* the same zero-height failure that made a kept panel useless to a list, one
|
|
700
|
+
* level up.
|
|
701
|
+
*
|
|
702
|
+
* With `auto` the chain resolves both ways. Given a height, the panels fill it
|
|
703
|
+
* and a virtualised list inside one has a real size to build against. Given
|
|
704
|
+
* none, the strip is as tall as its tallest panel and every panel stretches to
|
|
705
|
+
* match, so switching tabs does not change the tab set's height either.
|
|
706
|
+
*/
|
|
707
|
+
const FILL = { flexGrow: 1, flexShrink: 1, flexBasis: 'auto' } as const;
|
|
708
|
+
|
|
709
|
+
/** The window the strip moves behind, one panel wide. */
|
|
710
|
+
const PAGER_VIEWPORT = { ...FILL, overflow: 'hidden' } as const;
|
|
711
|
+
|
|
505
712
|
function TabsIndicator() {
|
|
506
|
-
const { value, layouts, variant } = useTabs('Tabs.List');
|
|
713
|
+
const { value, layouts, variant, animationDisabled } = useTabs('Tabs.List');
|
|
507
714
|
const x = useSharedValue(0);
|
|
508
715
|
const width = useSharedValue(0);
|
|
509
716
|
const initialized = useSharedValue(0);
|
|
@@ -517,7 +724,7 @@ function TabsIndicator() {
|
|
|
517
724
|
useEffect(() => {
|
|
518
725
|
if (!layout) return;
|
|
519
726
|
|
|
520
|
-
if (initialized.value === 0) {
|
|
727
|
+
if (initialized.value === 0 || animationDisabled) {
|
|
521
728
|
// First measurement snaps into place; there is nothing to animate from.
|
|
522
729
|
x.value = layout.x;
|
|
523
730
|
width.value = layout.width;
|
|
@@ -526,7 +733,7 @@ function TabsIndicator() {
|
|
|
526
733
|
x.value = withSpring(layout.x, SPRING);
|
|
527
734
|
width.value = withSpring(layout.width, SPRING);
|
|
528
735
|
}
|
|
529
|
-
}, [layout?.x, layout?.width, x, width, initialized, layout]);
|
|
736
|
+
}, [layout?.x, layout?.width, x, width, initialized, animationDisabled, layout]);
|
|
530
737
|
|
|
531
738
|
const style = useAnimatedStyle(() => ({
|
|
532
739
|
opacity: initialized.value,
|
|
@@ -635,15 +842,6 @@ function TabsTrigger({
|
|
|
635
842
|
[context, value]
|
|
636
843
|
);
|
|
637
844
|
|
|
638
|
-
// Separate from the layout registration above, and earlier than it: a swipe
|
|
639
|
-
// needs to know the sequence, which is known at mount, not the positions,
|
|
640
|
-
// which are not known until the row has been laid out.
|
|
641
|
-
const { registerTab, unregisterTab } = context;
|
|
642
|
-
useEffect(() => {
|
|
643
|
-
registerTab(value);
|
|
644
|
-
return () => unregisterTab(value);
|
|
645
|
-
}, [value, registerTab, unregisterTab]);
|
|
646
|
-
|
|
647
845
|
if (context.variant === 'expanding') {
|
|
648
846
|
return (
|
|
649
847
|
<ExpandingTrigger
|
|
@@ -651,6 +849,7 @@ function TabsTrigger({
|
|
|
651
849
|
disabled={disabled}
|
|
652
850
|
icon={icon}
|
|
653
851
|
badge={badge}
|
|
852
|
+
still={context.animationDisabled}
|
|
654
853
|
labelClassName={slots.label()}
|
|
655
854
|
className={cn(slots.trigger(), className)}
|
|
656
855
|
onLayout={handleLayout}
|
|
@@ -699,6 +898,7 @@ function ExpandingTrigger({
|
|
|
699
898
|
disabled,
|
|
700
899
|
icon,
|
|
701
900
|
badge,
|
|
901
|
+
still,
|
|
702
902
|
className,
|
|
703
903
|
labelClassName,
|
|
704
904
|
onLayout,
|
|
@@ -709,6 +909,7 @@ function ExpandingTrigger({
|
|
|
709
909
|
disabled: boolean;
|
|
710
910
|
icon?: ReactNode;
|
|
711
911
|
badge?: ReactNode;
|
|
912
|
+
still: boolean;
|
|
712
913
|
className: string;
|
|
713
914
|
labelClassName: string;
|
|
714
915
|
onLayout: (event: LayoutChangeEvent) => void;
|
|
@@ -720,7 +921,7 @@ function ExpandingTrigger({
|
|
|
720
921
|
const open = useSharedValue(active ? 1 : 0);
|
|
721
922
|
|
|
722
923
|
useEffect(() => {
|
|
723
|
-
if (reducedMotion) {
|
|
924
|
+
if (reducedMotion || still) {
|
|
724
925
|
open.value = active ? 1 : 0;
|
|
725
926
|
return;
|
|
726
927
|
}
|
|
@@ -738,7 +939,7 @@ function ExpandingTrigger({
|
|
|
738
939
|
duration: EXPAND_DURATION,
|
|
739
940
|
easing: Easing.bezier(0.2, 0, 0, 1),
|
|
740
941
|
});
|
|
741
|
-
}, [active, reducedMotion, open]);
|
|
942
|
+
}, [active, reducedMotion, still, open]);
|
|
742
943
|
|
|
743
944
|
const reveal = useAnimatedStyle(() => ({
|
|
744
945
|
width: (labelWidth + LABEL_GAP) * open.value,
|
|
@@ -826,211 +1027,51 @@ export interface TabsContentProps extends ViewProps {
|
|
|
826
1027
|
function TabsContent({ className, value, children, style, ...props }: TabsContentProps) {
|
|
827
1028
|
const context = useTabs('Tabs.Content');
|
|
828
1029
|
const active = context.value === value;
|
|
829
|
-
const {
|
|
830
|
-
tabs,
|
|
831
|
-
setValue,
|
|
832
|
-
swipeable,
|
|
833
|
-
swipeOffset: offset,
|
|
834
|
-
panelWidth: width,
|
|
835
|
-
} = context;
|
|
836
|
-
const sign = useDirectionSign();
|
|
837
|
-
// Read on the UI thread while the finger is down, so the resistance at the
|
|
838
|
-
// ends is known without a round trip to JavaScript.
|
|
839
|
-
const index = useSharedValue(0);
|
|
840
|
-
const count = useSharedValue(0);
|
|
841
|
-
|
|
842
|
-
const position = tabs.indexOf(value);
|
|
843
|
-
useEffect(() => {
|
|
844
|
-
index.value = position;
|
|
845
|
-
count.value = tabs.length;
|
|
846
|
-
}, [position, tabs.length, index, count]);
|
|
847
1030
|
|
|
848
1031
|
/*
|
|
849
|
-
*
|
|
850
|
-
*
|
|
851
|
-
*
|
|
852
|
-
*
|
|
1032
|
+
* In a strip, a panel does no hiding and no moving.
|
|
1033
|
+
*
|
|
1034
|
+
* It is positioned by the strip and sized by the box it was put in, so all
|
|
1035
|
+
* that is left to it is to fill that box and to stay out of the screen
|
|
1036
|
+
* reader's way while it is off screen. Everything else this used to do — the
|
|
1037
|
+
* displacement, the fade, the gesture, the two ways of being hidden — was
|
|
1038
|
+
* work to make one panel stand in for a row of them, and the row is real now.
|
|
853
1039
|
*/
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
);
|
|
868
|
-
|
|
869
|
-
const pan = useMemo(
|
|
870
|
-
() =>
|
|
871
|
-
Gesture.Pan()
|
|
872
|
-
// Sideways past the threshold takes the gesture; any real vertical
|
|
873
|
-
// travel hands it back, so a panel that scrolls still scrolls.
|
|
874
|
-
.activeOffsetX([-SWIPE_ACTIVATE_X, SWIPE_ACTIVATE_X])
|
|
875
|
-
.failOffsetY([-SWIPE_FAIL_Y, SWIPE_FAIL_Y])
|
|
876
|
-
.onUpdate((event) => {
|
|
877
|
-
// Positive is "towards the previous tab" in reading order, which is
|
|
878
|
-
// rightwards under LTR and leftwards under RTL.
|
|
879
|
-
const travel = event.translationX * sign;
|
|
880
|
-
const atEnd =
|
|
881
|
-
(travel > 0 && index.value === 0) ||
|
|
882
|
-
(travel < 0 && index.value >= count.value - 1);
|
|
883
|
-
/*
|
|
884
|
-
* One to one, except at the ends. The panel is under the finger for
|
|
885
|
-
* the whole of the drag, which is what lets the handover on release
|
|
886
|
-
* continue the movement rather than restart it — and a panel that
|
|
887
|
-
* moved a third as far as the finger never felt attached to it in
|
|
888
|
-
* the first place.
|
|
889
|
-
*/
|
|
890
|
-
offset.value = atEnd
|
|
891
|
-
? event.translationX * SWIPE_RESISTANCE_AT_END
|
|
892
|
-
: event.translationX;
|
|
893
|
-
})
|
|
894
|
-
.onEnd((event) => {
|
|
895
|
-
const travel = event.translationX * sign;
|
|
896
|
-
const speed = event.velocityX * sign;
|
|
897
|
-
const far = Math.abs(travel) > width.value * SWIPE_DISTANCE_RATIO;
|
|
898
|
-
const fast = Math.abs(speed) > SWIPE_VELOCITY;
|
|
899
|
-
const atEnd =
|
|
900
|
-
(travel > 0 && index.value === 0) ||
|
|
901
|
-
(travel < 0 && index.value >= count.value - 1);
|
|
902
|
-
|
|
903
|
-
if ((far || fast) && !atEnd) {
|
|
904
|
-
// Distance and speed can disagree — a flick back the way it came
|
|
905
|
-
// reads as a cancel — so the direction comes from whichever of the
|
|
906
|
-
// two crossed its threshold, speed first.
|
|
907
|
-
const delta = fast ? (speed < 0 ? 1 : -1) : travel < 0 ? 1 : -1;
|
|
908
|
-
|
|
909
|
-
/*
|
|
910
|
-
* The outgoing panel carries on off the edge on the UI thread,
|
|
911
|
-
* immediately, rather than waiting to be told what happened.
|
|
912
|
-
*
|
|
913
|
-
* It used to hold wherever the finger left it until `setValue`
|
|
914
|
-
* landed — which is fine when React commits in a frame, and is a
|
|
915
|
-
* visible freeze when the arriving panel is expensive to mount, a
|
|
916
|
-
* list of any size being the usual case. The panel appeared to
|
|
917
|
-
* stick to the screen for exactly as long as the JS thread was
|
|
918
|
-
* busy, which reads as the swipe having dropped the gesture.
|
|
919
|
-
*
|
|
920
|
-
* `setValue` still places the arriving panel *relative* to this
|
|
921
|
-
* one, reading the offset live at commit time, so continuing the
|
|
922
|
-
* movement here does not desynchronise the handover: the panel
|
|
923
|
-
* that arrives is one width from wherever this one has got to,
|
|
924
|
-
* whenever that turns out to be.
|
|
925
|
-
*/
|
|
926
|
-
offset.value = withSpring(-delta * sign * width.value, {
|
|
927
|
-
...ENTER_SPRING,
|
|
928
|
-
velocity: event.velocityX,
|
|
929
|
-
});
|
|
930
|
-
|
|
931
|
-
runOnJS(step)(delta, event.velocityX);
|
|
932
|
-
return;
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
// Nothing changed hands, so this panel simply comes back — with the
|
|
936
|
-
// speed it was let go at, so a cancelled flick decelerates rather
|
|
937
|
-
// than stopping and being pulled.
|
|
938
|
-
offset.value = withSpring(0, { ...SPRING, velocity: event.velocityX });
|
|
939
|
-
})
|
|
940
|
-
.onFinalize((_event, success) => {
|
|
941
|
-
// A cancelled gesture never reaches `onEnd`, and would otherwise
|
|
942
|
-
// leave the panel wherever the finger abandoned it.
|
|
943
|
-
if (!success) offset.value = withSpring(0, SPRING);
|
|
944
|
-
}),
|
|
945
|
-
[sign, step, offset, width, index, count]
|
|
946
|
-
);
|
|
947
|
-
|
|
948
|
-
const followStyle = useAnimatedStyle(() => {
|
|
949
|
-
// No `opacity` key at all when there is no fade, rather than a constant 1.
|
|
950
|
-
// Declaring it would still mark the property as animated and hand the view
|
|
951
|
-
// an alpha to composite every frame — the cost this is avoiding.
|
|
952
|
-
if (!SWIPE_FADE) return { transform: [{ translateX: offset.value }] };
|
|
953
|
-
|
|
954
|
-
const span = width.value || 1;
|
|
955
|
-
const travelled = Math.min(1, Math.abs(offset.value) / span);
|
|
956
|
-
return {
|
|
957
|
-
transform: [{ translateX: offset.value }],
|
|
958
|
-
opacity: 1 - SWIPE_FADE * travelled,
|
|
959
|
-
};
|
|
960
|
-
});
|
|
1040
|
+
if (context.pager) {
|
|
1041
|
+
return (
|
|
1042
|
+
<View
|
|
1043
|
+
style={[PAGER_PANEL, style]}
|
|
1044
|
+
accessibilityElementsHidden={!active}
|
|
1045
|
+
importantForAccessibility={active ? 'auto' : 'no-hide-descendants'}
|
|
1046
|
+
className={className}
|
|
1047
|
+
{...props}
|
|
1048
|
+
>
|
|
1049
|
+
{textChildren(children)}
|
|
1050
|
+
</View>
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
961
1053
|
|
|
962
1054
|
if (!active && !context.keepMounted) return null;
|
|
963
1055
|
|
|
964
|
-
/*
|
|
965
|
-
* Under `keepMounted='measured'` a hidden panel keeps its size instead of
|
|
966
|
-
* losing it. It is taken out of the flow and stretched over the tab set, so
|
|
967
|
-
* it is laid out at the same size as the visible panel without contributing
|
|
968
|
-
* its height to the parent, and it is hidden by being transparent rather
|
|
969
|
-
* than by `display: none`.
|
|
970
|
-
*
|
|
971
|
-
* That distinction is the whole point: `display: none` lays a panel out at
|
|
972
|
-
* zero size, and a child that sizes itself from its parent — a virtualised
|
|
973
|
-
* list deciding how many rows to render — renders nothing at all inside one.
|
|
974
|
-
* Given a real size while still hidden, it builds its rows now rather than on
|
|
975
|
-
* the frame the tab is switched to.
|
|
976
|
-
*
|
|
977
|
-
* `opacity: 0` is set once and never animated, so it costs a composite and
|
|
978
|
-
* not a per-frame redraw; the negative `zIndex` keeps it behind the visible
|
|
979
|
-
* panel rather than over it, whatever order the panels are written in.
|
|
980
|
-
*/
|
|
981
|
-
const measured = context.keepMounted === 'measured';
|
|
982
|
-
const hiddenStyle = measured
|
|
983
|
-
? ({
|
|
984
|
-
position: 'absolute',
|
|
985
|
-
top: 0,
|
|
986
|
-
left: 0,
|
|
987
|
-
right: 0,
|
|
988
|
-
bottom: 0,
|
|
989
|
-
opacity: 0,
|
|
990
|
-
zIndex: -1,
|
|
991
|
-
} as const)
|
|
992
|
-
: ({ display: 'none' } as const);
|
|
993
|
-
|
|
994
1056
|
/*
|
|
995
1057
|
* Hidden rather than unmounted under `keepMounted`, and hidden thoroughly:
|
|
996
1058
|
* it is not drawn, it takes no touches, and the accessibility props take it
|
|
997
1059
|
* out of the reading order too. A screen reader walking through three panels
|
|
998
1060
|
* of a tab set it cannot see is worse than no tabs at all.
|
|
1061
|
+
*
|
|
1062
|
+
* `display: none` takes it out of layout as well, so a kept panel costs
|
|
1063
|
+
* nothing to have around — and can hold nothing that needs a size while it is
|
|
1064
|
+
* hidden. That is what `swipeable` is for: in a strip every panel has one.
|
|
999
1065
|
*/
|
|
1000
|
-
|
|
1066
|
+
return (
|
|
1001
1067
|
<Animated.View
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
* touching the same view, so the fade would eat the slide. When panels
|
|
1010
|
-
* move, the movement is the entrance.
|
|
1011
|
-
*/
|
|
1012
|
-
entering={context.keepMounted || swipeable ? undefined : FadeIn.duration(150)}
|
|
1013
|
-
onLayout={(event: LayoutChangeEvent) => {
|
|
1014
|
-
// The thresholds are a share of the panel, not of the screen: a tab
|
|
1015
|
-
// set inside a card is narrower than the window, and a quarter of the
|
|
1016
|
-
// window would be most of the way across it.
|
|
1017
|
-
//
|
|
1018
|
-
// Zero is ignored. Every panel reports into the same value, and a
|
|
1019
|
-
// panel kept with `display: none` measures as nothing — letting it say
|
|
1020
|
-
// so would leave the visible panel with no width. A `'measured'` one
|
|
1021
|
-
// is stretched over the tab set and reports the same width as the
|
|
1022
|
-
// visible panel, so it is agreeing rather than overwriting.
|
|
1023
|
-
const measured = event.nativeEvent.layout.width;
|
|
1024
|
-
if (measured > 0) width.value = measured;
|
|
1025
|
-
}}
|
|
1026
|
-
/*
|
|
1027
|
-
* The follow style goes on the panel that is moving, and only that one.
|
|
1028
|
-
* Under `keepMounted` every other panel is hidden and cannot be seen to
|
|
1029
|
-
* move — but an animated style still subscribes them all to the offset,
|
|
1030
|
-
* so a five-tab set ran five mappers per frame to reposition four views
|
|
1031
|
-
* nobody was looking at.
|
|
1032
|
-
*/
|
|
1033
|
-
style={[!active && hiddenStyle, swipeable && active && followStyle, style]}
|
|
1068
|
+
// Only worth animating when the panel is genuinely arriving. A kept panel
|
|
1069
|
+
// is already there; fading it in every time it is revealed would undo the
|
|
1070
|
+
// point of keeping it.
|
|
1071
|
+
entering={
|
|
1072
|
+
context.keepMounted || context.animationDisabled ? undefined : FadeIn.duration(150)
|
|
1073
|
+
}
|
|
1074
|
+
style={[!active && HIDDEN_PANEL, style]}
|
|
1034
1075
|
pointerEvents={active ? 'auto' : 'none'}
|
|
1035
1076
|
accessibilityElementsHidden={!active}
|
|
1036
1077
|
importantForAccessibility={active ? 'auto' : 'no-hide-descendants'}
|
|
@@ -1040,15 +1081,13 @@ function TabsContent({ className, value, children, style, ...props }: TabsConten
|
|
|
1040
1081
|
{textChildren(children)}
|
|
1041
1082
|
</Animated.View>
|
|
1042
1083
|
);
|
|
1084
|
+
}
|
|
1043
1085
|
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
// but attaching a detector to each of them would put several competing
|
|
1047
|
-
// recognisers in the same tree.
|
|
1048
|
-
if (!swipeable || !active) return panel;
|
|
1086
|
+
/** A panel in the strip fills the box the strip put it in. */
|
|
1087
|
+
const PAGER_PANEL = { ...FILL, width: '100%' } as const;
|
|
1049
1088
|
|
|
1050
|
-
|
|
1051
|
-
}
|
|
1089
|
+
/** …and one that is kept without a strip is mounted, but takes up no room. */
|
|
1090
|
+
const HIDDEN_PANEL = { display: 'none' } as const;
|
|
1052
1091
|
|
|
1053
1092
|
export const Tabs = Object.assign(TabsRoot, {
|
|
1054
1093
|
List: TabsList,
|