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