【React Native】粘性布局StickyScrollView
React Native 实现粘性头部与动态缩放小头部组件(附源码解析)
一、前言
在移动端滚动视图(如商品详情页、个人中心页)中,粘性头部是常见交互:大头部随滚动渐变消失,小头部固定在顶部并伴随缩放/透明度动画,同时TabBar跟随粘性定位。本文基于react-native的Animated模块,实现一个可复用的粘性滚动组件。
二、组件概述
本组件StickyScrollView核心功能:
- 大头部:初始完全显示,滚动到一定距离后粘性定位
- 小头部:滚动到顶部时固定,伴随透明度(渐显)和缩放动画
- TabBar:跟随大头部保持粘性定位
- 通用粘性逻辑:通过
Sticky组件复用,支持任意元素粘性 - Ref暴露:通过forwardRef暴露scrollY和headerHeight给父组件
- 滚动回调:支持自定义onScroll回调函数
- 可配置粘性偏移:支持自定义stickyHeaderLeftOffset调整粘性定位偏移量
三、快速上手(使用示例)
直接复制以下代码,替换renderXXX函数即可快速使用:
import StickyScrollView from './StickyScrollView';const DemoPage = () => {return (<StickyScrollView// 大头部(初始显示,滚动渐变消失)renderHeader={() => (<View style={{ height: 100, backgroundColor: '#ff5722' }}><Text>大头部(滚动消失)</Text></View>)}// 小头部(固定顶部,带缩放/透明度)renderSmallHeader={() => (<View style={{ height: 50, backgroundColor: '#2196f3' }}><Text>小头部(固定+缩放)</Text></View>)}// TabBar(跟随小头部粘性)renderTabBar={() => (<View style={{ height: 40, backgroundColor: '#4caf50' }}><Text>TabBar(粘性)</Text></View>)}// 主内容(填充剩余空间)renderContent={() => (<View style={{ height: 5000, backgroundColor: '#f0f0f0' }}><Text>主内容区域</Text></View>)}/>);
);export default DemoPage;
四、源码解析
组件分为两部分:通用粘性组件Sticky和滚动容器StickyScrollView。
import React, {forwardRef,useImperativeHandle,useMemo,useRef,useState,
} from 'react';
import {Animated,LayoutChangeEvent,View,ViewProps,type ViewStyle,
} from 'react-native';
import {NativeSyntheticEvent} from 'react-native/Libraries/Types/CoreEventTypes';
import {NativeScrollEvent} from 'react-native/Libraries/Components/ScrollView/ScrollView';const Sticky = forwardRef<typeof Animated.View & View,{stickyWhileScrollY?: number;scrollY: Animated.Value;} & ViewProps
>(({stickyWhileScrollY, scrollY, children, style, onLayout, ...otherProps},ref,) => {const [posY, setPosY] = useState(0);const handleLayout = (event: LayoutChangeEvent) => {setPosY(event.nativeEvent.layout.y);onLayout?.(event);};const translateY = useMemo(() => {const bY = stickyWhileScrollY ? stickyWhileScrollY : posY;return scrollY.interpolate({inputRange: [-1, 0, bY, bY + 1],outputRange: [0, 0, 0, 1],});}, [stickyWhileScrollY, posY, scrollY]);return (<Animated.Viewref={ref}style={[style,{position: 'relative',zIndex: 1,},{transform: [{translateY}]} as ViewStyle,]}onLayout={handleLayout}{...otherProps}>{children}</Animated.View>);},
);export interface StickyScrollViewRef {scrollY: Animated.Value;headerHeight: number;
}interface StickyScrollViewProps {renderHeader?: () => React.ReactNode;renderSmallHeader?: () => React.ReactNode;renderContent?: () => React.ReactNode;renderTabBar?: () => React.ReactNode;style?: ViewStyle;onScroll?:| ((event: NativeSyntheticEvent<NativeScrollEvent>) => void)| undefined;// 头布局折叠剩余距离,默认为 0,完全折叠stickyHeaderLeftOffset?: number;
}const StickyScrollView = forwardRef<StickyScrollViewRef, StickyScrollViewProps>((props: StickyScrollViewProps, ref) => {const scrollY = useRef(new Animated.Value(0));const [headerHeight, setHeaderHeight] = useState(0);const [smallHeaderHeight, setSmallHeaderHeight] = useState(0);useImperativeHandle(ref, () => ({scrollY: scrollY.current,headerHeight: headerHeight,}));const smallHeaderOpacity = () => {if (smallHeaderHeight <= 0 || !scrollY.current) return 0; // 默认隐藏return scrollY.current.interpolate({inputRange: [headerHeight - smallHeaderHeight - 5,headerHeight - smallHeaderHeight,],outputRange: [0, 1],extrapolate: 'clamp', // 超出范围保持边界值});};const calSmallHeaderScale = () => {if (smallHeaderHeight <= 0 || !scrollY.current) return 0; // 默认隐藏return scrollY.current.interpolate({inputRange: [0, headerHeight - smallHeaderHeight],outputRange: [headerHeight / smallHeaderHeight, 1],extrapolate: 'clamp', // 超出范围保持边界值});};const handleHeaderLayout = (event: LayoutChangeEvent) => {setHeaderHeight(event.nativeEvent.layout.height);};const handleSmallHeaderLayout = (event: LayoutChangeEvent) => {if (calSmallHeaderScale() <= 0) {setSmallHeaderHeight(event.nativeEvent.layout.height);}};const offset = () => {let a =headerHeight - smallHeaderHeight - (props.stickyHeaderLeftOffset ?? 0);return a < 0 ? 0 : a;};const header = () => {return (props.renderHeader && (<StickystickyWhileScrollY={offset()}scrollY={scrollY.current}onLayout={handleHeaderLayout}>{props.renderHeader()}</Sticky>));};const smallHeader = () => {return (props.renderSmallHeader && (<Animated.Viewstyle={{width: '100%',zIndex: 3,opacity: smallHeaderOpacity(),position: 'absolute',top: 0,}}onLayout={handleSmallHeaderLayout}>{props.renderSmallHeader()}</Animated.View>));};const tab = () => {return (props.renderTabBar && (<Sticky stickyWhileScrollY={offset()} scrollY={scrollY.current}>{props.renderTabBar()}</Sticky>));};const content = () => {return props.renderContent && props.renderContent();};return (<View style={props.style}><Animated.ScrollViewshowsVerticalScrollIndicator={false}onScroll={Animated.event([{nativeEvent: {contentOffset: {y: scrollY.current}},},],{useNativeDriver: true, listener: props.onScroll},)}scrollEventThrottle={1}>{header()}{tab()}{content()}</Animated.ScrollView>{smallHeader()}</View>);},
);export default StickyScrollView;
1. 通用粘性组件:Sticky
负责实现元素的粘性定位,核心是通过translateY动画控制元素滚动时的偏移。该组件使用forwardRef接收和传递ref,支持外部访问和控制。
关键逻辑说明:
posY:记录元素布局后的Y坐标(通过onLayout回调获取)。stickyWhileScrollY:可选的粘性触发阈值,如果未提供则使用元素自身的Y坐标。translateY:通过scrollY.interpolate计算偏移量。当滚动到阈值时,元素开始向下偏移,视觉上保持“固定”效果。- 动画插值:当滚动位置在[-1, bY]范围内时,元素正常滚动;当超过bY时,开始产生偏移。
2. 滚动容器:StickyScrollView
负责管理滚动状态、动画计算和子元素渲染。组件使用forwardRef暴露滚动状态给父组件。
关键逻辑说明:
-
状态管理:
scrollY:记录滚动位置,驱动所有动画。headerHeight/smallHeaderHeight:记录大/小头部的实际高度,用于计算动画参数。ref暴露:通过useImperativeHandle向父组件暴露scrollY和headerHeight,便于自定义扩展。
-
粘性阈值计算:
offset()函数:计算粘性触发的Y坐标,支持通过stickyHeaderLeftOffset配置头布局折叠后的剩余距离。
-
动画计算:
- 小头部透明度:当滚动位置接近大头部底部时,小头部从透明(0)渐变到不透明(1)。
- 小头部缩放:当滚动位置在[0, headerHeight - smallHeaderHeight]范围内时,小头部从放大状态(比例为headerHeight/smallHeaderHeight)缩小到原始大小(1)。
-
布局测量:
handleHeaderLayout:测量并记录大头部的实际高度。handleSmallHeaderLayout:测量并记录小头部的实际高度,确保只在缩放比例为0时更新。
-
渲染逻辑:
- 大头部和TabBar通过
Sticky组件实现粘性,粘性阈值为offset()函数返回值。 - 小头部使用绝对定位(
position: 'absolute')固定在顶部,绑定缩放和透明度动画。 - 支持通过
onScroll属性自定义滚动事件处理。
- 大头部和TabBar通过
