Files
WAI_Project_UNIX/uni_modules/cool-ui/components/cl-draggable/cl-draggable.uvue
2025-09-04 20:18:18 +08:00

698 lines
17 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view
class="cl-draggable"
:class="[
{
'cl-draggable--columns': props.columns > 1
},
pt.className
]"
>
<!-- @vue-ignore -->
<view
v-for="(item, index) in list"
:key="getItemKey(item, index)"
class="cl-draggable__item"
:class="[
{
'cl-draggable__item--disabled': disabled,
'cl-draggable__item--dragging': dragging && dragIndex == index,
'cl-draggable__item--animating': dragging && dragIndex != index
}
]"
:style="getItemStyle(index)"
@touchstart="
(event: UniTouchEvent) => {
onTouchStart(event, index, 'touch');
}
"
@longpress="
(event: UniTouchEvent) => {
onTouchStart(event, index, 'longpress');
}
"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<slot
name="item"
:item="item"
:index="index"
:dragging="dragging"
:dragIndex="dragIndex"
:insertIndex="insertIndex"
>
</slot>
</view>
</view>
</template>
<script lang="ts" setup>
import { computed, ref, getCurrentInstance, type PropType, watch } from "vue";
import { isNull, parsePt, uuid } from "@/cool";
import type { PassThroughProps } from "../../types";
import { vibrate } from "@/uni_modules/cool-vibrate";
defineOptions({
name: "cl-draggable"
});
defineSlots<{
item(props: {
item: UTSJSONObject;
index: number;
dragging: boolean;
dragIndex: number;
insertIndex: number;
}): any;
}>();
// 项目位置信息类型定义
type ItemPosition = {
top: number;
left: number;
width: number;
height: number;
};
// 位移偏移量类型定义
type TranslateOffset = {
x: number;
y: number;
};
const props = defineProps({
/** PassThrough 样式配置 */
pt: {
type: Object,
default: () => ({})
},
/** 数据数组,支持双向绑定 */
modelValue: {
type: Array as PropType<UTSJSONObject[]>,
default: () => []
},
/** 是否禁用拖拽功能 */
disabled: {
type: Boolean,
default: false
},
/** 列数1为单列纵向布局>1为多列网格布局 */
columns: {
type: Number,
default: 1
},
// 是否需要长按触发
longPress: {
type: Boolean,
default: true
}
});
const emit = defineEmits(["update:modelValue", "change", "start", "end"]);
const { proxy } = getCurrentInstance()!;
// 透传样式类型定义
type PassThrough = {
className?: string;
ghost?: PassThroughProps;
};
/** PassThrough 样式解析 */
const pt = computed(() => parsePt<PassThrough>(props.pt));
/** 数据列表 */
const list = ref<UTSJSONObject[]>([]);
/** 是否正在拖拽 */
const dragging = ref(false);
/** 当前拖拽元素的原始索引 */
const dragIndex = ref(-1);
/** 预期插入的目标索引 */
const insertIndex = ref(-1);
/** 触摸开始时的Y坐标 */
const startY = ref(0);
/** 触摸开始时的X坐标 */
const startX = ref(0);
/** Y轴偏移量 */
const offsetY = ref(0);
/** X轴偏移量 */
const offsetX = ref(0);
/** 当前拖拽的数据项 */
const dragItem = ref<UTSJSONObject>({});
/** 所有项目的位置信息缓存 */
const itemPositions = ref<ItemPosition[]>([]);
/** 是否处于放下动画状态 */
const dropping = ref(false);
/** 动态计算的项目高度 */
const itemHeight = ref(0);
/** 动态计算的项目宽度 */
const itemWidth = ref(0);
/** 是否已开始排序模拟(防止误触) */
const sortingStarted = ref(false);
/**
* 重置所有拖拽相关的状态
* 在拖拽结束后调用,确保组件回到初始状态
*/
function reset() {
dragging.value = false; // 拖拽状态
dropping.value = false; // 放下动画状态
dragIndex.value = -1; // 拖拽元素索引
insertIndex.value = -1; // 插入位置索引
offsetX.value = 0; // X轴偏移
offsetY.value = 0; // Y轴偏移
dragItem.value = {}; // 拖拽的数据项
itemPositions.value = []; // 位置信息缓存
itemHeight.value = 0; // 动态计算的高度
itemWidth.value = 0; // 动态计算的宽度
sortingStarted.value = false; // 排序模拟状态
}
/**
* 计算网格布局中元素的位移偏移
* @param index 当前元素索引
* @param dragIdx 拖拽元素索引
* @param insertIdx 插入位置索引
* @returns 包含 x 和 y 坐标偏移的对象
*/
function calculateGridOffset(index: number, dragIdx: number, insertIdx: number): TranslateOffset {
const cols = props.columns;
// 计算当前元素在网格中的行列位置
const currentRow = Math.floor(index / cols);
const currentCol = index % cols;
// 计算元素在拖拽后的新位置索引
let newIndex = index;
if (dragIdx < insertIdx) {
// 向后拖拽dragIdx+1 到 insertIdx 之间的元素需要向前移动一位
if (index > dragIdx && index <= insertIdx) {
newIndex = index - 1;
}
} else if (dragIdx > insertIdx) {
// 向前拖拽insertIdx 到 dragIdx-1 之间的元素需要向后移动一位
if (index >= insertIdx && index < dragIdx) {
newIndex = index + 1;
}
}
// 计算新位置的行列坐标
const newRow = Math.floor(newIndex / cols);
const newCol = newIndex % cols;
// 使用动态计算的网格尺寸
const cellWidth = itemWidth.value;
const cellHeight = itemHeight.value;
// 计算实际的像素位移
const offsetX = (newCol - currentCol) * cellWidth;
const offsetY = (newRow - currentRow) * cellHeight;
return { x: offsetX, y: offsetY };
}
/**
* 计算网格布局的插入位置
* @param dragCenterX 拖拽元素中心点X坐标
* @param dragCenterY 拖拽元素中心点Y坐标
* @returns 最佳插入位置索引
*/
function calculateGridInsertIndex(dragCenterX: number, dragCenterY: number): number {
if (itemPositions.value.length == 0) {
return dragIndex.value;
}
let closestIndex = dragIndex.value;
let minDistance = Infinity;
// 使用欧几里得距离找到最近的网格位置(包括原位置)
for (let i = 0; i < itemPositions.value.length; i++) {
const position = itemPositions.value[i];
// 计算到元素中心点的距离
const centerX = position.left + position.width / 2;
const centerY = position.top + position.height / 2;
// 使用欧几里得距离公式
const distance = Math.sqrt(
Math.pow(dragCenterX - centerX, 2) + Math.pow(dragCenterY - centerY, 2)
);
// 更新最近的位置
if (distance < minDistance) {
minDistance = distance;
closestIndex = i;
}
}
return closestIndex;
}
/**
* 计算单列布局的插入位置
* @param clientY Y坐标
* @returns 最佳插入位置索引
*/
function calculateSingleColumnInsertIndex(clientY: number): number {
let closestIndex = dragIndex.value;
let minDistance = Infinity;
// 遍历所有元素,找到距离最近的元素中心
for (let i = 0; i < itemPositions.value.length; i++) {
const position = itemPositions.value[i];
// 计算到元素中心点的距离
const itemCenter = position.top + position.height / 2;
const distance = Math.abs(clientY - itemCenter);
if (distance < minDistance) {
minDistance = distance;
closestIndex = i;
}
}
return closestIndex;
}
/**
* 计算拖拽元素的最佳插入位置
* @param clientPosition 在主轴上的坐标仅用于单列布局的Y轴坐标
* @returns 最佳插入位置的索引
*/
function calculateInsertIndex(clientPosition: number): number {
// 如果没有位置信息,保持原位置
if (itemPositions.value.length == 0) {
return dragIndex.value;
}
// 根据布局类型选择计算方式
if (props.columns > 1) {
// 多列网格布局计算拖拽元素的中心点坐标使用2D坐标计算最近位置
const dragPos = itemPositions.value[dragIndex.value];
const dragCenterX = dragPos.left + dragPos.width / 2 + offsetX.value;
const dragCenterY = dragPos.top + dragPos.height / 2 + offsetY.value;
return calculateGridInsertIndex(dragCenterX, dragCenterY);
} else {
// 单列布局基于Y轴距离计算最近的元素中心
return calculateSingleColumnInsertIndex(clientPosition);
}
}
/**
* 计算单列布局的位移偏移
* @param index 元素索引
* @param dragIdx 拖拽元素索引
* @param insertIdx 插入位置索引
* @returns 位移偏移对象
*/
function calculateSingleColumnOffset(
index: number,
dragIdx: number,
insertIdx: number
): TranslateOffset {
if (dragIdx < insertIdx) {
// 向下拖拽dragIdx+1 到 insertIdx 之间的元素向上移动
if (index > dragIdx && index <= insertIdx) {
return { x: 0, y: -itemHeight.value };
}
} else if (dragIdx > insertIdx) {
// 向上拖拽insertIdx 到 dragIdx-1 之间的元素向下移动
if (index >= insertIdx && index < dragIdx) {
return { x: 0, y: itemHeight.value };
}
}
return { x: 0, y: 0 };
}
/**
* 计算非拖拽元素的位移偏移量
* @param index 元素索引
* @returns 包含 x 和 y 坐标偏移的对象
*/
function getItemTranslateOffset(index: number): TranslateOffset {
// 只在满足所有条件时才计算位移:拖拽中、非放下状态、已开始排序
if (!dragging.value || dropping.value || !sortingStarted.value) {
return { x: 0, y: 0 };
}
const dragIdx = dragIndex.value;
const insertIdx = insertIndex.value;
// 跳过正在拖拽的元素(拖拽元素由位置控制)
if (index == dragIdx) {
return { x: 0, y: 0 };
}
// 没有位置变化时不需要位移(拖回原位置)
if (dragIdx == insertIdx) {
return { x: 0, y: 0 };
}
// 根据布局类型计算位移
if (props.columns > 1) {
// 多列网格布局使用2D位移计算
return calculateGridOffset(index, dragIdx, insertIdx);
} else {
// 单列布局:使用简单的纵向位移
return calculateSingleColumnOffset(index, dragIdx, insertIdx);
}
}
/**
* 计算项目的完整样式对象
* @param index 项目索引
* @returns 样式对象
*/
function getItemStyle(index: number) {
const style = {};
const isCurrent = dragIndex.value == index;
// 多列布局时设置等宽分布
if (props.columns > 1) {
const widthPercent = 100 / props.columns;
style["flex-basis"] = `${widthPercent}%`;
style["width"] = `${widthPercent}%`;
style["box-sizing"] = "border-box";
}
// 放下动画期间,只保留基础样式
if (dropping.value) {
return style;
}
// 拖拽状态下的样式处理
if (dragging.value) {
if (isCurrent) {
// 拖拽元素:跟随移动
style["transform"] = `translate(${offsetX.value}px, ${offsetY.value}px)`;
style["z-index"] = "100";
} else {
// 其他元素:显示排序预览位移
const translateOffset = getItemTranslateOffset(index);
style["transform"] = `translate(${translateOffset.x}px, ${translateOffset.y}px)`;
}
}
return style;
}
/**
* 获取所有项目的位置信息
*/
async function getItemPosition(): Promise<void> {
return new Promise((resolve) => {
uni.createSelectorQuery()
.in(proxy)
.select(".cl-draggable")
.boundingClientRect()
.exec((res) => {
const box = res[0] as NodeInfo;
itemWidth.value = (box.width ?? 0) / props.columns;
uni.createSelectorQuery()
.in(proxy)
.selectAll(".cl-draggable__item")
.boundingClientRect()
.exec((res) => {
const rects = res[0] as NodeInfo[];
const positions: ItemPosition[] = [];
for (let i = 0; i < rects.length; i++) {
const rect = rects[i];
if (i == 0) {
itemHeight.value = rect.height ?? 0;
}
positions.push({
top: rect.top ?? 0,
left: rect.left ?? 0,
width: itemWidth.value,
height: itemHeight.value
});
}
itemPositions.value = positions;
resolve();
});
});
});
}
/**
* 获取项目是否禁用
* @param index 项目索引
* @returns 是否禁用
*/
function getItemDisabled(index: number): boolean {
return !isNull(list.value[index]["disabled"]) && (list.value[index]["disabled"] as boolean);
}
/**
* 检查拖拽元素的中心点是否移动到其他元素区域
*/
function checkMovedToOtherElement(): boolean {
// 如果没有位置信息,默认未移出
if (itemPositions.value.length == 0) return false;
const dragIdx = dragIndex.value;
const dragPosition = itemPositions.value[dragIdx];
// 计算拖拽元素当前的中心点位置(考虑拖拽偏移)
const dragCenterX = dragPosition.left + dragPosition.width / 2 + offsetX.value;
const dragCenterY = dragPosition.top + dragPosition.height / 2 + offsetY.value;
// 根据布局类型采用不同的判断策略
if (props.columns > 1) {
// 多列网格布局:检查中心点是否与其他元素区域重叠
for (let i = 0; i < itemPositions.value.length; i++) {
if (i == dragIdx) continue;
const otherPosition = itemPositions.value[i];
const isOverlapping =
dragCenterX >= otherPosition.left &&
dragCenterX <= otherPosition.left + otherPosition.width &&
dragCenterY >= otherPosition.top &&
dragCenterY <= otherPosition.top + otherPosition.height;
if (isOverlapping) {
return true;
}
}
} else {
// 检查是否向上移动超过上一个元素的中线
if (dragIdx > 0) {
const prevPosition = itemPositions.value[dragIdx - 1];
const prevCenterY = prevPosition.top + prevPosition.height / 2;
if (dragCenterY <= prevCenterY) {
return true;
}
}
// 检查是否向下移动超过下一个元素的中线
if (dragIdx < itemPositions.value.length - 1) {
const nextPosition = itemPositions.value[dragIdx + 1];
const nextCenterY = nextPosition.top + nextPosition.height / 2;
if (dragCenterY >= nextCenterY) {
return true;
}
}
}
return false;
}
/**
* 触摸开始事件处理
* @param event 触摸事件对象
* @param index 触摸的项目索引
*/
async function onTouchStart(event: UniTouchEvent, index: number, type: string) {
// 如果是长按触发,但未开启长按功能,则直接返回
if (type == "longpress" && !props.longPress) return;
// 如果是普通触摸触发,但已开启长按功能,则直接返回
if (type == "touch" && props.longPress) return;
// 检查是否禁用或索引无效
if (props.disabled) return;
if (getItemDisabled(index)) return;
if (index < 0 || index >= list.value.length) return;
// 获取触摸点
const touch = event.touches[0];
// 初始化拖拽状态
dragging.value = true;
// 初始化拖拽索引
dragIndex.value = index;
insertIndex.value = index; // 初始插入位置为原位置
startX.value = touch.clientX;
startY.value = touch.clientY;
offsetX.value = 0;
offsetY.value = 0;
// 初始化拖拽数据项
dragItem.value = list.value[index];
// 先获取所有项目的位置信息,为后续计算做准备
await getItemPosition();
// 触发开始事件
emit("start", index);
// 震动
vibrate(1);
// 阻止事件冒泡
event.stopPropagation();
// 阻止默认行为
event.preventDefault();
}
/**
* 触摸移动事件处理
* @param event 触摸事件对象
*/
function onTouchMove(event: TouchEvent): void {
if (!dragging.value) return;
const touch = event.touches[0];
// 更新拖拽偏移量
offsetX.value = touch.clientX - startX.value;
offsetY.value = touch.clientY - startY.value;
// 智能启动排序模拟:只有移出原元素区域才开始
if (!sortingStarted.value) {
if (checkMovedToOtherElement()) {
sortingStarted.value = true;
}
}
// 只有开始排序模拟后才计算插入位置
if (sortingStarted.value) {
// 计算拖拽元素当前的中心点坐标
const dragPos = itemPositions.value[dragIndex.value];
const dragCenterX = dragPos.left + dragPos.width / 2 + offsetX.value;
const dragCenterY = dragPos.top + dragPos.height / 2 + offsetY.value;
// 根据布局类型选择坐标轴网格布局使用X坐标单列布局使用Y坐标
const dragCenter = props.columns > 1 ? dragCenterX : dragCenterY;
// 计算最佳插入位置
const newIndex = calculateInsertIndex(dragCenter);
if (newIndex != insertIndex.value) {
insertIndex.value = newIndex;
}
}
// 阻止默认行为
event.preventDefault();
}
/**
* 触摸结束事件处理
*/
function onTouchEnd(): void {
if (!dragging.value) return;
// 旧索引
const oldIndex = dragIndex.value;
// 新索引
const newIndex = insertIndex.value;
// 如果位置发生变化,立即更新数组
if (oldIndex != newIndex && newIndex >= 0) {
const newList = [...list.value];
const item = newList.splice(oldIndex, 1)[0];
newList.splice(newIndex, 0, item);
list.value = newList;
// 触发变化事件
emit("update:modelValue", list.value);
emit("change", list.value);
}
// 开始放下动画
dropping.value = true;
dragging.value = false;
// 重置所有状态
reset();
// 等待放下动画完成后重置所有状态
emit("end", newIndex >= 0 ? newIndex : oldIndex);
}
/**
* 根据平台选择合适的key
* @param item 数据项
* @param index 索引
* @returns 合适的key
*/
function getItemKey(item: UTSJSONObject, index: number): string {
// #ifdef MP
// 小程序环境使用 index 作为 key避免数据错乱
return `${index}`;
// #endif
// #ifndef MP
// 其他平台使用 uid提供更好的性能
return item["uid"] as string;
// #endif
}
watch(
computed(() => props.modelValue),
(val: UTSJSONObject[]) => {
list.value = val.map((e) => {
return {
uid: e["uid"] ?? uuid(),
...e
};
});
},
{
immediate: true
}
);
</script>
<style lang="scss" scoped>
.cl-draggable {
@apply flex-col relative overflow-visible;
&--columns {
@apply flex-row flex-wrap;
}
&__item {
@apply relative z-10;
// #ifdef APP-IOS
@apply transition-none opacity-100;
// #endif
&--dragging {
@apply opacity-80 z-20;
}
&--disabled {
@apply opacity-60;
}
&--animating {
@apply duration-200;
transition-property: transform;
}
}
}
</style>