【React Native】粘性布局StickyScrollView

React Native 实现粘性头部与动态缩放小头部组件(附源码解析)

一、前言

在移动端滚动视图(如商品详情页、个人中心页)中,粘性头部 是常见交互:大头部随滚动渐变消失,小头部固定在顶部并伴随缩放/透明度动画,同时TabBar跟随粘性定位。本文基于react-nativeAnimated模块,实现一个可复用的粘性滚动组件。

二、组件概述

本组件StickyScrollView核心功能:

  1. 大头部:初始完全显示,滚动时透明度从1→0渐变消失
  2. 小头部 :滚动到顶部时固定,伴随透明度(渐显)动画
  3. TabBar:跟随小头部保持粘性定位
  4. 通用粘性逻辑 :通过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

负责管理滚动状态、动画计算和子元素渲染。

关键逻辑说明:
  1. 状态管理

    • scrollY:记录滚动位置,驱动所有动画。
    • headerHeight/smallHeaderHeight:记录大/小头部的实际高度,用于计算动画参数。
  2. 动画计算

    • 大头部透明度:滚动距离从0到大头部高度时,透明度从1→0。
    • 小头部透明度:滚动到小头部底部附近时,从0→1渐显。
  3. 渲染逻辑

    • 大头部和TabBar通过Sticky组件实现粘性。
    • 小头部使用Animated.View直接渲染,绑定缩放和透明度动画。
相关推荐
wordbaby7 小时前
React Native 进阶实战:基于 Server-Driven UI 的动态表单架构设计
前端·react native·react.js
胡琦博客1 天前
21天开源鸿蒙训练营|Day2 ReactNative 开发 OpenHarmony 应用环境搭建实录
javascript·react native·react.js
6***37941 天前
React Native热更新方案
javascript·react native·react.js
x***J3481 天前
React Native组件封装
javascript·react native·react.js
E***U9451 天前
React Native开发
android·react native·react.js
t***L2661 天前
React Native真机调试连接不上的解决
javascript·react native·react.js
Tamarous1 天前
React Native 通信机制详解 - 新架构
react native
Tamarous1 天前
React Native 通信机制详解 - 旧架构
react native
U***49832 天前
React Native性能分析
javascript·react native·react.js
不羁的木木2 天前
【开源鸿蒙跨平台开发学习笔记】Day02:React Native 开发 HarmonyOS-环境搭建篇(填坑记录)
笔记·学习·react native·harmonyos·har