Vertical Swiper - 垂直滚动组件

一个基于滚动位置的垂直滚动组件。

预览

使用示例

import { AnimationView } from '@/components/animation';
import { useSwiperAnimationValue } from '@/hooks/useSwiperAnimationValue';
import { pt } from '@/utils';
import { SpringValue } from '@react-spring/web';
import { Image, Swiper, SwiperItem, View } from '@tarojs/components';
import { createSelectorQuery, NodesRef, useReady } from '@tarojs/taro';
import { useState } from 'react';

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

const DTTA = [
  {
    title: '清纯可人',
    desc: '邻家女孩般的清新气质,让人心动不已',
    image: 'https://images.unsplash.com/photo-1648740366598-7fb7c5e73fa5?w=800&auto=format&fit=crop&q=10',
  },
  {
    title: '性感妖娆',
    desc: '曼妙身姿与迷人眼神,散发成熟魅力',
    image: 'https://images.unsplash.com/photo-1549768960-6e7b0c8b5d94?w=800&auto=format&fit=crop&q=10',
  },
  {
    title: '甜美可爱',
    desc: '灿烂笑容如阳光般温暖,让人忍不住想要呵护',
    image: 'https://images.unsplash.com/photo-1728711632084-58f02a71657d?w=800&auto=format&fit=crop&q=10',
  },
  {
    title: '知性优雅',
    desc: '端庄大方的气质,展现女性的智慧与魅力',
    image: 'https://images.unsplash.com/photo-1732965757891-368c1f235e7c?w=800&auto=format&fit=crop&q=10',
  },
  {
    title: '运动活力',
    desc: '健康阳光的形象,充满朝气和活力',
    image: 'https://images.unsplash.com/photo-1729025004944-5aa651b2a62d?w=800&auto=format&fit=crop&q=10',
  },
];

const MARGIN = pt(120);

export default function Page() {
  const [itemHeight, setItemHeight] = useState<number>(pt(532));

  const { transitionValue, currentIndex, setCurrentIndex, setTransitionValue } = useSwiperAnimationValue({
    defaultIndex: 1,
    itemSize: itemHeight,
  });

  useReady(() => {
    const query = createSelectorQuery();
    query
      .select(`#item-${currentIndex}`)
      .boundingClientRect((rect: NodesRef.BoundingClientRectCallbackResult) => {
        setItemHeight(rect.height);
        transitionValue.set(currentIndex * (rect.height ?? 0));
      })
      .exec();
  });

  return (
    <Swiper
      current={currentIndex}
      className="h-screen w-screen bg-black p-5"
      vertical
      snapToEdge
      previousMargin={MARGIN.toString()}
      nextMargin={MARGIN.toString()}
      onAnimationFinish={e => {
        setCurrentIndex(e.detail.current);
      }}
      onTransition={e => {
        setTransitionValue(e.detail.dy);
      }}
    >
      {DTTA.map((item, index) => (
        <SwiperItem key={index} className="bg-transparent" id={`item-${index}`}>
          <Card key={index} item={item} itemHeight={itemHeight} index={index} transitionValue={transitionValue} />
        </SwiperItem>
      ))}
    </Swiper>
  );
}

const Card = ({
  index,
  item,
  itemHeight,
  transitionValue,
}: { index: number; itemHeight: number; transitionValue: SpringValue<number> } & {
  item: (typeof DTTA)[number];
}) => {
  const RANGE = [(index - 1) * itemHeight, index * itemHeight, (index + 1) * itemHeight];

  return (
    <AnimationView
      style={{
        opacity: transitionValue.to(RANGE, [0.5, 1, 0.5], 'clamp'),
        transform: transitionValue.to(RANGE, [0.95, 1, 0.95], 'clamp').to(value => `scale(${value})`),
        backdropFilter: transitionValue.to(RANGE, ['blur(10px)', 'blur(20px)', 'blur(10px)'], 'clamp'),
        backgroundColor: transitionValue.to(
          RANGE,
          ['rgba(255, 255, 255, 0.05)', 'rgba(255, 255, 255, 0.1)', 'rgba(255, 255, 255, 0.05)'],
          'clamp'
        ),
        boxShadow: transitionValue.to(
          RANGE,
          [
            '0 2px 4px rgba(0, 0, 0, 0.05), 0 1px 2px rgba(0, 0, 0, 0.04)',
            '0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08)',
            '0 2px 4px rgba(0, 0, 0, 0.05), 0 1px 2px rgba(0, 0, 0, 0.04)',
          ],
          'clamp'
        ),
      }}
      className="box-border h-full w-full overflow-hidden rounded-lg p-2.5 opacity-90"
    >
      <Image mode="aspectFill" className="mb-2.5 h-[80%] w-full rounded" src={item.image} />
      <View className="flex flex-col space-y-2.5">
        <View className="text-2xl text-white">{item.title}</View>
        <View className="text-sm text-muted-foreground">{item.desc}</View>
      </View>
    </AnimationView>
  );
};

下载

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

添加Hook

// @/hooks/useSwiperAnimationValue
import { useSpringValue } from '@react-spring/web';
import { useReady } from '@tarojs/taro';
import { useEffect, useState } from 'react';

export function useSwiperAnimationValue({ defaultIndex, itemSize }: { defaultIndex: number; itemSize: number }) {
  const transitionValue = useSpringValue(0);
  const [currentIndex, setIndex] = useState(defaultIndex);
  const [isFirstChange, setIsFirstChange] = useState(true);

  useReady(() => {
    transitionValue.set(currentIndex * itemSize);
  });

  useEffect(() => {
    transitionValue.set(currentIndex * itemSize);
  }, [itemSize]);

  const setCurrentIndex = (indexValue: number) => {
    setIndex(indexValue);
    if (isFirstChange) {
      setIsFirstChange(false);
    }
  };

  const setTransitionValue = (value: number) => {
    transitionValue.set(currentIndex * itemSize + value - (isFirstChange ? itemSize * currentIndex : 0));
  };

  return { transitionValue, setCurrentIndex, currentIndex, setTransitionValue };
}