【React Native】粘性布局StickyScrollView

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

一、前言

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

二、组件概述

本组件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直接渲染,绑定缩放和透明度动画。
相关推荐
enjoywindstorm2 天前
移动端原生框架对比评测
react native
传奇开心果编程3 天前
【声明式UI开发实用技术学与练】第8课 状态提升与下放
学习·flutter·react native·ui·swiftui·composer
传奇开心果编程4 天前
【现代声明式UI学与练】第4课 列表渲染与 key——如何高效渲染列表、key 的作用、列表重排时的状态保持
学习·flutter·react native·ui·swiftui·android jetpack
传奇开心果编程4 天前
【现代声明式UI学与练】第7课 组件通信——父传子、子传父、跨层级通信、状态提升与 Context
学习·flutter·react native·ui·swiftui·android jetpack
500845 天前
React Native for OpenHarmony 实战:三方库 react-native-url-polyfill 的鸿蒙化适配指南
javascript·react native·react.js·性能优化·electron·harmonyos
传奇开心果编程5 天前
【现代声明式UI学与练】第9课 性能优化——渲染优化、列表优化、内存优化、启动优化
学习·flutter·react native·ui·性能优化·swiftui·android jetpack
500845 天前
React Native for OpenHarmony 实战:三方库 react-native-volume-control 的鸿蒙化适配指南
javascript·react native·react.js·electron·harmonyos
5008410 天前
React Native for OpenHarmony 实战:三方库 react-native-crypto-js 的鸿蒙化适配指南
javascript·react native·性能优化·electron·harmonyos
5008410 天前
React Native for OpenHarmony 实战:三方库 react-native-device-name 的鸿蒙化适配指南
深度学习·react native·react.js·机器学习·harmonyos
5008410 天前
React Native for OpenHarmony 实战:三方库 react-native-torch 的鸿蒙化适配指南
javascript·react native·react.js·harmonyos