小程序实现的列表上下拖拽排序
发布于 4 年前 作者 yang94 3906 次浏览 来自 分享

先来看看效果

快速拖拽排序测试演示视频地址:https://v.qq.com/x/page/r3207k4fxe1.html

完整拖拽排序效果演示视频地址:https://v.qq.com/x/page/y3207g6agur.html

采用技术:uni-app

接下来分析分析实现该效果所需要用到的标签

  1. 元素是通过拖拽进行排序的,此处采用的是官方出的 <movable-area> <movable-view> 两位标签大佬解决移动的问题 (主要是相信官方支持的动画会比自己搞更加丝滑一些)。
  2. 支持拖拽到上下边界,检查可视区域的位置并自动进行滚动, 此处就需要我们的 <scroll-view> 标签大佬坐镇了。

标签的选择搞定了,再来了解了解这些标签要用到的重点属性

  1. movable-view 想要移动就必须作为 movable-area 的直接子元素,且 movable-area 必须设置 width,height 属性 (还有些提示可以查看文档)。
  2. movable-view 的 x, y 属性决定了 movable-view 再 movable-area 所处的位置 (是不是猜出了要搞些什么东东了)
  3. scroll-view 滚动到指定位置可以通过控制 scroll-top 的属性值来进行控制滚动

接下来就是怎么个实现思路,先来捋捋实现的步骤

  1. 列表该如何渲染
  2. 如何控制拖拽元素的跟随
  3. 如何使拖拽中的元素与相交互的元素进行位置调换
  4. 如何判断拖拽元素至上下边界滚动屏幕
  5. 如何使页面的滚动与拖拽时的滚动互不影响

描述完宏观的蓝图,接下来就是代码小细节,客官请随我来

一、解决列表渲染问题

/**
 * 上面说到 movable-view 可以通过 x,y 决定它的位置, 且 movable-area 需要设置 widht,height 属性
 *  配置完这些属性 movable-view 就可以再 movable-area 愉快的拖拽玩耍了
 *  思路:
 *  1. 通过列表的数量乘于显示列表项的高度得出最终可拖拽区域的总高度,赋值给 movable-area 
 *  2. 扩展列表项一些字段,此处使用 y 保存当前项距离顶部位置, idx 保存当前项所在列表的下标
/

// 伪代码
// js
initList(list) {
   this.areaHeight = list.length * this.height; // aeraHieght 可拖拽区域总高度, height 为元素所需高度
   this.internalList = list.map((item, idx) => {
       return {
          ...item,
          y: idx * this.height, // movable-view 当前项所处的高度
          idx: idx, // 当前项所处于列表的下标,用于比较
          animation: true, // 主要用于控制拖拽的元素要关闭动画, 其他的元素可以保留动画
       }       
   })
}

// html
 
        
          

 

二、 如何控制拖拽元素的跟随

// 主要是通过监听 movable-view 的 touchstart touchmove touchend 三个事件完成拖拽动作的起始、移动、结束。
// methods
{
   _dragStart(e){
      // 取得触摸点距离行顶部距离
      this.deviationY = (e.mp.touches[0].clientY - this.wrap.top) % this.height;
    this.internalList[idx].animation = false// 关闭当前拖拽元素的动画属性
    this.activeIdx = idx; // 保存当前拖拽元素的下标  
   },
   _dragMove(e) {
      const activeItem = this.internalList[this.activeIdx]; 
    if (!activeItem) return;

      // 实时取得触摸点的位置信息
    const clientY = e.mp.touches[0].clientY;
    let touchY = clientY - this.wrap.top - this.deviationY + this.scrollTop;
    if (touchY <= 0 || touchY + this.height >= this.areaHeight) return;

    activeItem.y = touchY; // 拖拽元素的移动秘密就在于此
   }
}

三、如何使拖拽中的元素与相交互的元素进行位置调换

// 上述代码解决了当前拖拽元素的位置移动问题, 接下来就需要解决拖拽元素和上下元素交互的问题
// methods
{
     __dragMove(e){
         // ...同上代码一致
         // 上下元素交互位置代码实现
        for(let item of this.internalList) {
             if (item.idx !== activeItem.idx) {
                 if (item.idx > activeItem.idx) { 
                     // 如果当前元素下标大于拖拽元素下标,则检查当前拖拽位置是否大于当前元素中心点
                      if (touchY > item.idx * this.height - this.height / 2) {
                           [activeItem.idx, item.idx] = [item.idx, activeItem.idx]; // 对调位置
                           item.y = item.idx * this.height; // 更新对调后的位置
                           break; // 退出循环
                      }  
                 } else {
                     // 如果当前元素下标小于拖拽元素下标,则检查当前拖拽位置是否小于当前元素中心点
                     if (touchY < item.idx * this.height + this.height / 2) {
                           [activeItem.idx, item.idx] = [item.idx, activeItem.idx];
                           item.y = item.idx * this.height; 
                           break;
                     }
                 }
             }
         }
     }
}

四、如何判断拖拽元素至上下边界滚动屏幕

// 将 movable-area 包裹在 scroll-view 标签中, 通过控制 scroll-top 的值来进行滚动
// 思路: 判断当前拖拽元素的位置信息与当前屏幕可视区域进行比较
// methods 
{
     _dragMove(e) {
         // ...同上代码 
         // 检查当前位置是否处于可视区域 
         if (activeItem.idx + 1 * this.height + this.height / 2 > this.scrollTop + this.wrap.top) {
             this.viewTop = this.scrollTop + this.height; // 往上滚动一个元素的高度
         } else if (activeItem.idx * this.height - this.height / 2 < this.scrollTop ) {
             this.viewTop = this.scrollTop - this.height; // 往下滚动一个元素的高度
         }
     }
}

五、如何使页面的滚动与拖拽时的滚动互不影响

// 事实上我是通过一种取巧的方式, scroll-veiw 有一个 scroll-y 属性可以控制滚动方向
// 思路: 
// 1.不进行滚动的时候将 scroll-y 置为 true , 使用默认的滚动效果
// 2.当进入拖拽排序状态时则将 scroll0y 置为 false, 滚动通过拖拽代码比较计算滚动位置


完整代码:

主要小程序上的插槽不允许往外传值、所以自定义元素实现的方式相比于H5实现Vue的方式比较别扭。

因为有多个地方需要用到排序功能,所以边抽离了 js 部分进行混入。

// DargSortMixin.js 文件

export default {
    props: {
        list: {
            type: Array,
            default() {
                return [];
            },
        },

        sort: {
            type: Boolean,
            defaultfalse,
        },

        height: {
            type: Number,
            default66,
        },
    },

    data() {
        return {
            areaHeight: 0// 区域总高度
            internalList: [], // 列表

            activeIdx: -1// 移动中激活项
            deviationY: 0// 偏移量

            // 包裹容器信息
            wrap: {
                top: 0,
                height: 0,
            },
            viewTop: 0// 指定滚动高度
            scrollTop: 0// 容器实时滚动高度
            scrollWithAnimation: false,
            canScroll: true,
        };
    },

    created() {
        // 组件使用选择器,需用使用this
        const query = this.createSelectorQuery();

        query
            .select('#scroll-wrap')
            .boundingClientRect(rect => {
                if (rect) {
                    this.wrap = {
                        top: rect.top,
                        height: rect.height,
                    };
                }
            })
            .exec();
    },

    watch: {
        list: {
            handler(val) {
                this.initList(val);
            },
            immediate: true,
        },
    },

    methods: {
        getList() {
            return this.internalList
                .sort((a, b) => {
                    return a.idx - b.idx;
                })
                .map(item => {
                    let newItem = { ...item };

                    delete newItem.y;
                    delete newItem.idx;
                    delete newItem.animation;

                    return newItem;
                });
        },

        initList(list) {
            this.areaHeight = list.length * this.height;
            this.internalList = list.map((item, idx) => {
                return {
                    ...item,
                    y: idx * this.height,
                    idx,
                    animation: true,
                };
            });
        },

        _dragStart(e, idx) {
            // 取得触摸点距离行顶部距离
            this.deviationY = (e.mp.touches[0].clientY - this.wrap.top) % this.height;
            this.internalList[idx].animation = false// 关闭动画
            this.activeIdx = idx;
            this.scrollWithAnimation = true;
            this.canScroll = false;
        },

        _dragMove(e) {
            const activeItem = this.internalList[this.activeIdx];
            if (!activeItem) return;

            // 保存触摸点位置和长按时中心一致
            const clientY = e.mp.touches[0].clientY;
            let touchY = clientY - this.wrap.top - this.deviationY + this.scrollTop;
            if (touchY <= 0 || touchY + this.height >= this.areaHeight) return;

            activeItem.y = touchY; // 设置位置

            // 检查元素和上下交互元素的位置
            for (const item of this.internalList) {
                if (item.idx !== activeItem.idx) {
                    if (item.idx > activeItem.idx) {
                        if (touchY > item.idx * this.height - this.height / 2) {
                            [activeItem.idx, item.idx] = [item.idx, activeItem.idx]; // 对调位置
                            item.y = item.idx * this.height; // 更新位置
                            break;
                        }
                    } else {
                        if (touchY < item.idx * this.height + this.height / 2) {
                            [activeItem.idx, item.idx] = [item.idx, activeItem.idx]; // 对调位置
                            item.y = item.idx * this.height; // 更新位置
                            break;
                        }
                    }
                }
            }

            // 检查当前位置是否处于可视区域
            if (
                (activeItem.idx + 1) * this.height + this.height / 2 >
                this.scrollTop + this.wrap.height
            ) {
                this.canScroll = true;
                activeItem.y = activeItem.idx * this.height;
                this.$nextTick(() => {
                    this.viewTop = this.scrollTop + this.height;
                });
            } else if (activeItem.idx * this.height - this.height / 2 < this.scrollTop) {
                this.canScroll = true;
                activeItem.y = activeItem.idx * this.height;
                this.$nextTick(() => {
                    this.viewTop = this.scrollTop - this.height;
                });
            }
        },

        _dragEnd(e) {
            const activeItem = this.internalList[this.activeIdx];
            if (!activeItem) return;

            activeItem.animation = true;
            activeItem.disabled = true;
            activeItem.y = activeItem.idx * this.height;

            this.activeIdx = -1;
            this.scrollWithAnimation = false;
            this.canScroll = true;
        },

        _onScroll(e) {
            this.scrollTop = e.detail.scrollTop;
        },
    },
};


// TheDragSortAreaList.vue 文件

import DragSortMixin from '@/mixins/DragSortMixin';

export default {
    name: 'TheDragSortTableList',

    mixins: [DragSortMixin],
};

                                
                                    
                                        
                                    
                                
                            
                        
                    
                
            
        
    

.active-item {
    z-index: 10;
}

.drag-item {
    background: $theme-color;
    color: $white !important;

    .count {
        color: $white !important;
    }
}

回到顶部