【React Native】粘性布局StickyScrollView

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

一、前言

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

二、组件概述

本组件StickyScrollView核心功能:

  1. 大头部:初始完全显示,滚动到一定距离后粘性定位
  2. 小头部:滚动到顶部时固定,伴随透明度(渐显)和缩放动画
  3. TabBar:跟随大头部保持粘性定位
  4. 通用粘性逻辑:通过Sticky组件复用,支持任意元素粘性
  5. Ref暴露:通过forwardRef暴露scrollY和headerHeight给父组件
  6. 滚动回调:支持自定义onScroll回调函数
  7. 可配置粘性偏移:支持自定义stickyHeaderLeftOffset调整粘性定位偏移量

三、快速上手(使用示例)

直接复制以下代码,替换renderXXX函数即可快速使用:

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

import React, {
  forwardRef,
  useImperativeHandle,
  useMemo,
  useRef,
  useState,
} from 'react';
import {
  Animated,
  LayoutChangeEvent,
  View,
  ViewProps,
  type ViewStyle,
} from 'react-native';
import {NativeSyntheticEvent} from 'react-native/Libraries/Types/CoreEventTypes';
import {NativeScrollEvent} from 'react-native/Libraries/Components/ScrollView/ScrollView';

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 = (event: LayoutChangeEvent) => {
      setPosY(event.nativeEvent.layout.y);
      onLayout?.(event);
    };

    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>
    );
  },
);

export interface StickyScrollViewRef {
  scrollY: Animated.Value;
  headerHeight: number;
}

interface StickyScrollViewProps {
  renderHeader?: () => React.ReactNode;
  renderSmallHeader?: () => React.ReactNode;
  renderContent?: () => React.ReactNode;
  renderTabBar?: () => React.ReactNode;
  style?: ViewStyle;
  onScroll?:
    | ((event: NativeSyntheticEvent<NativeScrollEvent>) => void)
    | undefined;
  // 头布局折叠剩余距离,默认为 0,完全折叠
  stickyHeaderLeftOffset?: number;
}

const StickyScrollView = forwardRef<StickyScrollViewRef, StickyScrollViewProps>(
  (props: StickyScrollViewProps, ref) => {
    const scrollY = useRef(new Animated.Value(0));
    const [headerHeight, setHeaderHeight] = useState(0);
    const [smallHeaderHeight, setSmallHeaderHeight] = useState(0);

    useImperativeHandle(ref, () => ({
      scrollY: scrollY.current,
      headerHeight: headerHeight,
    }));

    const smallHeaderOpacity = () => {
      if (smallHeaderHeight <= 0 || !scrollY.current) return 0; // 默认隐藏
      return scrollY.current.interpolate({
        inputRange: [
          headerHeight - smallHeaderHeight - 5,
          headerHeight - smallHeaderHeight,
        ],
        outputRange: [0, 1],
        extrapolate: 'clamp', // 超出范围保持边界值
      });
    };

    const calSmallHeaderScale = () => {
      if (smallHeaderHeight <= 0 || !scrollY.current) return 0; // 默认隐藏
      return scrollY.current.interpolate({
        inputRange: [0, headerHeight - smallHeaderHeight],
        outputRange: [headerHeight / smallHeaderHeight, 1],
        extrapolate: 'clamp', // 超出范围保持边界值
      });
    };

    const handleHeaderLayout = (event: LayoutChangeEvent) => {
      setHeaderHeight(event.nativeEvent.layout.height);
    };

    const handleSmallHeaderLayout = (event: LayoutChangeEvent) => {
      if (calSmallHeaderScale() <= 0) {
        setSmallHeaderHeight(event.nativeEvent.layout.height);
      }
    };

    const offset = () => {
      let a =
        headerHeight - smallHeaderHeight - (props.stickyHeaderLeftOffset ?? 0);
      return a < 0 ? 0 : a;
    };

    const header = () => {
      return (
        props.renderHeader && (
          <Sticky
            stickyWhileScrollY={offset()}
            scrollY={scrollY.current}
            onLayout={handleHeaderLayout}>
            {props.renderHeader()}
          </Sticky>
        )
      );
    };

    const smallHeader = () => {
      return (
        props.renderSmallHeader && (
          <Animated.View
            style={{
              width: '100%',
              zIndex: 3,
              opacity: smallHeaderOpacity(),
              position: 'absolute',
              top: 0,
            }}
            onLayout={handleSmallHeaderLayout}>
            {props.renderSmallHeader()}
          </Animated.View>
        )
      );
    };

    const tab = () => {
      return (
        props.renderTabBar && (
          <Sticky stickyWhileScrollY={offset()} scrollY={scrollY.current}>
            {props.renderTabBar()}
          </Sticky>
        )
      );
    };

    const content = () => {
      return props.renderContent && props.renderContent();
    };

    return (
      <View style={props.style}>
        <Animated.ScrollView
          showsVerticalScrollIndicator={false}
          onScroll={Animated.event(
            [
              {
                nativeEvent: {contentOffset: {y: scrollY.current}},
              },
            ],
            {useNativeDriver: true, listener: props.onScroll},
          )}
          scrollEventThrottle={1}>
          {header()}
          {tab()}
          {content()}
        </Animated.ScrollView>
        {smallHeader()}
      </View>
    );
  },
);

export default StickyScrollView;

1. 通用粘性组件:Sticky

负责实现元素的粘性定位,核心是通过translateY动画控制元素滚动时的偏移。该组件使用forwardRef接收和传递ref,支持外部访问和控制。

关键逻辑说明:
  • posY:记录元素布局后的Y坐标(通过onLayout回调获取)。
  • stickyWhileScrollY:可选的粘性触发阈值,如果未提供则使用元素自身的Y坐标。
  • translateY:通过scrollY.interpolate计算偏移量。当滚动到阈值时,元素开始向下偏移,视觉上保持“固定”效果。
  • 动画插值:当滚动位置在[-1, bY]范围内时,元素正常滚动;当超过bY时,开始产生偏移。

2. 滚动容器:StickyScrollView

负责管理滚动状态、动画计算和子元素渲染。组件使用forwardRef暴露滚动状态给父组件。

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

    • scrollY:记录滚动位置,驱动所有动画。
    • headerHeight/smallHeaderHeight:记录大/小头部的实际高度,用于计算动画参数。
    • ref暴露:通过useImperativeHandle向父组件暴露scrollYheaderHeight,便于自定义扩展。
  2. 粘性阈值计算

    • offset()函数:计算粘性触发的Y坐标,支持通过stickyHeaderLeftOffset配置头布局折叠后的剩余距离。
  3. 动画计算

    • 小头部透明度:当滚动位置接近大头部底部时,小头部从透明(0)渐变到不透明(1)。
    • 小头部缩放:当滚动位置在[0, headerHeight - smallHeaderHeight]范围内时,小头部从放大状态(比例为headerHeight/smallHeaderHeight)缩小到原始大小(1)。
  4. 布局测量

    • handleHeaderLayout:测量并记录大头部的实际高度。
    • handleSmallHeaderLayout:测量并记录小头部的实际高度,确保只在缩放比例为0时更新。
  5. 渲染逻辑

    • 大头部和TabBar通过Sticky组件实现粘性,粘性阈值为offset()函数返回值。
    • 小头部使用绝对定位(position: 'absolute')固定在顶部,绑定缩放和透明度动画。
    • 支持通过onScroll属性自定义滚动事件处理。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值