【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直接渲染,绑定缩放和透明度动画。
相关推荐
chao_6666661 天前
React Native + Expo 开发指南:编译、调试、构建全解析
javascript·react native·react.js
_pengliang1 天前
react native ios 2个modal第二个不显示
javascript·react native·react.js
wayne2141 天前
React Native 0.80 学习参考:一个完整可运行的实战项目
学习·react native·react.js
坚果派·白晓明2 天前
Windows 11 OpenHarmony版React Native开发环境搭建完整指南
react native·开源鸿蒙·rnoh
开心不就得了3 天前
React Native对接Sunmi打印sdk
javascript·react native·react.js
Joyee6914 天前
RN 的初版架构——运行时异常与异常捕获处理
react native·前端框架
前端不太难4 天前
RN 列表状态设计 Checklist
react native·list·状态模式
hongkid4 天前
React Native 如何打包正式apk
javascript·react native·react.js
光影少年4 天前
前端如何虚拟列表优化?
前端·react native·react.js
千里马-horse5 天前
Rect Native bridging 源码分析--Bool.h
javascript·c++·react native·react.js·bool