panelui-native 0.59.0 → 0.60.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.
@@ -16,27 +16,38 @@
16
16
  * </Tabs>
17
17
  * ```
18
18
  *
19
- * **Swiping.** Only the visible panel is mounted, so a drag has nothing behind
20
- * it to reveal. The panel still tracks the finger one to one and dims as it
21
- * goes, and the panel arriving comes back up through the same fade — the
22
- * movement carries the change and the dissolve covers the gap. On release the
23
- * arriving panel picks up a whole panel's width from wherever the outgoing one
24
- * was let go, so the two views draw one continuous movement between them. The
25
- * displacement lives on the root for exactly that reason: a value belonging to
26
- * either panel would be destroyed at the handover.
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 the panel's width changes tab on release. */
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 panel follows at the ends of the row,
83
- * where there is nowhere for it to go. Everywhere else it follows all of it.
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 but takes it out of layout, so
147
- * it costs nothing to have around. `'measured'` keeps it laid out at full size
148
- * as well — the expensive option, and the only one a child that sizes itself
149
- * from its parent can be built inside while it is hidden.
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, handover?: SwipeHandover) => void;
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
- * How far the visible panel is displaced from its resting place, in points.
232
+ * Whether the panels are in a strip rather than stacked in place.
263
233
  *
264
- * It lives on the root rather than on a panel because the outgoing and
265
- * incoming panels are different views, and a value that belonged to either
266
- * of them would be destroyed at the moment of the handover. Shared, the
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
- swipeOffset: SharedValue<number>;
271
- /** The visible panel's measured width, which the throw above is a share of. */
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
- * Keep inactive panels mounted and hidden instead of unmounting them, so a
303
- * scroll position or a half-filled form survives a switch away and back.
304
- * Costs the render of every panel up front.
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
- * It is *not* enough for a child that decides what to render by measuring the
312
- * space it has been given. A virtualised list asks its parent how tall it is
313
- * and fills that many rows; asked inside a panel of zero height it answers
314
- * zero rows, and the whole first render still lands on the frame the tab
315
- * becomes visible — the stall this flag looks like it should have avoided.
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
- * `'measured'` is for that case. A kept panel stays laid out at the full size
318
- * of the tab set, and is hidden by not being drawn rather than by being
319
- * removed from layout: a list inside it measures, renders its rows and
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 panel, as well as by
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 does not change what is mounted.** Only `keepMounted` decides that,
338
- * with or without this — a swipe animates between two panels of which one is
339
- * being unmounted and the other mounted for the first time, exactly as a
340
- * press does. What it does change is that the mount now happens *while
341
- * something is moving*, so a panel that is slow to build stops being a pause
342
- * before it appears and starts being a stutter in the movement. If a swipe
343
- * feels heavier than a press on the same tab set, the panel is expensive to
344
- * mount — and the answer is whichever `keepMounted` actually keeps its
345
- * content built, which for a virtualised list is `'measured'` rather than
346
- * `true`. Turning this off hides the cost rather than removing it.
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, handover?: SwipeHandover) => {
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, swipeable, sign, swipeOffset, panelWidth]
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
- * Triggers add themselves on mount, so the order is React's child order —
452
- * the order they are written in, which is the order they are read in.
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 registerTab = useCallback((tab: string) => {
455
- setTabs((current) => (current.includes(tab) ? current : [...current, tab]));
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
- swipeable,
476
- swipeOffset,
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
- swipeable,
491
- swipeOffset,
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
- * Same reason as `setValue` on the root: `step` is reached through the pan
850
- * gesture, and a `step` that changes identity on every switch rebuilds the
851
- * gesture on every switch. The two facts it needs are read at call time
852
- * instead, which is when they are wanted anyway.
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
- const tabsRef = useRef(tabs);
855
- tabsRef.current = tabs;
856
- const valueRef = useRef(value);
857
- valueRef.current = value;
858
-
859
- const step = useCallback(
860
- (delta: number, velocity: number) => {
861
- const list = tabsRef.current;
862
- const from = list.indexOf(valueRef.current);
863
- const next = list[from + delta];
864
- if (next) setValue(next, { velocity });
865
- },
866
- [setValue]
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
- const panel = (
1066
+ return (
1001
1067
  <Animated.View
1002
- /*
1003
- * Only worth animating when the panel is genuinely arriving. A kept
1004
- * panel is already there; fading it in every time it is revealed would
1005
- * undo the point of keeping it.
1006
- *
1007
- * And never alongside the swipe: an entering layout animation owns the
1008
- * view's style for the length of it and will overwrite an animated style
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
- // Only the visible panel carries the gesture. A kept-mounted panel takes no
1045
- // touches anyway — `pointerEvents` is off on it whichever way it is hidden —
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
- return <GestureDetector gesture={pan}>{panel}</GestureDetector>;
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,