8 changed files with 700 additions and 190 deletions
@ -0,0 +1,307 @@ |
|||
<template> |
|||
<div |
|||
ref="containerRef" |
|||
:class="[ |
|||
'relative overflow-hidden p-4', |
|||
round ? 'rounded-full border border-[#333]' : 'rounded-[24px] border border-[#333]' |
|||
]" |
|||
:style="{ |
|||
width: `${baseWidth}px`, |
|||
...(round && { height: `${baseWidth}px` }) |
|||
}" |
|||
> |
|||
<Motion |
|||
tag="div" |
|||
class="flex" |
|||
drag="x" |
|||
:dragConstraints="dragConstraints" |
|||
:style="{ |
|||
width: itemWidth + 'px', |
|||
gap: `${GAP}px`, |
|||
perspective: 1000, |
|||
perspectiveOrigin: `${currentIndex * trackItemOffset + itemWidth / 2}px 50%`, |
|||
x: motionX |
|||
}" |
|||
@dragEnd="handleDragEnd" |
|||
:animate="{ x: -(currentIndex * trackItemOffset) }" |
|||
:transition="effectiveTransition" |
|||
@animationComplete="handleAnimationComplete" |
|||
> |
|||
<Motion |
|||
v-for="(item, index) in carouselItems" |
|||
:key="index" |
|||
tag="div" |
|||
:class="[ |
|||
'relative shrink-0 flex flex-col overflow-hidden cursor-grab active:cursor-grabbing', |
|||
round |
|||
? 'items-center justify-center text-center bg-[#111] border border-[#333] rounded-full' |
|||
: 'items-start justify-between bg-[#111] border border-[#333] rounded-[12px]' |
|||
]" |
|||
:style="{ |
|||
width: itemWidth + 'px', |
|||
height: round ? itemWidth + 'px' : '100%', |
|||
rotateY: getRotateY(index), |
|||
...(round && { borderRadius: '50%' }) |
|||
}" |
|||
:transition="effectiveTransition" |
|||
> |
|||
<div :class="round ? 'p-0 m-0' : 'mb-4 p-5'"> |
|||
<span class="flex h-[28px] w-[28px] items-center justify-center rounded-full bg-[#0b0b0b]"> |
|||
<i :class="item.icon" class="text-white text-base"></i> |
|||
</span> |
|||
</div> |
|||
|
|||
<div class="p-5"> |
|||
<div class="mb-1 font-black text-lg text-white">{{ item.title }}</div> |
|||
|
|||
<p class="text-sm text-white">{{ item.description }}</p> |
|||
</div> |
|||
</Motion> |
|||
</Motion> |
|||
|
|||
<div :class="['flex w-full justify-center', round ? 'absolute z-20 bottom-12 left-1/2 -translate-x-1/2' : '']"> |
|||
<div class="mt-4 flex w-[150px] justify-between px-8"> |
|||
<Motion |
|||
v-for="(_, index) in items" |
|||
:key="index" |
|||
tag="div" |
|||
:class="[ |
|||
'h-2 w-2 rounded-full cursor-pointer transition-colors duration-150', |
|||
currentIndex % items.length === index |
|||
? round |
|||
? 'bg-white' |
|||
: 'bg-[#333333]' |
|||
: round |
|||
? 'bg-[#555]' |
|||
: 'bg-[rgba(51,51,51,0.4)]' |
|||
]" |
|||
:animate="{ |
|||
scale: currentIndex % items.length === index ? 1.2 : 1 |
|||
}" |
|||
@click="() => setCurrentIndex(index)" |
|||
:transition="{ duration: 0.15 }" |
|||
/> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
export interface CarouselItem { |
|||
title: string; |
|||
description: string; |
|||
id: number; |
|||
icon: string; |
|||
} |
|||
|
|||
export interface CarouselProps { |
|||
items?: CarouselItem[]; |
|||
baseWidth?: number; |
|||
autoplay?: boolean; |
|||
autoplayDelay?: number; |
|||
pauseOnHover?: boolean; |
|||
loop?: boolean; |
|||
round?: boolean; |
|||
} |
|||
|
|||
export const DEFAULT_ITEMS: CarouselItem[] = [ |
|||
{ |
|||
title: 'Text Animations', |
|||
description: 'Cool text animations for your projects.', |
|||
id: 1, |
|||
icon: 'pi pi-file' |
|||
}, |
|||
{ |
|||
title: 'Animations', |
|||
description: 'Smooth animations for your projects.', |
|||
id: 2, |
|||
icon: 'pi pi-circle' |
|||
}, |
|||
{ |
|||
title: 'Components', |
|||
description: 'Reusable components for your projects.', |
|||
id: 3, |
|||
icon: 'pi pi-objects-column' |
|||
}, |
|||
{ |
|||
title: 'Backgrounds', |
|||
description: 'Beautiful backgrounds and patterns for your projects.', |
|||
id: 4, |
|||
icon: 'pi pi-table' |
|||
}, |
|||
{ |
|||
title: 'Common UI', |
|||
description: 'Common UI components are coming soon!', |
|||
id: 5, |
|||
icon: 'pi pi-code' |
|||
} |
|||
]; |
|||
</script> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, computed, onMounted, onUnmounted, watch, useTemplateRef } from 'vue'; |
|||
import { Motion, useMotionValue, useTransform } from 'motion-v'; |
|||
|
|||
const DRAG_BUFFER = 0; |
|||
const VELOCITY_THRESHOLD = 500; |
|||
const GAP = 16; |
|||
const SPRING_OPTIONS = { type: 'spring' as const, stiffness: 300, damping: 30 }; |
|||
|
|||
const props = withDefaults(defineProps<CarouselProps>(), { |
|||
items: () => DEFAULT_ITEMS, |
|||
baseWidth: 300, |
|||
autoplay: false, |
|||
autoplayDelay: 3000, |
|||
pauseOnHover: false, |
|||
loop: false, |
|||
round: false |
|||
}); |
|||
|
|||
const containerPadding = 16; |
|||
const itemWidth = computed(() => props.baseWidth - containerPadding * 2); |
|||
const trackItemOffset = computed(() => itemWidth.value + GAP); |
|||
|
|||
const carouselItems = computed(() => (props.loop ? [...props.items, props.items[0]] : props.items)); |
|||
const currentIndex = ref<number>(0); |
|||
const motionX = useMotionValue(0); |
|||
const isHovered = ref<boolean>(false); |
|||
const isResetting = ref<boolean>(false); |
|||
|
|||
const containerRef = useTemplateRef<HTMLDivElement>('containerRef'); |
|||
let autoplayTimer: number | null = null; |
|||
|
|||
const dragConstraints = computed(() => { |
|||
return props.loop |
|||
? {} |
|||
: { |
|||
left: -trackItemOffset.value * (carouselItems.value.length - 1), |
|||
right: 0 |
|||
}; |
|||
}); |
|||
|
|||
const effectiveTransition = computed(() => (isResetting.value ? { duration: 0 } : SPRING_OPTIONS)); |
|||
|
|||
const maxItems = Math.max(props.items.length + 1, 10); |
|||
const rotateYTransforms = Array.from({ length: maxItems }, (_, index) => { |
|||
const range = computed(() => [ |
|||
-(index + 1) * trackItemOffset.value, |
|||
-index * trackItemOffset.value, |
|||
-(index - 1) * trackItemOffset.value |
|||
]); |
|||
const outputRange = [90, 0, -90]; |
|||
return useTransform(motionX, range, outputRange, { clamp: false }); |
|||
}); |
|||
|
|||
const getRotateY = (index: number) => { |
|||
return rotateYTransforms[index] || rotateYTransforms[0]; |
|||
}; |
|||
|
|||
const setCurrentIndex = (index: number) => { |
|||
currentIndex.value = index; |
|||
}; |
|||
|
|||
const handleAnimationComplete = () => { |
|||
if (props.loop && currentIndex.value === carouselItems.value.length - 1) { |
|||
isResetting.value = true; |
|||
motionX.set(0); |
|||
currentIndex.value = 0; |
|||
setTimeout(() => { |
|||
isResetting.value = false; |
|||
}, 50); |
|||
} |
|||
}; |
|||
|
|||
interface DragInfo { |
|||
offset: { x: number; y: number }; |
|||
velocity: { x: number; y: number }; |
|||
} |
|||
|
|||
const handleDragEnd = (event: Event, info: DragInfo) => { |
|||
const offset = info.offset.x; |
|||
const velocity = info.velocity.x; |
|||
|
|||
if (offset < -DRAG_BUFFER || velocity < -VELOCITY_THRESHOLD) { |
|||
if (props.loop && currentIndex.value === props.items.length - 1) { |
|||
currentIndex.value = currentIndex.value + 1; |
|||
} else { |
|||
currentIndex.value = Math.min(currentIndex.value + 1, carouselItems.value.length - 1); |
|||
} |
|||
} else if (offset > DRAG_BUFFER || velocity > VELOCITY_THRESHOLD) { |
|||
if (props.loop && currentIndex.value === 0) { |
|||
currentIndex.value = props.items.length - 1; |
|||
} else { |
|||
currentIndex.value = Math.max(currentIndex.value - 1, 0); |
|||
} |
|||
} |
|||
}; |
|||
|
|||
const startAutoplay = () => { |
|||
if (props.autoplay && (!props.pauseOnHover || !isHovered.value)) { |
|||
autoplayTimer = window.setInterval(() => { |
|||
currentIndex.value = (() => { |
|||
const prev = currentIndex.value; |
|||
if (prev === props.items.length - 1 && props.loop) { |
|||
return prev + 1; |
|||
} |
|||
if (prev === carouselItems.value.length - 1) { |
|||
return props.loop ? 0 : prev; |
|||
} |
|||
return prev + 1; |
|||
})(); |
|||
}, props.autoplayDelay); |
|||
} |
|||
}; |
|||
|
|||
const stopAutoplay = () => { |
|||
if (autoplayTimer) { |
|||
clearInterval(autoplayTimer); |
|||
autoplayTimer = null; |
|||
} |
|||
}; |
|||
|
|||
const handleMouseEnter = () => { |
|||
isHovered.value = true; |
|||
if (props.pauseOnHover) { |
|||
stopAutoplay(); |
|||
} |
|||
}; |
|||
|
|||
const handleMouseLeave = () => { |
|||
isHovered.value = false; |
|||
if (props.pauseOnHover) { |
|||
startAutoplay(); |
|||
} |
|||
}; |
|||
|
|||
watch( |
|||
[ |
|||
() => props.autoplay, |
|||
() => props.autoplayDelay, |
|||
isHovered, |
|||
() => props.loop, |
|||
() => props.items.length, |
|||
() => carouselItems.value.length, |
|||
() => props.pauseOnHover |
|||
], |
|||
() => { |
|||
stopAutoplay(); |
|||
startAutoplay(); |
|||
} |
|||
); |
|||
|
|||
onMounted(() => { |
|||
if (props.pauseOnHover && containerRef.value) { |
|||
containerRef.value.addEventListener('mouseenter', handleMouseEnter); |
|||
containerRef.value.addEventListener('mouseleave', handleMouseLeave); |
|||
} |
|||
startAutoplay(); |
|||
}); |
|||
|
|||
onUnmounted(() => { |
|||
if (containerRef.value) { |
|||
containerRef.value.removeEventListener('mouseenter', handleMouseEnter); |
|||
containerRef.value.removeEventListener('mouseleave', handleMouseLeave); |
|||
} |
|||
stopAutoplay(); |
|||
}); |
|||
</script> |
|||
@ -0,0 +1,175 @@ |
|||
<template> |
|||
<div ref="containerRef" class="relative overflow-hidden rounded-[24px] border border-[#333] bg-[#111]" |
|||
:style="{ width: `${baseWidth}px` }"> |
|||
<!-- 添加顶部占位区域,与底部导航按钮区域对称 --> |
|||
<div class="h-[10px]"></div> <!-- pb-3(12px) + 按钮高度(约22px) ≈ 34px --> |
|||
|
|||
<div class="p-3 pt-6 flex flex-col justify-between items-center"> |
|||
<Motion tag="div" class="flex" drag="x" :dragConstraints="dragConstraints" :style="{ |
|||
width: itemWidth + 'px', |
|||
gap: `${GAP}px`, |
|||
perspective: 1000, |
|||
perspectiveOrigin: `${currentIndex * trackItemOffset + itemWidth / 2}px 50%`, |
|||
x: motionX |
|||
}" @dragEnd="handleDragEnd" :animate="{ x: -(currentIndex * trackItemOffset) }" :transition="effectiveTransition" |
|||
@animationComplete="handleAnimationComplete"> |
|||
<Motion v-for="(item, index) in carouselItems" :key="item.id" tag="div" |
|||
class="relative shrink-0 flex flex-col overflow-hidden cursor-grab active:cursor-grabbing bg-[#1a1a1a] rounded-[12px]" |
|||
:style="{ |
|||
width: itemWidth + 'px', |
|||
height: `${cardHeight}px`, |
|||
rotateY: getRotateY(index) |
|||
}" :transition="effectiveTransition"> |
|||
<slot :item="item" :index="index" :isActive="currentIndex === index" /> |
|||
</Motion> |
|||
</Motion> |
|||
</div> |
|||
|
|||
<div class="flex w-full justify-center pb-3 border-2 border-transparent"> |
|||
<div class="flex w-[150px] justify-between px-8 border-2 border-transparent"> |
|||
<Motion v-for="(_, index) in items" :key="index" tag="div" |
|||
class="h-2 w-2 rounded-full cursor-pointer transition-colors duration-150" |
|||
:class="currentIndex % items.length === index ? 'bg-white' : 'bg-[rgba(51,51,51,0.4)]'" :animate="{ |
|||
scale: currentIndex % items.length === index ? 1.2 : 1 |
|||
}" @click="() => setCurrentIndex(index)" :transition="{ duration: 0.15 }" /> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
export interface MissionCarouselItem { |
|||
id: number; |
|||
} |
|||
</script> |
|||
|
|||
<script setup lang="ts" generic="T extends MissionCarouselItem"> |
|||
import { ref, computed, onMounted, onUnmounted, watch, useTemplateRef } from 'vue'; |
|||
import { Motion, useMotionValue, useTransform } from 'motion-v'; |
|||
|
|||
const DRAG_BUFFER = 0; |
|||
const VELOCITY_THRESHOLD = 500; |
|||
const GAP = 16; |
|||
const SPRING_OPTIONS = { type: 'spring' as const, stiffness: 300, damping: 30 }; |
|||
|
|||
interface Props { |
|||
items?: T[]; |
|||
baseWidth?: number; |
|||
cardHeight?: number; |
|||
loop?: boolean; |
|||
} |
|||
|
|||
const props = withDefaults(defineProps<Props>(), { |
|||
items: () => [], |
|||
baseWidth: 320, |
|||
cardHeight: 200, |
|||
loop: false |
|||
}); |
|||
|
|||
const emit = defineEmits<{ |
|||
(e: 'select', item: T): void; |
|||
}>(); |
|||
|
|||
const containerPadding = 12; |
|||
const itemWidth = computed(() => props.baseWidth - containerPadding * 2); |
|||
const trackItemOffset = computed(() => itemWidth.value + GAP); |
|||
|
|||
const carouselItems = computed(() => (props.loop ? [...props.items, props.items[0]] : props.items)); |
|||
const currentIndex = ref<number>(0); |
|||
const motionX = useMotionValue(0); |
|||
const isResetting = ref<boolean>(false); |
|||
|
|||
const containerRef = useTemplateRef<HTMLDivElement>('containerRef'); |
|||
|
|||
const dragConstraints = computed(() => { |
|||
return props.loop |
|||
? {} |
|||
: { |
|||
left: -trackItemOffset.value * (carouselItems.value.length - 1), |
|||
right: 0 |
|||
}; |
|||
}); |
|||
|
|||
const effectiveTransition = computed(() => (isResetting.value ? { duration: 0 } : SPRING_OPTIONS)); |
|||
|
|||
const maxItems = Math.max(props.items.length + 1, 10); |
|||
const rotateYTransforms = Array.from({ length: maxItems }, (_, index) => { |
|||
const range = computed(() => [ |
|||
-(index + 1) * trackItemOffset.value, |
|||
-index * trackItemOffset.value, |
|||
-(index - 1) * trackItemOffset.value |
|||
]); |
|||
const outputRange = [90, 0, -90]; |
|||
return useTransform(motionX, range, outputRange, { clamp: false }); |
|||
}); |
|||
|
|||
const getRotateY = (index: number) => { |
|||
return rotateYTransforms[index] || rotateYTransforms[0]; |
|||
}; |
|||
|
|||
const setCurrentIndex = (index: number) => { |
|||
currentIndex.value = index; |
|||
if (props.items[index]) { |
|||
emit('select', props.items[index]); |
|||
} |
|||
}; |
|||
|
|||
const handleAnimationComplete = () => { |
|||
if (props.loop && currentIndex.value === carouselItems.value.length - 1) { |
|||
isResetting.value = true; |
|||
motionX.set(0); |
|||
currentIndex.value = 0; |
|||
setTimeout(() => { |
|||
isResetting.value = false; |
|||
}, 50); |
|||
} |
|||
}; |
|||
|
|||
interface DragInfo { |
|||
offset: { x: number; y: number }; |
|||
velocity: { x: number; y: number }; |
|||
} |
|||
|
|||
const handleDragEnd = (event: Event, info: DragInfo) => { |
|||
const offset = info.offset.x; |
|||
const velocity = info.velocity.x; |
|||
|
|||
if (offset < -DRAG_BUFFER || velocity < -VELOCITY_THRESHOLD) { |
|||
if (props.loop && currentIndex.value === props.items.length - 1) { |
|||
currentIndex.value = currentIndex.value + 1; |
|||
} else { |
|||
currentIndex.value = Math.min(currentIndex.value + 1, carouselItems.value.length - 1); |
|||
} |
|||
} else if (offset > DRAG_BUFFER || velocity > VELOCITY_THRESHOLD) { |
|||
if (props.loop && currentIndex.value === 0) { |
|||
currentIndex.value = props.items.length - 1; |
|||
} else { |
|||
currentIndex.value = Math.max(currentIndex.value - 1, 0); |
|||
} |
|||
} |
|||
|
|||
const realIndex = props.loop && currentIndex.value === props.items.length ? 0 : currentIndex.value; |
|||
if (props.items[realIndex]) { |
|||
emit('select', props.items[realIndex]); |
|||
} |
|||
}; |
|||
|
|||
watch( |
|||
() => props.items.length, |
|||
() => { |
|||
if (currentIndex.value >= props.items.length && props.items.length > 0) { |
|||
currentIndex.value = props.items.length - 1; |
|||
} |
|||
} |
|||
); |
|||
|
|||
onMounted(() => { |
|||
if (props.items.length > 0) { |
|||
emit('select', props.items[0]); |
|||
} |
|||
}); |
|||
|
|||
onUnmounted(() => { |
|||
// cleanup |
|||
}); |
|||
</script> |
|||
Loading…
Reference in new issue