/**
* Compare — two versions of one picture, with a seam the reader drags across
* it.
*
* ```tsx
*
*
*
*
*
*
*
*
*
* ```
*
* ## What it is for
*
* Showing that two images differ, when they differ in a way a pair of
* thumbnails side by side will not carry. Retouching, a filter, a render at two
* quality settings, a map at two dates: the change is spread across the frame
* rather than gathered in one place, and the eye cannot hold one image well
* enough to spot it in the other.
*
* The seam works because both versions are in the same place on the screen at
* the same scale. Every pixel the reader is comparing is a pixel that was just
* under the one beside it, so the difference arrives as movement rather than as
* something to be remembered.
*
* ## It clips, it does not resize
*
* The revealed side is a window onto a full-size copy of its content, not a
* copy of the content squeezed into the window. That distinction is the whole
* implementation: a view whose width is animated will lay its children out
* again at every new width, so an image inside one is an image being squashed
* and stretched as the seam moves, and the two halves stop lining up — which
* is the one thing this component exists to guarantee.
*
* It follows that the content has to be told how big to be, and the only thing
* that knows is the container once it has been measured. So nothing is drawn
* until the first layout pass has run, and `Compare.Before` sizes its child to
* the measured box rather than to itself.
*
* ## It needs a height
*
* Everything inside is positioned absolutely, so the box has no height of its
* own to take from its content. `height` is what gives it one. An image told to
* fill the box will fill whatever height is set here, and cropping is
* `resizeMode` on the image rather than anything this component does.
*
* ## Dragging, and the two ways round it
*
* The whole frame is the drag target, not just the knob — a knob is a small
* thing to hit on a phone and the reader's finger is already over the picture.
* The gesture only claims horizontal movement, so a Compare inside a scrolling
* page still scrolls.
*
* A drag is not available to everyone, so the seam is also `adjustable`: a
* screen reader moves it a `step` at a time without one, and `value` drives it
* from anywhere else — a button that snaps to the ends, an animation, a slider
* somewhere else on the screen.
*/
import {
Children,
createContext,
isValidElement,
useCallback,
useContext,
useEffect,
useMemo,
useState,
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,
useAnimatedReaction,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
type SharedValue,
} from 'react-native-reanimated';
import { tv } from 'tailwind-variants';
import { Text } from '../../primitives/text';
import { cn } from '../../utils/cn';
import { selectionTick } from '../../utils/haptics';
import { useDirectionSign } from '../../hooks/use-direction';
/** How tall the frame is when the caller does not say. */
const DEFAULT_HEIGHT = 240;
/** The knob's diameter, and so the width of the column the seam lives in. */
const KNOB = 36;
/** How far a screen reader's increment moves the seam, as a share of the box. */
const DEFAULT_STEP = 0.05;
/** Where the seam starts: the middle, so both sides are equally on show. */
const DEFAULT_VALUE = 0.5;
/** Movement along the axis before the drag is the gesture's rather than a scroll's. */
const CLAIM = 8;
/** And movement across it that hands the gesture back. */
const YIELD = 16;
export type CompareOrientation = 'horizontal' | 'vertical';
const compareVariants = tv({
slots: {
root: 'w-full overflow-hidden rounded-xl bg-muted',
seam: 'absolute items-center justify-center',
line: 'absolute bg-background',
knob: 'items-center justify-center rounded-full border border-border bg-background shadow-md',
grip: 'rounded-full bg-foreground/60',
},
variants: {
orientation: {
horizontal: {
seam: 'bottom-0 top-0',
line: 'h-full w-0.5',
grip: 'h-3.5 w-1',
},
vertical: {
seam: 'left-0 right-0',
line: 'h-0.5 w-full',
grip: 'h-1 w-3.5',
},
},
},
defaultVariants: { orientation: 'horizontal' },
});
interface CompareContextValue {
/** Where the seam is, `0` to `1` of the box, on the UI thread. */
ratio: SharedValue;
/** The measured box. Nothing is drawn until both are above zero. */
width: number;
height: number;
orientation: CompareOrientation;
/**
* True where the frame reads right to left. A horizontal seam is anchored to
* the reading edge, so everything positioned along that axis flips with it.
*/
mirrored: boolean;
disabled: boolean;
step: number;
/** Moves the seam and reports it, for the paths that are not the drag. */
nudge: (delta: number) => void;
}
const CompareContext = createContext(null);
function useCompare(component: string): CompareContextValue {
const context = useContext(CompareContext);
if (!context) {
throw new Error(`${component} must be used within a `);
}
return context;
}
function clamp(value: number): number {
'worklet';
return value < 0 ? 0 : value > 1 ? 1 : value;
}
export interface CompareProps extends Omit {
className?: string;
/**
* How tall the frame is, in points.
*
* Required in practice rather than in the types: both sides are positioned
* absolutely, so there is no content left to give the box a height of its own.
*/
height?: number;
/** Where the seam sits, `0` to `1`. Leave unset to let the frame track it. */
value?: number;
/** Where it starts when the frame is tracking it itself. */
defaultValue?: number;
/** Fires while the seam moves, with its new position. */
onValueChange?: (value: number) => void;
/** Fires once, when the finger is lifted. The one to persist. */
onValueCommit?: (value: number) => void;
/** Which way the seam runs. */
orientation?: CompareOrientation;
/** Freezes the seam where it is and takes it out of the accessibility tree. */
disabled?: boolean;
/** How far one screen-reader increment moves the seam, `0` to `1`. */
step?: number;
/** A tick when the seam reaches either end. */
haptics?: boolean;
children?: ReactNode;
}
function CompareRoot({
className,
height = DEFAULT_HEIGHT,
value,
defaultValue = DEFAULT_VALUE,
onValueChange,
onValueCommit,
orientation = 'horizontal',
disabled = false,
step = DEFAULT_STEP,
haptics = true,
children,
...props
}: CompareProps) {
const [box, setBox] = useState({ width: 0, height: 0 });
const [internal, setInternal] = useState(defaultValue);
const sign = useDirectionSign();
const mirrored = sign === -1;
const controlled = value !== undefined;
const current = controlled ? value : internal;
const ratio = useSharedValue(clamp(current));
/** True between the finger landing and it lifting, so the prop can stand back. */
const dragging = useSharedValue(false);
/*
* A controlled `value` owns the seam except while a finger is on it. The
* drag writes the shared value directly and reports afterwards, so pushing
* the prop back in during the drag would fight it for the same number.
*/
const settled = clamp(current);
useEffect(() => {
if (controlled && !dragging.value) ratio.value = settled;
}, [controlled, settled, ratio, dragging]);
const commit = useCallback(
(next: number) => {
if (!controlled) setInternal(next);
onValueChange?.(next);
},
[controlled, onValueChange]
);
const finish = useCallback(
(next: number) => {
if (!controlled) setInternal(next);
onValueCommit?.(next);
},
[controlled, onValueCommit]
);
/*
* Reported a whole percent at a time rather than every frame. The seam itself
* never needs the JS thread — it is drawn from the shared value — so a
* callback per frame would be sixty re-renders a second spent on a number
* nobody can read that fast.
*/
const reported = useSharedValue(-1);
const report = useCallback(
(next: number) => {
onValueChange?.(next);
},
[onValueChange]
);
const nudge = useCallback(
(delta: number) => {
const next = clamp(ratio.value + delta);
ratio.value = next;
commit(next);
onValueCommit?.(next);
},
[ratio, commit, onValueCommit]
);
const horizontal = orientation === 'horizontal';
const extent = horizontal ? box.width : box.height;
const start = useSharedValue(0);
const edge = useSharedValue(false);
const pan = useMemo(() => {
const gesture = Gesture.Pan()
.enabled(!disabled)
.onBegin(() => {
start.value = ratio.value;
dragging.value = true;
edge.value = ratio.value <= 0 || ratio.value >= 1;
})
.onUpdate((event) => {
if (extent <= 0) return;
/*
* Under a right-to-left layout the leading edge is the right-hand one,
* so a drag towards it has to move the seam the other way for "towards
* the start" to keep meaning the same thing. Only the horizontal seam
* has a leading edge; down is down in both directions.
*/
const travelled = horizontal ? event.translationX * sign : event.translationY;
const next = clamp(start.value + travelled / extent);
ratio.value = next;
// The one moment in this drag worth feeling: arriving at an end, where
// the seam stops following the finger. Without it the stall reads as
// the gesture having been dropped.
const ended = next <= 0 || next >= 1;
if (ended !== edge.value) {
edge.value = ended;
if (ended && haptics) runOnJS(selectionTick)();
}
const percent = Math.round(next * 100);
if (percent !== reported.value) {
reported.value = percent;
runOnJS(report)(next);
}
})
.onFinalize(() => {
dragging.value = false;
reported.value = -1;
runOnJS(finish)(ratio.value);
});
/*
* The seam only answers to the axis it moves on. Without that it claims any
* movement at all, which is a Compare inside a scrolling page eating the
* scroll the moment a finger lands on the picture.
*/
return horizontal
? gesture.activeOffsetX([-CLAIM, CLAIM]).failOffsetY([-YIELD, YIELD])
: gesture.activeOffsetY([-CLAIM, CLAIM]).failOffsetX([-YIELD, YIELD]);
}, [
disabled,
extent,
horizontal,
sign,
haptics,
ratio,
start,
edge,
dragging,
reported,
report,
finish,
]);
const onLayout = (event: LayoutChangeEvent) => {
const { width: w, height: h } = event.nativeEvent.layout;
const next = { width: Math.round(w), height: Math.round(h) };
if (next.width !== box.width || next.height !== box.height) setBox(next);
props.onLayout?.(event);
};
const context = useMemo(
() => ({
ratio,
width: box.width,
height: box.height,
orientation,
mirrored,
disabled,
step,
nudge,
}),
[ratio, box.width, box.height, orientation, mirrored, disabled, step, nudge]
);
const { root } = compareVariants({ orientation });
/*
* The background side is drawn first whatever order the caller wrote the
* children in, because the revealed side is a window laid over it — reversed,
* the window would be underneath and nothing would show through.
*/
const layers: { base: ReactNode[]; clip: ReactNode[]; over: ReactNode[] } = {
base: [],
clip: [],
over: [],
};
Children.forEach(children, (child, index) => {
if (!isValidElement(child)) return;
const layer = (child.type as { layer?: keyof typeof layers }).layer ?? 'over';
layers[layer in layers ? layer : 'over'].push(
{child}
);
});
return (
{box.width > 0 && box.height > 0 ? (
<>
{layers.base}
{layers.clip}
{layers.over}
>
) : null}
);
}
function ChildSlot({ children }: { children: ReactNode }) {
return <>{children}>;
}
export interface CompareAfterProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/**
* The side the seam uncovers as it travels: the whole frame, underneath.
*
* It is drawn at full size and never clipped, so it is the one that decides
* what the frame looks like at either end of the travel.
*/
function CompareAfter({ className, children, ...props }: CompareAfterProps) {
const { width, height } = useCompare('Compare.After');
return (
{children}
);
}
CompareAfter.displayName = 'Compare.After';
CompareAfter.layer = 'base' as const;
export interface CompareBeforeProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/**
* The side on the near edge of the seam: a window onto the content, sized to
* the frame.
*
* The child is given the frame's measured size in points rather than a
* percentage. A percentage would be a percentage *of the window*, which shrinks
* as the seam closes — so the image would slide and scale under the seam
* instead of standing still behind it, and the two halves would no longer be
* the same picture in the same place.
*/
function CompareBefore({ className, children, ...props }: CompareBeforeProps) {
const { ratio, width, height, orientation, mirrored } = useCompare('Compare.Before');
const horizontal = orientation === 'horizontal';
/*
* A horizontal window opens from the edge the line starts at, which is the
* right-hand one under a right-to-left layout. Both the window and the copy
* inside it are pinned to that same edge — pinning only the window would
* slide the content along under the seam, which is the one thing the clip
* exists to prevent. Vertical is unaffected: down is down in both directions.
*/
const anchor = horizontal && mirrored ? 'right' : 'left';
const clip = useAnimatedStyle(() =>
horizontal
? { width: ratio.value * width, height }
: { width, height: ratio.value * height }
);
return (
{children}
);
}
CompareBefore.displayName = 'Compare.Before';
CompareBefore.layer = 'clip' as const;
export interface CompareHandleProps extends ViewProps {
className?: string;
/** Hide the two grip bars inside the knob. */
withGrip?: boolean;
/** Spoken name. */
accessibilityLabel?: string;
/** Replaces the knob. The line behind it is kept. */
children?: ReactNode;
}
/**
* The seam: a line across the frame with a knob on it.
*
* The knob is a marker rather than the target — the drag is on the whole frame,
* and a 36-point circle is not something to ask a thumb to find. What it is
* for is saying where the seam is and that it is the thing that moves, which a
* bare line does not.
*
* It carries the accessibility wiring for the same reason a splitter's handle
* does: it is the one part of this that is a control, so it is the part that is
* `adjustable` and takes the increment and decrement a screen reader sends
* instead of a drag.
*/
function CompareHandle({
className,
withGrip = true,
accessibilityLabel = 'Compare',
children,
style,
...props
}: CompareHandleProps) {
const { ratio, width, height, orientation, mirrored, disabled, step, nudge } =
useCompare('Compare.Handle');
const horizontal = orientation === 'horizontal';
const { seam, line, knob, grip } = compareVariants({ orientation });
// Pinned to the same edge the window opens from, and travelling away from it.
const anchor = horizontal && mirrored ? 'right' : 'left';
const towards = horizontal && mirrored ? -1 : 1;
const offset = useDerivedValue(() =>
horizontal ? ratio.value * width : ratio.value * height
);
const animatedStyle = useAnimatedStyle(() =>
horizontal
? { transform: [{ translateX: (offset.value - KNOB / 2) * towards }] }
: { transform: [{ translateY: offset.value - KNOB / 2 }] }
);
const [now, setNow] = useState(() => Math.round(ratio.value * 100));
// The spoken value only has to be right when a screen reader reads it, so it
// is pulled back to the JS thread a percent at a time rather than every frame
// of a drag.
useAnimatedReaction(
() => Math.round(ratio.value * 100),
(percent, previous) => {
if (percent !== previous) runOnJS(setNow)(percent);
}
);
const onAccessibilityAction = useCallback(
(event: AccessibilityActionEvent) => {
if (event.nativeEvent.actionName === 'increment') nudge(step);
else if (event.nativeEvent.actionName === 'decrement') nudge(-step);
},
[nudge, step]
);
return (
{children ?? (
{withGrip ? (
) : null}
)}
);
}
CompareHandle.displayName = 'Compare.Handle';
CompareHandle.layer = 'over' as const;
export interface CompareLabelProps extends ViewProps {
className?: string;
/** Which side of the frame it sits on. */
side?: 'start' | 'end';
children?: ReactNode;
}
/**
* A caption pinned to one corner, for saying which side is which.
*
* Worth adding wherever the two versions are not obviously an original and an
* edit — two dates, two settings, two models. Where they are, it is one more
* thing over the picture and the picture is the point.
*/
function CompareLabel({ className, side = 'start', children, ...props }: CompareLabelProps) {
const { orientation, mirrored } = useCompare('Compare.Label');
const horizontal = orientation === 'horizontal';
// `start` is the edge the line begins at, which the layout direction decides.
const start = horizontal && mirrored ? side !== 'start' : side === 'start';
return (
{typeof children === 'string' ? (
{children}
) : (
children
)}
);
}
CompareLabel.displayName = 'Compare.Label';
CompareLabel.layer = 'over' as const;
CompareRoot.displayName = 'Compare';
export const Compare = Object.assign(CompareRoot, {
Before: CompareBefore,
After: CompareAfter,
Handle: CompareHandle,
Label: CompareLabel,
});