/**
* Splitter — panes that share a container, with a seam between them the reader
* can drag.
*
* ```tsx
*
* {list}
*
* {detail}
*
* ```
*
* Sizes are percentages of the splitter, so a layout dragged in portrait is
* still the same layout in landscape. The splitter itself has no height of its
* own — give it one, or put it in something that does, or it collapses to
* nothing and takes its panes with it.
*
* The seams are drawn over the panes rather than between them. A handle that
* took layout space would have to be measured before the panes could be sized,
* which puts the whole layout downstream of a number nobody controls; floating
* it means the panes add up to exactly the container and the handle can be as
* wide a target as a finger needs without changing anything.
*
* Dragging runs on the UI thread. `onLayoutChange` fires when the seam is let
* go rather than on every frame, because a layout that round-trips through
* React sixty times a second is the one thing that makes this feel slow.
*/
import {
Children,
createContext,
isValidElement,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactElement,
type ReactNode,
} from 'react';
import {
View,
type AccessibilityActionEvent,
type LayoutChangeEvent,
type ViewProps,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
runOnJS,
useAnimatedStyle,
useSharedValue,
type SharedValue,
} from 'react-native-reanimated';
import { tv } from 'tailwind-variants';
import { useDirectionSign } from '../../hooks/use-direction';
import { selectionTick } from '../../utils/haptics';
import {
isCollapsed,
layoutOffset,
normalizeConstraint,
resetLayout,
resizeLayout,
resolveLayout,
type SplitterConstraint,
} from './splitter-math';
export type SplitterOrientation = 'horizontal' | 'vertical';
/**
* How thick the seam's touch target is, in points. Wider than the line it
* draws, because the line is a hairline and a hairline is not a target.
*/
const HANDLE_THICKNESS = 24;
const splitterVariants = tv({
slots: {
root: 'relative overflow-hidden',
panel: 'overflow-hidden',
handle: 'absolute items-center justify-center',
line: 'bg-border',
grip: 'absolute rounded-full bg-muted-foreground/30',
},
variants: {
orientation: {
horizontal: {
// `w-full`, because the panes are shares of a width the splitter has to
// already know. Left to size itself, a row asks its children how wide
// they are and the children answer with a share of that answer — a
// circle that resolves to zero and takes the whole splitter with it.
// Inside anything that centres its children, that is what happens.
root: 'w-full flex-row',
// Over the pane that follows it, not under. A seam is drawn between two
// panes but written between them too, and a later sibling paints on top
// — which left the outer half of every touch target dead.
handle: 'bottom-0 start-0 top-0 z-10 w-6',
line: 'h-full w-px',
grip: 'h-8 w-1',
},
vertical: {
root: 'flex-col',
handle: 'end-0 start-0 top-0 z-10 h-6',
line: 'h-px w-full',
grip: 'h-1 w-8',
},
},
},
defaultVariants: {
orientation: 'horizontal',
},
});
interface SplitterContextValue {
orientation: SplitterOrientation;
/** Live percentages, one per panel. Written by drags, read by every pane. */
layout: SharedValue;
/** The splitter's own size along its axis, in points. `0` until measured. */
available: SharedValue;
/** Whether that measurement has landed. Panes size themselves by flex until it has. */
measured: boolean;
/** The last settled layout, for the props that have to be React state. */
settled: number[];
/** The layout the splitter resolved to before anybody dragged it. */
initial: number[];
constraints: SplitterConstraint[];
disabled: boolean;
step: number;
sign: 1 | -1;
commit: (next: number[]) => void;
}
const SplitterContext = createContext(null);
const PanelIndexContext = createContext(0);
const BoundaryIndexContext = createContext(-1);
function useSplitter(component: string): SplitterContextValue {
const context = useContext(SplitterContext);
if (!context) throw new Error(`${component} must be used within a `);
return context;
}
export interface SplitterProps extends ViewProps {
className?: string;
/** Which way the panes are laid out. Defaults to `horizontal`. */
orientation?: SplitterOrientation;
/**
* Controlled layout, as one percentage per panel. Pair it with
* `onLayoutChange`: a seam that is let go snaps back to this unless the value
* moves with it.
*/
layout?: number[];
/**
* Starting layout when uncontrolled, as one percentage per panel. Panels left
* out of it fall back to their own `defaultSize`, and then to an even share.
*/
defaultLayout?: number[];
/** Called with the new layout once a seam is let go, or stepped. */
onLayoutChange?: (layout: number[]) => void;
/** Freezes every seam. */
disabled?: boolean;
/** How far one accessibility step moves a seam, in percent. Defaults to `5`. */
step?: number;
}
function isElementOfType(child: ReactNode, type: unknown): child is ReactElement> {
return isValidElement(child) && child.type === type;
}
function SplitterRoot({
className,
orientation = 'horizontal',
layout: layoutProp,
defaultLayout,
onLayoutChange,
disabled = false,
step = 5,
children,
onLayout,
...props
}: SplitterProps) {
const horizontal = orientation === 'horizontal';
const sign = useDirectionSign();
const { root } = splitterVariants({ orientation });
const items = Children.toArray(children);
const panels = items.filter((child) => isElementOfType(child, SplitterPanel)) as ReactElement[];
// The panes' limits are read straight off their elements rather than
// registered from an effect. A layout that is only correct after the children
// have mounted and reported in is a layout that is wrong on the first frame,
// and this one is knowable before any of them render.
const constraintsKey = JSON.stringify(
panels.map((panel) => [
panel.props.minSize,
panel.props.maxSize,
panel.props.collapsible === true,
panel.props.collapsedSize,
panel.props.defaultSize,
])
);
const constraints = useMemo(
() => panels.map((panel) => normalizeConstraint(panel.props)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[constraintsKey]
);
const sizesKey = JSON.stringify([
layoutProp ?? null,
defaultLayout ?? null,
panels.map((panel) => panel.props.defaultSize ?? null),
]);
const resolved = useMemo(() => {
const declared = layoutProp ?? defaultLayout;
return resolveLayout(
panels.map((panel, index) => declared?.[index] ?? panel.props.defaultSize),
constraints
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sizesKey, constraints]);
const layout = useSharedValue(resolved);
const available = useSharedValue(0);
const [measured, setMeasured] = useState(false);
const [settled, setSettled] = useState(resolved);
// Bumped when a controlled splitter is let go, so a caller that ignored the
// change gets the seam put back where its props still say it is.
const [syncToken, setSyncToken] = useState(0);
const controlled = layoutProp !== undefined;
const controlledRef = useRef(controlled);
controlledRef.current = controlled;
useEffect(() => {
layout.value = resolved;
setSettled(resolved);
}, [resolved, syncToken, layout]);
const commit = useCallback(
(next: number[]) => {
if (controlledRef.current) setSyncToken((token) => token + 1);
else setSettled(next);
onLayoutChange?.(next);
},
[onLayoutChange]
);
const handleLayout = useCallback(
(event: LayoutChangeEvent) => {
const box = event.nativeEvent.layout;
const size = horizontal ? box.width : box.height;
available.value = size;
if (size > 0) setMeasured(true);
onLayout?.(event);
},
[available, horizontal, onLayout]
);
const context = useMemo(
() => ({
orientation,
layout,
available,
measured,
settled,
initial: resolved,
constraints,
disabled,
// A step of zero or less is a seam that cannot be moved without a drag,
// which is the one thing the accessibility actions exist to avoid.
step: Number.isFinite(step) && step > 0 ? step : 5,
sign,
commit,
}),
[
orientation,
layout,
available,
measured,
settled,
resolved,
constraints,
disabled,
step,
sign,
commit,
]
);
// Panes learn their index and seams learn which pair they sit between from
// where they appear, so nothing has to be numbered by hand and a pane cannot
// be given the wrong one.
let panelIndex = -1;
const laidOut = items.map((child, index) => {
if (isElementOfType(child, SplitterPanel)) {
panelIndex += 1;
return (
{child}
);
}
if (isElementOfType(child, SplitterHandle)) {
return (
{child}
);
}
return child;
});
return (
{laidOut}
);
}
export interface SplitterPanelProps extends ViewProps {
className?: string;
/** Starting share of the splitter, in percent. Unsized panes split the rest. */
defaultSize?: number;
/** Smallest share this pane may hold while open, in percent. Defaults to `10`. */
minSize?: number;
/** Largest share this pane may hold, in percent. Defaults to `100`. */
maxSize?: number;
/** Lets a drag past `minSize` shut the pane rather than stopping at it. */
collapsible?: boolean;
/** Share this pane holds while shut, in percent. Defaults to `0`. */
collapsedSize?: number;
}
/**
* One pane.
*
* It clips what is inside it, so a pane dragged narrower hides its content
* rather than pushing it into the pane beside it.
*/
function SplitterPanel({ className, children, style, ...props }: SplitterPanelProps) {
const context = useSplitter('Splitter.Panel');
const index = useContext(PanelIndexContext);
const { panel } = splitterVariants({ orientation: context.orientation });
const horizontal = context.orientation === 'horizontal';
const animatedStyle = useAnimatedStyle(() => {
const size = (context.available.value * (context.layout.value[index] ?? 0)) / 100;
return horizontal ? { width: size } : { height: size };
}, [index, horizontal]);
// Nothing is measured on the first frame, and a pane sized from a container of
// zero is a pane nobody can see. Until the measurement lands, a definite flex
// basis of zero takes precedence over that width and the shares below carry
// the layout — so the first frame is already in proportion, and the switch to
// measured points changes nothing anybody can see.
const unmeasured = { flexGrow: context.settled[index] ?? 0, flexBasis: 0, flexShrink: 1 };
return (
{children}
);
}
export interface SplitterHandleProps extends ViewProps {
className?: string;
/** Freezes this seam on its own, leaving the others draggable. */
disabled?: boolean;
/** Draws the grip in the middle of the seam. Defaults to `true`. */
withGrip?: boolean;
/** What a screen reader calls the seam. Defaults to "Resize panels". */
accessibilityLabel?: string;
}
/**
* The seam between two panes.
*
* Put one between each pair. It is `adjustable` to a screen reader, so the
* layout can be moved a step at a time without a drag, and a double tap puts
* the pair back where it started — the fastest way out of a pane dragged shut
* by accident.
*/
function SplitterHandle({
className,
disabled,
withGrip = true,
accessibilityLabel = 'Resize panels',
style,
...props
}: SplitterHandleProps) {
const context = useSplitter('Splitter.Handle');
const boundary = useContext(BoundaryIndexContext);
const { handle, line, grip } = splitterVariants({ orientation: context.orientation });
const horizontal = context.orientation === 'horizontal';
const frozen = context.disabled || disabled === true;
const { layout, available, constraints, sign, commit } = context;
const start = useSharedValue([]);
const collapsed = useSharedValue(false);
const moved = useSharedValue(false);
const pan = useMemo(() => {
const gesture = Gesture.Pan()
.enabled(!frozen && boundary >= 0)
.onBegin(() => {
start.value = layout.value.slice();
moved.value = false;
const constraint = constraints[boundary];
collapsed.value = constraint
? isCollapsed(layout.value[boundary] ?? 0, constraint)
: false;
})
.onUpdate((event) => {
if (available.value <= 0) return;
const travelled = horizontal ? event.translationX * sign : event.translationY;
const delta = (travelled / available.value) * 100;
const next = resizeLayout(start.value, boundary, delta, constraints);
moved.value = true;
layout.value = next;
// The one moment in a drag worth feeling: a pane arriving at shut, or
// leaving it. Both are a jump the finger did not make, so the tick is
// what says the jump was the control and not a stutter.
const constraint = constraints[boundary];
if (constraint) {
const shut = isCollapsed(next[boundary] ?? 0, constraint);
if (shut !== collapsed.value) {
collapsed.value = shut;
runOnJS(selectionTick)();
}
}
})
// A seam that was pressed and not moved has nothing to report, and a
// layout change nobody made is a re-render nobody asked for.
.onFinalize(() => {
if (moved.value) runOnJS(commit)(layout.value.slice());
});
/*
* A seam only answers to the axis it moves on. Without that it claims any
* movement at all — which is a splitter inside a scroller taking the
* scroll, and a drag with no way to fail, which is the one thing the double
* tap behind it is waiting for.
*/
return horizontal
? gesture.activeOffsetX([-8, 8]).failOffsetY([-16, 16])
: gesture.activeOffsetY([-8, 8]).failOffsetX([-16, 16]);
}, [
available,
boundary,
collapsed,
commit,
constraints,
frozen,
horizontal,
layout,
moved,
sign,
start,
]);
const { initial } = context;
/**
* Puts this pair back to the proportions the splitter started with, without
* touching the panes further along — the two of them share exactly the room
* they already have between them.
*/
const reset = useCallback(() => {
const next = resetLayout(layout.value, initial, boundary, constraints);
layout.value = next;
commit(next);
}, [boundary, commit, constraints, initial, layout]);
const doubleTap = useMemo(
() =>
Gesture.Tap()
.numberOfTaps(2)
// A tap that has travelled this far is a drag, and saying so is what
// lets it give up on the movement rather than on a timer.
.maxDistance(HANDLE_THICKNESS / 2)
.enabled(!frozen && boundary >= 0)
.onEnd((_event, success) => {
if (success) runOnJS(reset)();
}),
[boundary, frozen, reset]
);
/*
* The drag comes first, and the order is the whole of it.
*
* A race gives its first gesture the priority and makes the rest wait for it
* to fail. With the tap in front, every drag was held back until the tap gave
* up — which a two-tap gesture only does once its window has expired, half a
* second after the finger lands. A slow first drag outlasted that and worked;
* the quicker one after it did not, and the seam ignored it.
*
* Round this way the pan activates on movement, and a press that never moves
* fails it and hands the touch on, which is all the tap was waiting for.
*/
const gesture = useMemo(() => Gesture.Race(pan, doubleTap), [doubleTap, pan]);
const animatedStyle = useAnimatedStyle(() => {
if (available.value <= 0) return { opacity: 0 };
const seam = (available.value * layoutOffset(layout.value, boundary)) / 100;
const shift = seam - HANDLE_THICKNESS / 2;
return {
opacity: 1,
transform: horizontal ? [{ translateX: shift * sign }] : [{ translateY: shift }],
};
}, [boundary, horizontal, sign]);
// A step moves the seam along the layout, not along the screen, so it is the
// one piece of this that the reading direction does not touch: incrementing
// grows the pane that comes first either way.
const step = useCallback(
(direction: 1 | -1) => {
const next = resizeLayout(
layout.value.slice(),
boundary,
direction * context.step,
constraints
);
layout.value = next;
commit(next);
},
[boundary, commit, constraints, context.step, layout]
);
const onAccessibilityAction = useCallback(
(event: AccessibilityActionEvent) => {
if (event.nativeEvent.actionName === 'increment') step(1);
else if (event.nativeEvent.actionName === 'decrement') step(-1);
},
[step]
);
const now = Math.round(layoutOffset(context.settled, boundary));
return (
{withGrip ? : null}
);
}
SplitterRoot.displayName = 'Splitter';
SplitterPanel.displayName = 'Splitter.Panel';
SplitterHandle.displayName = 'Splitter.Handle';
export const Splitter = Object.assign(SplitterRoot, {
Panel: SplitterPanel,
Handle: SplitterHandle,
});
export {
DEFAULT_MAX_SIZE,
DEFAULT_MIN_SIZE,
isCollapsed,
layoutOffset,
normalizeConstraint,
panelFloor,
resetLayout,
resizeLayout,
resolveLayout,
type SplitterConstraint,
type SplitterConstraintInput,
} from './splitter-math';