/** * ScrollBlur — blurs the edges of a scroll container. * * Content that runs past a boundary reads better receding than being cut off, * and a receding edge doubles as an affordance: an edge that is going soft is * an edge with more content behind it. * * A blur says that where a fade cannot: a fade takes the content towards the * colour behind the scroller, so it only works where that colour is known and * flat. A blur takes it out of focus instead, which is true over a photograph, * a gradient, or a list of coloured cards. It is also what belongs under * something laid *over* the scroller — a button, a header, a search field — * because the content passing beneath stays visible as shape and colour while * losing the detail that would compete with the thing on top. * * ```tsx * * … * * ``` * * ## The ramp is a stack and a wash, because neither alone is smooth * * A blur that goes from nothing to full across a band needs a per-pixel blur * radius, and there is no such thing on either platform — a blur view has one * strength for its whole rectangle. * * So the ramp is built out of several of them: each layer covers a shorter * span than the last, measured from the edge, and each blurs what the layer * under it has already blurred. The spans are spaced on a curve rather than * evenly, which puts most of the layers in the outer third where the blur is * changing fastest and the steps would otherwise be widest. * * That alone is not enough. Every layer has a hard edge, and a stack of hard * edges is a stack of visible lines however many there are. So a gradient of * `color` is washed over the top — opaque at the outer edge, clear at the * inner one. It hides the seams, and it is what makes the band read as one * material rather than as a pile of rectangles: the content goes soft and * fades into the surface at the same time, which is what the eye expects an * edge to do. * * That wash is why `color` matters even when the blur is drawn. Give it the * surface the scrollable actually sits on — a sheet, a card, the page — or the * band fades towards a colour that is not there. * * ## Where it cannot blur, it fades * * A real blur needs a native view. `expo-blur` is optional, and Reduce * Transparency is a preference that outranks the design, so both cases fall * back to a gradient towards `color` — the same thing `ScrollFade` draws. A * blur you cannot draw is better shown as a fade than as a hard edge, and far * better than as a crash. * * Because of that fallback, pass `color` whenever the scroller does not sit on * the theme background. It is unused in the blurred case and the whole effect * in the other one. * * Scroll position, content size and viewport size are held in shared values * and read on the UI thread, so scrolling never re-renders React. */ import { Children, isValidElement, useMemo, type ComponentType, type ReactElement, type ReactNode, } from 'react'; import { StyleSheet, View, type LayoutChangeEvent, 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'; import { hasBlur, useReduceTransparency } from '../../primitives/scrim'; import { useThemeMode } from '../../theme/use-theme'; /** * `expo-blur`'s BlurView, or null when it is not installed. Resolved once at * module load — the require is cheap and caching it avoids a try/catch on * every render. */ const BlurView: ComponentType<{ intensity?: number; tint?: ScrollBlurTint; style?: unknown; }> | null = (() => { try { // eslint-disable-next-line @typescript-eslint/no-require-imports const mod = require('expo-blur'); return (mod?.BlurView as ComponentType<{ intensity?: number }>) ?? null; } catch { return null; } })(); /** Past this many pixels from an edge, that edge's band is fully drawn. */ const DEFAULT_FADE_IN_DISTANCE = 24; /** Which way the material tints. `default` follows the app's theme. */ export type ScrollBlurTint = 'light' | 'dark' | 'default'; export interface ScrollBlurProps extends ViewProps { className?: string; /** Depth of the blurred band in pixels. */ size?: number; /** Which edges blur. */ 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'; /** * How many blur views make up the ramp. More is smoother and costs more; the * band shows visible steps below four, and past eight nobody can tell. */ layers?: number; /** Blur strength at the very edge, 0–100. The layers share it between them. */ intensity?: number; /** * Which way the material tints. Defaults to the app's theme rather than the * phone's, so an app running dark on a light phone does not blur light. */ material?: ScrollBlurTint; /** * The colour the band fades towards — washed over the blur, and the whole * effect where there is no blur to draw. * * Defaults to the theme's background. Pass the surface the scrollable * actually sits on, or the band fades towards a colour that is not there. */ color?: string; /** * How opaque that wash gets at the outer edge, 0 to 1. Lower it to let more * of the content show through the far end of the band; `0` leaves the blur * bare, along with the seams between its layers. */ tint?: number; /** Distance in pixels over which an edge comes in from clear to full. */ fadeInDistance?: number; /** Set false to render the child with no bands 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 ScrollBlur({ className, size = 80, edges = 'both', orientation, layers = 8, intensity = 56, material = 'default', tint = 0.95, color, fadeInDistance = DEFAULT_FADE_IN_DISTANCE, enabled = true, children, ...props }: ScrollBlurProps) { const offset = useSharedValue(0); const contentLength = useSharedValue(0); const viewportLength = useSharedValue(0); const reduceTransparency = useReduceTransparency(); const { mode } = useThemeMode(); /* * Not knowing yet counts as "do not blur": the answer is asynchronous and * arriving a frame late costs nothing, while a blur flashed at somebody who * switched Reduce Transparency on is the whole thing that setting is for. */ const blurring = hasBlur && BlurView !== null && reduceTransparency === false; const materialTint: ScrollBlurTint = material === 'default' ? mode : material; 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 is drawn 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 blur 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} ); } const edgeProps = { size, horizontal, layers: Math.max(1, Math.round(layers)), intensity, material: materialTint, tint: Math.max(0, Math.min(1, tint)), blurring, color: fadeColor, }; return ( {scrollable} {edges !== 'end' ? ( ) : null} {edges !== 'start' ? ( ) : null} ); } ScrollBlur.displayName = 'ScrollBlur'; /** One edge's band: the stack of blur views, and the wash that joins them up. */ function Edge({ size, horizontal, layers, intensity, material, tint, blurring, color, opacity, edge, }: { size: number; horizontal: boolean; layers: number; intensity: number; material: ScrollBlurTint; tint: number; blurring: boolean; color: string; 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 })); /** How opaque the wash gets at the outer edge. Without a blur it is all of it. */ const full = blurring ? tint : 1; const steps = useMemo( () => Array.from({ length: layers }, (_unused, index) => { /* * Spans on a curve rather than evenly spaced. The blur's strength grows * fastest near the edge, so that is where the steps between layers * would be widest — squaring the fraction crowds most of the layers * into the outer third and spreads the rest thin across the inner * two, which is where nothing much is happening anyway. */ const fraction = (layers - index) / layers; return { key: index, // Cubed rather than squared: with eight layers a square curve still // leaves the outermost two nearly the same width, and two layers of // the same width are one layer at twice the cost. span: size * fraction * fraction * fraction, /* * The widest layer is the faintest, so the band starts from nothing * instead of stepping up at its inner boundary. Squared, so the * fade-in is gentle where the layers are widest and the eye has the * most room to notice one arriving. */ opacity: ((index + 1) / layers) ** 2, }; }), [layers, size] ); const along = (span: number) => horizontal ? { top: 0, bottom: 0, width: span, ...(isStart ? { left: 0 } : { right: 0 }) } : { left: 0, right: 0, height: span, ...(isStart ? { top: 0 } : { bottom: 0 }) }; return ( {blurring && BlurView ? steps.map((step) => ( )) : null} {/* The wash. Over the blur it hides the seam every layer's hard edge leaves and carries the content into the surface; without a blur to draw it is the whole band, which is the fade this degrades to. */} {tint > 0 || !blurring ? ( /* * Three stops, not two. A straight line from opaque to clear spends * half its length at alphas high enough to read as a flat panel, then * gives out all at once — the band gets an edge of its own, which is * the thing it exists to remove. The middle stop at just over a third * of the full alpha, placed past the halfway point, bends it into the * long tail an edge is supposed to have. */ ) : null} ); } /** * Gradients need a transparent stop of the *same* colour — `transparent` is * black at zero alpha on Android, which shows as a grey smear. * * Written out here rather than shared with `ScrollFade`, because each of these * components is also copied into a project on its own and a helper reached for * across two of them would arrive as a file nobody asked for. */ 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; }