/** * SplitView — two stacked panes whose seam settles on one of a few named * heights rather than wherever the finger stopped. * * ```tsx * * {map} * * * * {list} * * ``` * * The snapping is the difference between this and a free-resize split. A layout * with a few right answers — a map over a list, a preview over an editor — is * better served by a control that lands on one of them than by one that lets * the reader stop three points short of it and live with the result. * * Snap points are ratios of the room the two panes share, so they mean the same * thing on any screen and a rotation costs no re-measuring. That room is the * container minus the drag area, which takes real layout height: half means * half of what is actually divisible rather than half of a number the seam then * eats into. * * The split view has no height of its own — give it one, or put it in something * that has one, or it collapses and takes its panes with it. * * Dragging runs on the UI thread. `onSnap` fires once the pane has settled * 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 { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from 'react'; import { View, type LayoutChangeEvent, type ViewProps } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { runOnJS, useAnimatedStyle, useReducedMotion, useSharedValue, withSpring, type SharedValue, } from 'react-native-reanimated'; import { tv } from 'tailwind-variants'; import { selectionTick } from '../../utils/haptics'; import { DEFAULT_MIN_HEIGHT, clamp, nearestSnapIndex, resolveLength, resolveSnapPoints, } from './split-view-math'; import { useSplitViewIndexLifecycle } from './split-view-lifecycle'; /** Settles the pane onto a snap point without overshooting past its limits. */ const SPRING = { damping: 25, stiffness: 300, mass: 0.8, overshootClamping: false, restDisplacementThreshold: 0.01, restSpeedThreshold: 0.01, } as const; /** How much the grip grows while it is being dragged. */ const GRIP_SPRING = { damping: 18, stiffness: 300, mass: 0.8 } as const; const splitViewVariants = tv({ slots: { root: 'w-full flex-col overflow-hidden', top: 'w-full overflow-hidden', bottom: 'w-full flex-1 overflow-hidden', dragArea: 'w-full items-center justify-center', handle: 'rounded-full', }, variants: { /* * How the split is drawn. The mechanics are the same either way — this is * only what the reader sees, and what they see decides what they think the * control is. * * `panes` gives each half a surface of its own on a recessed ground, with * the grip in the gap between them. Two things you can resize, and a * handle that belongs to neither. * * `seam` is a hairline grip on a shared background: no surfaces, no gap, * the two halves reading as one region with a line through it. It suits a * split inside something that already has a surface — a card, a sheet — * where a second one is a box in a box. */ variant: { panes: { root: 'bg-inset', top: 'rounded-2xl bg-surface', bottom: 'rounded-2xl bg-surface', dragArea: 'py-3', handle: 'h-1.5 w-12 bg-muted-foreground/50', }, seam: { dragArea: 'py-2', handle: 'h-1 w-10 bg-muted-foreground/30', }, }, }, defaultVariants: { variant: 'panes', }, }); export type SplitViewVariant = 'panes' | 'seam'; interface SplitViewContextValue { /** The top pane's height in points. Written by drags, read by both panes. */ topHeight: SharedValue; /** True while a finger is on the seam. */ dragging: SharedValue; /** Snap heights in points, in order. Empty until the container is measured. */ points: number[]; minPx: number; maxPx: number; room: number; measured: boolean; disabled: boolean; snapIndex: number; animate: boolean; snapTo: (index: number) => void; measureDragArea: (height: number) => void; dragAreaHeight: number; variant: SplitViewVariant; } const SplitViewContext = createContext(null); function useSplitViewContext(component: string): SplitViewContextValue { const context = useContext(SplitViewContext); if (!context) throw new Error(`${component} must be used within a `); return context; } /** * Reads the live layout from inside a split view, and moves the seam. * * `topHeight` is a shared value on the UI thread — read it in a worklet, not in * render, where it is only ever the number the last commit happened to see. */ export function useSplitView() { return useSplitViewContext('useSplitView'); } export interface SplitViewProps extends ViewProps { className?: string; /** * How the split is drawn. `panes` gives each half its own rounded surface on * a recessed ground, with the grip in the gap between them; `seam` is a * hairline grip on a shared background, for a split inside something that * already has a surface of its own. */ variant?: SplitViewVariant; /** * Heights the seam settles on. A number at or below `1` is a fraction of the * room the panes share; anything larger is points. Defaults to * `[0.2, 0.5, 0.8]`. */ snapPoints?: readonly number[]; /** Smallest the top pane may get, as a fraction or in points. Defaults to `100`. */ minHeight?: number; /** * Largest the top pane may get, as a fraction or in points. A negative number * is measured back from the bottom — `-80` leaves eighty points for the other * pane. Defaults to all the room there is. */ maxHeight?: number; /** Which snap point the seam starts at when uncontrolled. Defaults to `1`. */ defaultSnapIndex?: number; /** Controlled snap index. Pair it with `onSnapIndexChange`. */ snapIndex?: number; /** Called with the index the seam settled on. */ onSnapIndexChange?: (index: number) => void; /** Called once the pane has settled, with the index and its height in points. */ onSnap?: (index: number, topHeight: number) => void; /** Freezes the seam. The panes keep the heights they have. */ disabled?: boolean; /** Springs to the starting snap point on mount instead of opening at it. */ animateOnMount?: boolean; children?: ReactNode; } function SplitViewRoot({ className, variant = 'panes', snapPoints, minHeight, maxHeight, defaultSnapIndex = 1, snapIndex: snapIndexProp, onSnapIndexChange, onSnap, disabled = false, animateOnMount = false, children, onLayout, ...props }: SplitViewProps) { const { root } = splitViewVariants({ variant }); const reducedMotion = useReducedMotion(); const animate = !reducedMotion; const [container, setContainer] = useState(0); const [dragAreaHeight, setDragAreaHeight] = useState(0); const topHeight = useSharedValue(0); const dragging = useSharedValue(false); const settled = useRef(false); const room = Math.max(container - dragAreaHeight, 0); const minPx = clamp(resolveLength(minHeight, room, DEFAULT_MIN_HEIGHT), 0, room); const maxPx = clamp(resolveLength(maxHeight, room, room), minPx, room); const pointsKey = JSON.stringify([snapPoints ?? null, room, minPx, maxPx]); const points = useMemo( () => (room > 0 ? resolveSnapPoints(snapPoints, room, minPx, maxPx) : []), // eslint-disable-next-line react-hooks/exhaustive-deps [pointsKey] ); const { index: snapIndex, requestIndex, requestToken, } = useSplitViewIndexLifecycle({ snapIndex: snapIndexProp, defaultSnapIndex, count: points.length, onSnapIndexChange, }); const onSnapRef = useRef(onSnap); onSnapRef.current = onSnap; /** Reports a settled pane once, from the spring's own completion. */ const report = useCallback((index: number, height: number) => { onSnapRef.current?.(index, height); }, []); /* * Every modality makes the same request. The synchronization effect below * moves only to the index the owner accepts, then reports that settlement. */ const moveTo = useCallback( (index: number) => { if (points[index] === undefined) return; requestIndex(index); }, [points, requestIndex] ); const snapTo = useCallback((index: number) => moveTo(index), [moveTo]); /* * The first frame with a measurement is the only one that may place the pane * without animating: there is nothing on screen yet to animate from, and a * spring out of zero reads as the layout arriving broken and correcting * itself. `animateOnMount` is for when that arrival is the point. */ useEffect(() => { const target = points[snapIndex]; if (target === undefined) return; if (!settled.current) { settled.current = true; if (!animateOnMount) { topHeight.value = target; report(snapIndex, target); return; } } if (dragging.value) return; if (!animate) { topHeight.value = target; report(snapIndex, target); return; } const finish = () => report(snapIndex, target); topHeight.value = withSpring(target, SPRING, (finished) => { 'worklet'; if (finished) runOnJS(finish)(); }); // Only when the resolved layout moves — a re-render that changes nothing // must not restart a spring the reader is watching. // eslint-disable-next-line react-hooks/exhaustive-deps }, [points, snapIndex, animate, animateOnMount, requestToken]); const handleLayout = useCallback( (event: LayoutChangeEvent) => { setContainer(event.nativeEvent.layout.height); onLayout?.(event); }, [onLayout] ); const measureDragArea = useCallback((height: number) => { setDragAreaHeight((current) => (Math.abs(current - height) < 0.5 ? current : height)); }, []); const context = useMemo( () => ({ topHeight, dragging, points, minPx, maxPx, room, measured: room > 0, disabled, snapIndex, animate, snapTo, measureDragArea, dragAreaHeight, variant, }), [ topHeight, dragging, points, minPx, maxPx, room, disabled, snapIndex, animate, snapTo, measureDragArea, dragAreaHeight, variant, ] ); return ( {children} ); } export interface SplitViewPaneProps extends ViewProps { className?: string; children?: ReactNode; } /** * The upper pane. * * It clips what is inside it, so a pane dragged short hides its content rather * than pushing it through the seam. Content that outgrows it is the caller's to * scroll — put a `ScrollView` in here and it behaves like any other scroller in * a box whose height changes. */ function SplitViewTop({ className, children, style, ...props }: SplitViewPaneProps) { const context = useSplitViewContext('SplitView.Top'); const { top } = splitViewVariants({ variant: context.variant }); const animatedStyle = useAnimatedStyle(() => ({ height: context.topHeight.value, })); /* * Before the container is measured there is no height to give, and a pane * sized from a container of zero is a pane nobody can see. A flex basis of * zero and a grow of one carries the first frame instead, so the pane is * already the shape it will be when the measurement lands. */ const unmeasured = { flexGrow: 1, flexBasis: 0, flexShrink: 1 }; return ( {children} ); } /** * The lower pane. It takes exactly the room the upper one gave up, with no * second measurement — which is what keeps the two adding to the container on * every frame of a drag rather than only at rest. */ function SplitViewBottom({ className, children, ...props }: SplitViewPaneProps) { const context = useSplitViewContext('SplitView.Bottom'); const { bottom } = splitViewVariants({ variant: context.variant }); return ( {children} ); } export interface SplitViewDragAreaProps extends ViewProps { className?: string; /** What a screen reader calls the seam. Defaults to "Resize panes". */ accessibilityLabel?: string; children?: ReactNode; } /** * The seam, and the target for the drag. * * It takes real layout height rather than floating over the panes, because that * height is what the snap points are fractions of: a finger-sized target that * did not take room would make `0.5` mean half of a number the seam then ate * into. Give it padding to make the target larger — the room it takes is * measured, so the arithmetic follows. */ function SplitViewDragArea({ className, accessibilityLabel = 'Resize panes', children, onLayout, ...props }: SplitViewDragAreaProps) { const context = useSplitViewContext('SplitView.DragArea'); const { dragArea } = splitViewVariants({ variant: context.variant }); const { topHeight, dragging, points, minPx, maxPx, room, disabled, snapTo } = context; const start = useSharedValue(0); const landed = useSharedValue(-1); const settle = useCallback((index: number) => snapTo(index), [snapTo]); const pan = useMemo( () => Gesture.Pan() .enabled(!disabled && points.length > 0) // The seam only answers to the axis it moves on, so a split view inside // a scroller leaves the scroll alone. .activeOffsetY([-8, 8]) .failOffsetX([-16, 16]) .onBegin(() => { start.value = topHeight.value; dragging.value = true; landed.value = -1; }) .onUpdate((event) => { if (room <= 0) return; topHeight.value = clamp(start.value + event.translationY, minPx, maxPx); // The moment worth feeling: passing the point the pane would settle // at if the finger let go now. It is the only feedback that says a // drag has a destination rather than a position. const next = nearestSnapIndex(topHeight.value, points, 0, room); if (next !== landed.value) { landed.value = next; runOnJS(selectionTick)(); } }) .onFinalize((event) => { dragging.value = false; if (room <= 0) return; const index = nearestSnapIndex( topHeight.value, points, event.velocityY ?? 0, room ); runOnJS(settle)(index); }), [disabled, dragging, landed, maxPx, minPx, points, room, settle, start, topHeight] ); const handleLayout = useCallback( (event: LayoutChangeEvent) => { context.measureDragArea(event.nativeEvent.layout.height); onLayout?.(event); }, [context, onLayout] ); /* * A step moves to the next snap point rather than by a distance, because the * snap points are the positions this control has. Announcing a percentage * somebody cannot stop at would be describing a different control. */ const step = useCallback( (direction: 1 | -1) => { const next = clamp(context.snapIndex + direction, 0, points.length - 1); if (next !== context.snapIndex) snapTo(next); }, [context.snapIndex, points.length, snapTo] ); return ( { if (event.nativeEvent.actionName === 'increment') step(1); else if (event.nativeEvent.actionName === 'decrement') step(-1); } } className={dragArea({ className })} onLayout={handleLayout} > {children} ); } export interface SplitViewHandleProps extends ViewProps { className?: string; children?: ReactNode; } /** * The grip inside the drag area. It grows a little while the seam is moving, * which is the only thing on screen saying the gesture was received before the * panes have moved far enough to say it themselves. */ function SplitViewHandle({ className, children, style, ...props }: SplitViewHandleProps) { const context = useSplitViewContext('SplitView.Handle'); const { handle } = splitViewVariants({ variant: context.variant }); const { animate } = context; const animatedStyle = useAnimatedStyle(() => { if (!animate) return { transform: [{ scaleX: 1 }] }; return { transform: [ { scaleX: withSpring(context.dragging.value ? 1.15 : 1, GRIP_SPRING) }, ], }; }); if (children) { return ( {children} ); } return ( ); } SplitViewRoot.displayName = 'SplitView'; SplitViewTop.displayName = 'SplitView.Top'; SplitViewBottom.displayName = 'SplitView.Bottom'; SplitViewDragArea.displayName = 'SplitView.DragArea'; SplitViewHandle.displayName = 'SplitView.Handle'; export const SplitView = Object.assign(SplitViewRoot, { Top: SplitViewTop, Bottom: SplitViewBottom, DragArea: SplitViewDragArea, Handle: SplitViewHandle, }); export { DEFAULT_MIN_HEIGHT, DEFAULT_SNAP_POINTS, nearestSnapIndex, normalizeSnapIndex, resolveLength, resolveSnapPoints, } from './split-view-math';