Files

318 lines
6.4 KiB
Plaintext
Raw Permalink Normal View History

2025-11-13 10:36:23 +08:00
<template>
<view class="cl-index-bar" :class="[pt.className]">
<view
class="cl-index-bar__list"
@touchstart="onTouchStart"
@touchmove.stop.prevent="onTouchMove"
@touchend="onTouchEnd"
>
<view class="cl-index-bar__item" v-for="(item, index) in list" :key="index">
<view
class="cl-index-bar__item-inner"
:class="{
'is-active': activeIndex == index
}"
>
<text
class="cl-index-bar__item-text"
:class="{
'is-active': activeIndex == index || isDark
}"
>{{ item }}</text
>
</view>
</view>
</view>
</view>
<view class="cl-index-bar__alert" v-show="showAlert">
<view class="cl-index-bar__alert-icon dark:!bg-surface-800">
<view class="cl-index-bar__alert-arrow dark:!bg-surface-800"></view>
<text class="cl-index-bar__alert-text dark:!text-white">{{ alertText }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, getCurrentInstance, nextTick, onMounted, ref, watch, type PropType } from "vue";
import { isDark, isEmpty, parsePt } from "@/cool";
defineOptions({
name: "cl-index-bar"
});
const props = defineProps({
pt: {
type: Object,
default: () => ({})
},
modelValue: {
type: Number,
default: 0
},
list: {
type: Array as PropType<string[]>,
default: () => []
}
});
const emit = defineEmits(["update:modelValue", "change"]);
const { proxy } = getCurrentInstance()!;
type PassThrough = {
className?: string;
};
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 存储索引条整体的位置信息
const barRect = ref({
height: 0,
width: 0,
left: 0,
top: 0
} as NodeInfo);
// 存储所有索引项的位置信息数组
const itemsRect = ref<NodeInfo[]>([]);
// 是否正在触摸
const isTouching = ref(false);
// 是否显示提示弹窗
const showAlert = ref(false);
// 当前提示弹窗显示的文本
const alertText = ref("");
// 当前触摸过程中的临时索引
const activeIndex = ref(-1);
/**
* 获取索引条及其所有子项的位置信息
* 用于后续触摸时判断手指所在的索引项
*/
function getRect() {
nextTick(() => {
setTimeout(() => {
uni.createSelectorQuery()
.in(proxy)
.select(".cl-index-bar")
.boundingClientRect()
.exec((bar) => {
if (isEmpty(bar)) {
return;
}
// 获取索引条整体的位置信息
barRect.value = bar[0] as NodeInfo;
// 获取所有索引项的位置信息
uni.createSelectorQuery()
.in(proxy)
.selectAll(".cl-index-bar__item")
.boundingClientRect()
.exec((items) => {
if (isEmpty(items)) {
getRect();
return;
}
itemsRect.value = items[0] as NodeInfo[];
});
});
}, 350);
});
}
/**
* 根据触摸点的Y坐标计算出最接近的索引项下标
* @param clientY 触摸点的Y坐标相对于屏幕
* @returns 最接近的索引项下标
*/
function getIndex(clientY: number): number {
if (itemsRect.value.length == 0) {
// 没有索引项时默认返回0
return 0;
}
// 初始化最接近的索引和最小距离
let closestIndex = 0;
let minDistance = Number.MAX_VALUE;
// 遍历所有索引项,找到距离触摸点最近的项
for (let i = 0; i < itemsRect.value.length; i++) {
const item = itemsRect.value[i];
// 计算每个item的中心点Y坐标
const itemCenterY = (item.top ?? 0) + (item.height ?? 0) / 2;
// 计算触摸点到中心点的距离
const distance = Math.abs(clientY - itemCenterY);
// 更新最小距离和索引
if (distance < minDistance) {
minDistance = distance;
closestIndex = i;
}
}
// 边界处理,防止越界
if (closestIndex < 0) {
closestIndex = 0;
} else if (closestIndex >= props.list.length) {
closestIndex = props.list.length - 1;
}
return closestIndex;
}
/**
* 更新触摸过程中的显示状态
* @param index 新的索引
*/
function updateActive(index: number) {
// 更新当前触摸索引
activeIndex.value = index;
// 更新弹窗提示文本
alertText.value = props.list[index];
}
/**
* 触摸开始事件处理
* @param e 触摸事件对象
*/
function onTouchStart(e: TouchEvent) {
// 标记为正在触摸
isTouching.value = true;
// 显示提示弹窗
showAlert.value = true;
// 获取第一个触摸点
const touch = e.touches[0];
// 计算对应的索引
const index = getIndex(touch.clientY);
// 更新显示状态
updateActive(index);
}
/**
* 触摸移动事件处理
* @param e 触摸事件对象
*/
function onTouchMove(e: TouchEvent) {
// 未处于触摸状态时不处理
if (!isTouching.value) return;
// 获取第一个触摸点
const touch = e.touches[0];
// 计算对应的索引
const index = getIndex(touch.clientY);
// 更新显示状态
updateActive(index);
}
/**
* 触摸结束事件处理
* 结束后延迟隐藏提示弹窗,并确认最终选中的索引
*/
function onTouchEnd() {
isTouching.value = false; // 标记为未触摸
// 更新值
if (props.modelValue != activeIndex.value) {
emit("update:modelValue", activeIndex.value);
emit("change", activeIndex.value);
}
// 延迟500ms后隐藏提示弹窗提升用户体验
setTimeout(() => {
showAlert.value = false;
}, 500);
}
watch(
computed(() => props.modelValue),
(val: number) => {
activeIndex.value = val;
},
{
immediate: true
}
);
onMounted(() => {
watch(
computed(() => props.list),
() => {
getRect();
},
{
immediate: true
}
);
});
</script>
<style lang="scss" scoped>
.cl-index-bar {
@apply flex flex-col items-center justify-center;
@apply absolute bottom-0 right-0 h-full;
z-index: 110;
&__item {
@apply flex flex-col items-center justify-center;
width: 50rpx;
height: 34rpx;
&-inner {
@apply rounded-full flex flex-row items-center justify-center;
width: 30rpx;
height: 30rpx;
&.is-active {
@apply bg-primary-500;
}
}
&-text {
@apply text-xs text-surface-500;
&.is-active {
@apply text-white;
}
}
}
}
.cl-index-bar__alert {
@apply absolute bottom-0 right-8 h-full flex flex-col items-center justify-center;
width: 120rpx;
z-index: 110;
&-icon {
@apply rounded-full flex flex-row items-center justify-center;
@apply bg-surface-300;
height: 80rpx;
width: 80rpx;
overflow: visible;
}
&-arrow {
@apply bg-surface-300 absolute;
right: -8rpx;
height: 40rpx;
width: 40rpx;
transform: rotate(45deg);
}
&-text {
@apply text-white text-2xl;
}
}
</style>