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.View
ref={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 && (
<Sticky
stickyWhileScrollY={offset()}
scrollY={scrollY.current}
onLayout={handleHeaderLayout}>
{props.renderHeader()}
</Sticky>
)
);
};
const smallHeader = () => {
return (
props.renderSmallHeader && (
<Animated.View
style={{
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.ScrollView
showsVerticalScrollIndicator={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通过
1163

被折叠的 条评论
为什么被折叠?



