圆环轮播图-星座、轮盘、罗盘选择

5 阅读2分钟

image.png 支持mouse、touch事件

<CircleCarousel
    v-model="index"
    :items="list"
    :radius="180"
    :selectedAngle="-90"
    @change="onChange"
  />

可设置selectedAngle调整选中项角度所在

image.png

<template>
  <div
    ref="container"
    class="circle-carousel"
    @mousedown="startDrag"
    @touchstart="startDrag"
  >
    <div
      v-for="(item, i) in items"
      :key="i"
      class="circle-item"
      :class="{ active: i === activeIndex }"
      :style="getItemStyle(i)"
    >
      <slot :item="item" :index="i">
        {{ item }}
      </slot>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from "vue"

interface Props {
  items: any[]
  radius?: number
  selectedAngle?: number
  modelValue?: number
}

const props = withDefaults(defineProps<Props>(), {
  radius: 200,
  selectedAngle: 0,
  modelValue: 0
})

const emit = defineEmits(["update:modelValue", "change"])

const container = ref<HTMLDivElement>()

const rotation = ref(0)
const activeIndex = ref(props.modelValue)

const dragging = ref(false)

let centerX = 0
let centerY = 0

let lastAngle = 0

const stepAngle = computed(() => 360 / props.items.length)

function getItemStyle(index: number) {
  const angle = index * stepAngle.value + rotation.value
  const rad = (angle * Math.PI) / 180

  const x = Math.cos(rad) * props.radius
  const y = Math.sin(rad) * props.radius

  return {
    transform: `translate(${x}px, ${y}px)`
  }
}

function getPoint(e: MouseEvent | TouchEvent) {
  return "touches" in e ? e.touches[0] : e
}

function getAngle(x: number, y: number) {
  return Math.atan2(y - centerY, x - centerX) * 180 / Math.PI
}

function startDrag(e: MouseEvent | TouchEvent) {
  if (!container.value) return

  dragging.value = true

  const rect = container.value.getBoundingClientRect()

  centerX = rect.left + rect.width / 2
  centerY = rect.top + rect.height / 2

  const p = getPoint(e)

  lastAngle = getAngle(p.clientX, p.clientY)

  window.addEventListener("mousemove", moveDrag)
  window.addEventListener("touchmove", moveDrag)

  window.addEventListener("mouseup", endDrag)
  window.addEventListener("touchend", endDrag)
}

function moveDrag(e: MouseEvent | TouchEvent) {
  if (!dragging.value) return

  const p = getPoint(e)

  const angle = getAngle(p.clientX, p.clientY)

  let delta = angle - lastAngle

  if (delta > 180) delta -= 360
  if (delta < -180) delta += 360

  rotation.value += delta

  lastAngle = angle

  updateActive()
}

function endDrag() {
  dragging.value = false

  snapToNearest()

  window.removeEventListener("mousemove", moveDrag)
  window.removeEventListener("touchmove", moveDrag)

  window.removeEventListener("mouseup", endDrag)
  window.removeEventListener("touchend", endDrag)
}

function updateActive() {
  const step = stepAngle.value

  const index =
    Math.round((props.selectedAngle - rotation.value) / step) %
    props.items.length

  const fixed = (index + props.items.length) % props.items.length

  if (fixed !== activeIndex.value) {
    activeIndex.value = fixed

    emit("update:modelValue", fixed)
    emit("change", fixed)
  }
}

function snapToNearest() {
  const step = stepAngle.value

  const target =
    Math.round((props.selectedAngle - rotation.value) / step) * step

  animateTo(props.selectedAngle - target)
}

function animateTo(target: number) {
  const start = rotation.value
  const diff = target - start

  const duration = 300
  const startTime = performance.now()

  function frame(now: number) {
    const t = Math.min((now - startTime) / duration, 1)

    const ease = 1 - Math.pow(1 - t, 3)

    rotation.value = start + diff * ease

    updateActive()

    if (t < 1) requestAnimationFrame(frame)
  }

  requestAnimationFrame(frame)
}

onMounted(() => {
  rotation.value = -props.modelValue * stepAngle.value
  updateActive()
})
</script>

<style scoped>
.circle-carousel {
  width: 500px;
  height: 500px;

  margin: auto;

  position: relative;

  display: flex;
  align-items: center;
  justify-content: center;

  user-select: none;

  cursor: grab;
}

.circle-item {
  position: absolute;

  width: 80px;
  height: 80px;

  border-radius: 50%;

  display: flex;
  align-items: center;
  justify-content: center;

  background: #eee;

  transition: transform 0.2s;
}

.circle-item.active {
  background: #409eff;
  color: white;

  transform: scale(1.3);
}
</style>

使用示例

<template>
  <CircleCarousel
    v-model="index"
    :items="list"
    :radius="180"
    :selectedAngle="0"
    @change="onChange"
  />
</template>

<script setup lang="ts">
import { ref } from "vue"
import CircleCarousel from "./components/CircleCarousel.vue"

const index = ref(0)

const list = [
  "A",
  "B",
  "C",
  "D",
  "E",
  "F",
  "G",
  "H",
  "I",
  "J",
  "K",
  "L",
]

function onChange(i: number) {
  console.log("选中:", i)
}
</script>