【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直接渲染,绑定缩放和透明度动画。
相关推荐
chenbin___1 天前
react native中 createAsyncThunk 的详细说明,及用法示例(转自通义千问)
javascript·react native·react.js
前端拿破轮3 天前
ReactNative从入门到性能优化(一)
前端·react native·客户端
ideaout技术团队5 天前
android集成react native组件踩坑笔记(Activity局部展示RN的组件)
android·javascript·笔记·react native·react.js
洞窝技术6 天前
前端开发APP之跨平台开发(ReactNative0.74.5)
android·react native·ios
光影少年6 天前
React Native 第三章
javascript·react native·react.js
光影少年7 天前
React Navite 第二章
前端·react native·react.js·前端框架
月弦笙音8 天前
【React】19深度解析:掌握新一代React特性
javascript·react native·react.js
Amy_cx9 天前
搭建React Native开发环境
javascript·react native·react.js