/** * ScrollFade — fades the edges of a scroll container. * * Content that runs past a boundary reads better fading out than being cut * off, and the fade doubles as an affordance: an edge that is fading is an * edge with more content behind it. * * Scroll position, content size and viewport size are all held in shared * values and consumed by `useAnimatedStyle`, so the fades track the scroll on * the UI thread without re-rendering React. * * ```tsx * * … * * ``` */ import { Children, cloneElement, isValidElement, useMemo, type ComponentType, type ReactElement, type ReactNode, } from 'react'; import { StyleSheet, View, type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent, type ViewProps, } from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; import Animated, { useAnimatedScrollHandler, useAnimatedStyle, useComposedEventHandler, useDerivedValue, useSharedValue, type AnimatedScrollViewProps, type DerivedValue, } from 'react-native-reanimated'; import { useCSSVariable } from 'uniwind'; /** Past this many pixels from an edge, that edge's fade is fully shown. */ const DEFAULT_FADE_IN_DISTANCE = 24; export interface ScrollFadeProps extends ViewProps { className?: string; /** Depth of the fade in pixels. */ size?: number; /** Which edges fade. */ edges?: 'both' | 'start' | 'end' | 'none'; /** * Scroll axis. Inferred from the child's `horizontal` prop when omitted — * pass it explicitly for children that scroll horizontally without that prop * (a `FlatList` with `horizontal` set through `contentContainerStyle`, say). */ orientation?: 'horizontal' | 'vertical'; /** * Colour the fade resolves to — normally whatever sits behind the * scrollable. Defaults to the theme's background. */ color?: string; /** Distance in pixels over which an edge fades from clear to full. */ fadeInDistance?: number; /** Set false to render the child with no fades at all. */ enabled?: boolean; children?: ReactNode; } interface ScrollableProps { horizontal?: boolean; onScroll?: AnimatedScrollViewProps['onScroll']; onLayout?: (event: LayoutChangeEvent) => void; onContentSizeChange?: (width: number, height: number) => void; scrollEventThrottle?: number; } export function ScrollFade({ className, size = 48, edges = 'both', orientation, color, fadeInDistance = DEFAULT_FADE_IN_DISTANCE, enabled = true, children, ...props }: ScrollFadeProps) { const offset = useSharedValue(0); const contentLength = useSharedValue(0); const viewportLength = useSharedValue(0); const themeBackground = useCSSVariable('--color-background'); const fadeColor = color ?? (typeof themeBackground === 'string' ? themeBackground : '#000000'); const child = Children.only(children) as ReactElement; const horizontal = orientation !== undefined ? orientation === 'horizontal' : isValidElement(child) && !!child.props.horizontal; // Reanimated can only drive a scroll handler on an animated component, and // `Animated.ScrollView` is not necessarily what was passed in — the child may // be a FlatList, a SectionList or a custom scrollable. Keyed on the element // *type*, not the element: rebuilding the wrapper would remount the list. const childType = isValidElement(child) ? (child.type as ComponentType) : null; const AnimatedScrollable = useMemo( () => (childType ? Animated.createAnimatedComponent(childType) : null), [childType] ); const scrollHandler = useAnimatedScrollHandler({ onScroll: (event) => { const { contentOffset, contentSize, layoutMeasurement } = event; offset.value = horizontal ? contentOffset.x : contentOffset.y; contentLength.value = horizontal ? contentSize.width : contentSize.height; viewportLength.value = horizontal ? layoutMeasurement.width : layoutMeasurement.height; }, }); // A consumer `onScroll` is composed rather than dropped — but because the // child is now an animated component it has to be an animated handler too. const onScroll = useComposedEventHandler([ scrollHandler, (child.props.onScroll as typeof scrollHandler | undefined) ?? null, ]); // Measured up front as well as on scroll, so an end edge with content behind // it fades from the first frame rather than waiting for a scroll event. const onLayout = (event: LayoutChangeEvent) => { const { width, height } = event.nativeEvent.layout; viewportLength.value = horizontal ? width : height; child.props.onLayout?.(event); }; const onContentSizeChange = (width: number, height: number) => { contentLength.value = horizontal ? width : height; child.props.onContentSizeChange?.(width, height); }; const startOpacity = useDerivedValue(() => Math.min(offset.value / fadeInDistance, 1) ); const endOpacity = useDerivedValue(() => { // Nothing to fade towards when the content fits inside the viewport. const remaining = contentLength.value - viewportLength.value - offset.value; return Math.min(Math.max(remaining, 0) / fadeInDistance, 1); }); const scrollable = AnimatedScrollable && isValidElement(child) ? ( ) : ( child ); if (!enabled || edges === 'none') { return ( {scrollable} ); } return ( {scrollable} {edges !== 'end' ? ( ) : null} {edges !== 'start' ? ( ) : null} ); } ScrollFade.displayName = 'ScrollFade'; /** One edge's gradient, opaque at the edge and clear inwards. */ function Fade({ color, size, horizontal, opacity, edge, }: { color: string; size: number; horizontal: boolean; opacity: DerivedValue; edge: 'start' | 'end'; }) { const isStart = edge === 'start'; const position = horizontal ? { top: 0, bottom: 0, width: size, ...(isStart ? { left: 0 } : { right: 0 }) } : { left: 0, right: 0, height: size, ...(isStart ? { top: 0 } : { bottom: 0 }) }; const style = useAnimatedStyle(() => ({ opacity: opacity.value })); // The gradient runs from the edge inwards, so its stops are reversed on the // end edge. const colors: [string, string] = isStart ? [withAlpha(color, 1), withAlpha(color, 0)] : [withAlpha(color, 0), withAlpha(color, 1)]; return ( ); } /** * Gradients need a transparent stop of the *same* colour — `transparent` is * black at zero alpha on Android, which shows as a grey smear. */ function withAlpha(color: string, alpha: number): string { if (color.startsWith('#')) { const hex = color.slice(1); const full = hex.length === 3 ? hex .split('') .map((c) => c + c) .join('') : hex.slice(0, 6); const r = parseInt(full.slice(0, 2), 16); const g = parseInt(full.slice(2, 4), 16); const b = parseInt(full.slice(4, 6), 16); if (Number.isNaN(r + g + b)) return color; return `rgba(${r}, ${g}, ${b}, ${alpha})`; } const channels = color.match(/rgba?\(([^)]+)\)/)?.[1]; if (channels) { const [r, g, b] = channels.split(',').map((part) => part.trim()); return `rgba(${r}, ${g}, ${b}, ${alpha})`; } return color; }