| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672 |
- <template>
- <view
- class="cl-draggable"
- :class="[
- {
- 'cl-draggable--grid': props.columns > 1
- },
- pt.className
- ]"
- >
- <view
- v-for="(item, index) in list"
- :key="getItemKey(item, index)"
- class="cl-draggable__item"
- :class="[
- {
- 'cl-draggable__item--disabled': disabled
- },
- dragging && dragIndex == index ? `opacity-80 ${pt.ghost?.className}` : ''
- ]"
- :style="getItemStyle(index)"
- @touchstart="
- (event: UniTouchEvent) => {
- onTouchStart(event, index);
- }
- "
- @touchmove.stop.prevent="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";
- 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
- },
- /** 动画持续时间(毫秒) */
- animation: {
- type: Number,
- default: 150
- },
- /** 列数:1为单列纵向布局,>1为多列网格布局 */
- columns: {
- type: Number,
- default: 1
- }
- });
- 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 (props.animation > 0 && !isCurrent) {
- style["transition-property"] = "transform";
- style["transition-duration"] = `${props.animation}ms`;
- }
- // 拖拽状态下的样式处理
- if (dragging.value) {
- if (isCurrent) {
- // 拖拽元素:跟随移动
- style["transform"] = `translate(${offsetX.value}px, ${offsetY.value}px)`;
- style["z-index"] = "100";
- } else {
- // 其他元素:显示排序预览位移
- const translateOffset = getItemTranslateOffset(index);
- if (translateOffset.x != 0 || translateOffset.y != 0) {
- 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): Promise<void> {
- // 检查是否禁用或索引无效
- 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);
- }
- /**
- * 触摸移动事件处理
- * @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;
- // 让拖拽元素回到自然位置(偏移归零)
- offsetX.value = 0;
- offsetY.value = 0;
- // 等待放下动画完成后重置所有状态
- setTimeout(() => {
- emit("end", newIndex >= 0 ? newIndex : oldIndex);
- reset();
- }, 10);
- }
- /**
- * 根据平台选择合适的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;
- }
- .cl-draggable--grid {
- @apply flex-row flex-wrap;
- }
- .cl-draggable__item {
- @apply relative;
- }
- .cl-draggable__item--dragging {
- @apply opacity-80;
- }
- .cl-draggable__item--disabled {
- @apply opacity-60;
- }
- </style>
|