React Native 实现粘性头部与动态缩放小头部组件(附源码解析)
一、前言
在移动端滚动视图(如商品详情页、个人中心页)中,粘性头部 是常见交互:大头部随滚动渐变消失,小头部固定在顶部并伴随缩放/透明度动画,同时TabBar跟随粘性定位。本文基于react-native的Animated模块,实现一个可复用的粘性滚动组件。
二、组件概述
本组件StickyScrollView核心功能:
- 大头部:初始完全显示,滚动时透明度从1→0渐变消失
- 小头部 :滚动到顶部时固定,伴随透明度(渐显)动画
- TabBar:跟随小头部保持粘性定位
- 通用粘性逻辑 :通过
Sticky组件复用,支持任意元素粘性
三、快速上手(使用示例)
直接复制以下代码,替换renderXXX函数即可快速使用:
tsx
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。
tsx
import React, {forwardRef, useCallback, useMemo, useRef, useState} from 'react';
import {
Animated,
LayoutChangeEvent,
View,
ViewProps,
type ViewStyle,
} from 'react-native';
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 = useCallback(
(event: LayoutChangeEvent) => {
setPosY(event.nativeEvent.layout.y);
onLayout?.(event);
},
[onLayout],
);
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>
);
},
);
interface StickyContainerProps {
renderHeader?: () => React.ReactNode;
renderSmallHeader?: () => React.ReactNode;
renderContent?: () => React.ReactNode;
renderTabBar?: () => React.ReactNode;
}
const StickyScrollView = (props: StickyContainerProps) => {
const scrollY = useRef(new Animated.Value(0));
const [headerHeight, setHeaderHeight] = useState(0);
const [smallHeaderHeight, setSmallHeaderHeight] = useState(0);
const bigHeaderOpacity = useMemo(() => {
if (headerHeight <= 0 || !scrollY.current) return 1; // 初始状态完全显示
return scrollY.current.interpolate({
inputRange: [0, headerHeight],
outputRange: [1, 0],
extrapolate: 'clamp', // 超出范围保持边界值
});
}, [headerHeight, scrollY.current]);
const smallHeaderOpacity = useMemo(() => {
if (smallHeaderHeight <= 0 || !scrollY.current) return 0; // 默认隐藏
return scrollY.current.interpolate({
inputRange: [smallHeaderHeight - 5, smallHeaderHeight],
outputRange: [0, 1],
extrapolate: 'clamp', // 超出范围保持边界值
});
}, [smallHeaderHeight, scrollY.current]);
const calSmallHeaderScale = useMemo(() => {
if (smallHeaderHeight <= 0 || !scrollY.current) return 0; // 默认隐藏
return scrollY.current.interpolate({
inputRange: [0, headerHeight - smallHeaderHeight],
outputRange: [headerHeight / smallHeaderHeight, 1],
extrapolate: 'clamp', // 超出范围保持边界值
});
}, [smallHeaderHeight, scrollY.current]);
const handleHeaderLayout = useCallback(
(event: LayoutChangeEvent) => {
setHeaderHeight(event.nativeEvent.layout.height);
},
[setHeaderHeight],
);
const handleSmallHeaderLayout = useCallback(
(event: LayoutChangeEvent) => {
if (calSmallHeaderScale <= 0) {
setSmallHeaderHeight(event.nativeEvent.layout.height);
}
},
[setSmallHeaderHeight],
);
const header = useCallback(() => {
return (
props.renderHeader && (
<Sticky
stickyWhileScrollY={smallHeaderHeight}
scrollY={scrollY.current}
onLayout={handleHeaderLayout}>
{props.renderHeader()}
</Sticky>
)
);
}, [handleHeaderLayout, bigHeaderOpacity]);
const smallHeader = useCallback(() => {
return (
props.renderSmallHeader && (
<Animated.View
style={{
width: '100%',
zIndex: 3,
opacity: smallHeaderOpacity,
position: 'absolute',
top: 0,
}}
onLayout={handleSmallHeaderLayout}>
{props.renderSmallHeader()}
</Animated.View>
)
);
}, [smallHeaderOpacity]);
const tab = useCallback(() => {
return (
props.renderTabBar && (
<Sticky
stickyWhileScrollY={smallHeaderHeight}
scrollY={scrollY.current}>
{props.renderTabBar()}
</Sticky>
)
);
}, [smallHeaderHeight]);
const content = useCallback(() => {
return props.renderContent && props.renderContent();
}, []);
return (
<View>
<Animated.ScrollView
showsVerticalScrollIndicator={false}
onScroll={Animated.event(
[
{
nativeEvent: {contentOffset: {y: scrollY.current}},
},
],
{useNativeDriver: true},
)}
scrollEventThrottle={1}>
{header()}
{tab()}
{content()}
</Animated.ScrollView>
{smallHeader()}
</View>
);
};
export default StickyScrollView;
1. 通用粘性组件:Sticky
负责实现元素的粘性定位 ,核心是通过translateY动画控制元素滚动时的偏移。
关键逻辑说明:
posY:记录元素布局后的Y坐标(通过onLayout回调获取)。translateY:通过scrollY.interpolate计算偏移量。当滚动到triggerY(粘性阈值)时,元素开始向下偏移,视觉上保持"固定"。
2. 滚动容器:StickyScrollView
负责管理滚动状态、动画计算和子元素渲染。
关键逻辑说明:
-
状态管理:
scrollY:记录滚动位置,驱动所有动画。headerHeight/smallHeaderHeight:记录大/小头部的实际高度,用于计算动画参数。
-
动画计算:
- 大头部透明度:滚动距离从0到大头部高度时,透明度从1→0。
- 小头部透明度:滚动到小头部底部附近时,从0→1渐显。
-
渲染逻辑:
- 大头部和TabBar通过
Sticky组件实现粘性。 - 小头部使用
Animated.View直接渲染,绑定缩放和透明度动画。
- 大头部和TabBar通过