Animated Tabs - 动画标签页

一个基于 React Spring 实现的平滑过渡的标签页组件。

预览

下载

1

添加依赖

npm i @react-spring/web clsx tailwind-merge
2

添加工具函数

// @/utils
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

// 合并 Tailwind CSS 类名和 clsx 类名
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

// 根据屏幕宽度计算响应式值
export const pt = (num: number) => {
  const { screenWidth } = getWindowInfo();
  return num * (screenWidth / 375);
};

3

添加动画组件

// @/components/animation
import { animated } from '@react-spring/web';
import { Label, Text, View } from '@tarojs/components';

export const AnimationView = animated(View);
export const AnimationText = animated(Text);
export const AnimationLabel = animated(Label);

源代码

import { AnimationView } from '@/components/animation';
import { cn } from '@/utils';
import { useTransition } from '@react-spring/web';
import { ScrollView, View } from '@tarojs/components';
import { getMenuButtonBoundingClientRect } from '@tarojs/taro';
import { useRef, useState } from 'react';

definePageConfig({
  navigationStyle: 'custom',
});

const data = [
  {
    icon: 'icon-[lucide--aperture]',
    name: 'Aperture',
    width: '100px',
    color: '#fca5a5',
  },
  {
    icon: 'icon-[lucide--axe]',
    name: 'Axe',
    width: '60px',
    color: '#f59e0b',
  },
  {
    icon: 'icon-[lucide--backpack]',
    name: 'Backpack',
    width: '100px',
    color: '#10a37f',
  },
];

export default function Page() {
  const { top, left, height } = getMenuButtonBoundingClientRect();

  const [active, setActive] = useState('Aperture');

  const currentIndex = useRef(data.findIndex(item => item.name === active));

  const transitions = useTransition(data, {
    from: { opacity: 0.5, width: '28px', transformOrigin: 'right' },
    enter: item => ({
      opacity: 1,
      width: item.name === active ? item.width : '28px',
    }),
    update: item => ({
      opacity: 1,
      width: item.name === active ? item.width : '28px',
    }),
    leave: { opacity: 0.5, width: '28px' },
    config: { tension: 300, friction: 20, clamp: true },
  });

  const getX = (item: (typeof data)[number], index: number) => {
    currentIndex.current = data.findIndex(it => it.name === active);

    return item.name === active ? 0 : index > currentIndex.current ? 200 : -200;
  };

  const transitionsScrillView = useTransition(data, {
    from: (item, index) => ({
      x: getX(item, index),
    }),
    enter: (item, index) => ({ x: getX(item, index) }),
    update: (item, index) => {
      currentIndex.current = data.findIndex(it => it.name === active);
      return {
        x: getX(item, index),
      };
    },

    config: { tension: 280, friction: 24 },
  });

  const handleClick = (item: (typeof data)[number]) => {
    setActive(item.name);
  };

  return (
    <View className="relative" style={{ paddingTop: top }}>
      <View className="flex items-center justify-between px-4" style={{ width: left, height }}>
        <View className="icon-[lucide--house] size-5"></View>
        <View className="flex space-x-1.5">
          {transitions((style, item) => (
            <AnimationView
              onClick={() => handleClick(item)}
              className={cn(
                'flex origin-right items-center justify-center rounded bg-gray-300 p-1',
                item.name === active ? 'bg-black text-white' : 'text-gray-400'
              )}
              style={{ width: style.width }}
            >
              <View className={`${item.icon} size-5`}></View>
              {item.name === active && (
                <AnimationView style={{ opacity: style.opacity }} className="ml-1 w-fit text-sm">
                  {item.name}
                </AnimationView>
              )}
            </AnimationView>
          ))}
        </View>
      </View>
      {transitionsScrillView((style, item) => (
        <>
          {item.name === active && (
            <AnimationView style={style} className="overflow-hidden px-4 py-2.5">
              <ScrollView
                className="rounded"
                style={{
                  width: '100%',
                  height: `calc(100vh - ${top}px - ${height}px - 20px)`,
                  backgroundColor: item.color,
                }}
              ></ScrollView>
            </AnimationView>
          )}
        </>
      ))}
    </View>
  );
}