最近po主写小程序过程当中遇到一个拖拽排序需求. 上网一顿搜索未果, 遂自行实现.css
此次就不上效果图了, 直接扫码感觉吧.jquery
首先因为并无啥现成的小程序案例给我参考. 因此有点无从下手, 那就找个h5的拖拽实现参考参考. 因而在jquery插件网看了几个拖拽排序实现后基本肯定了思路. 大概就是用 transform 作变换. 是的, 灵感这种东西就是借鉴过来的~~git
首先能拖拽的元素最起码都要是同样的大小, 至于不规则大小, 或者大小成倍数关系的均不在本次实现范围.github
而后咱们对应需求找解决方案:小程序
使用 movable-view 实现拖拽, 这种方式简单快捷, 可是因为咱们的灵感是使用 transform 作变换, 而这里 movable-view 自己也是用 transform 来实现的, 因此会有冲突, 遂弃之.api
使用自定义手势, 如 touchstart, touchmove, touchend. 对的又是这三个基佬, 虽然咱们在作下拉刷新时候采用用了 movable-view 而抛弃这三兄弟. 可是是金子总会发光的, 今天就是大家三兄弟展现自身本领的时候了(真香警告). 废话有点多, 言归正传, 使用自定义手势能够方便咱们控制每个细节.数组
排序是基于拖拽的, 经过上面 touchstart, touchmove, touchend 这三兄弟拿到触摸信息后动态计算出当前元素的排序位置,而后根据当前激活元素的排序位置去动态更换数组内其余元素的位置. 大概意思就是十个兄弟作一排, 老大起来跑到老三的位置, 老三看了看往前移了移, 老二看了看也往前移了移. 固然这是正序, 还有逆序, 好比老十跑到了老大的位置, 那么老大到老九都得顺序后移一个位置.缓存
自定义列数, 到是没啥难度, 小程序组件暴露一个列属性, 而后把计算过程当中的固定的列数改为该参数就能够了微信
先上 touchstart, touchmove, touchend 三兄弟dom
这里为了体验把 touchstart 换成了 longpress 长按触发. 首先咱们须要设置一个状态 touch 表示咱们在拖拽了. 而后就是获取 pageX, pageY 注意这里获取 pageX, pageY 而不是 clientX, clientY 由于咱们的 drag 组件有可能会有 margin 或者顶部仍有其余元素, 这时候若是获取 clientX, clientY 就会出现误差了. 这里把当前 pageX, pageY 设置为初始触摸点 startX, startY.
而后须要计算下初始化的激活元素的偏移位置 tranX 和 tranY, 这里为了优化体验在列数为1的时候初始化 tranX 不作位移, tranY 移动到当前激活元素中间位置, 多列的时候把 tranX 和 tranY 所有位移到当前激活元素中间位置.
最后设置当前激活元素的索引 cur 和 curZ(该参数用于控制激活元素z轴的显示时机, 具体参看 wxml 中代码以及 clearData 方法中对应的代码) 以及偏移量 tranX, tranY. 而后震动一下下 wx.vibrateShort() 体验美美哒.
/** * 长按触发移动排序 */ longPress(e) { this.setData({ touch: true }); this.startX = e.changedTouches[0].pageX this.startY = e.changedTouches[0].pageY let index = e.currentTarget.dataset.index; if(this.data.columns === 1) { // 单列时候X轴初始不作位移 this.tranX = 0; } else { // 多列的时候计算X轴初始位移, 使 item 水平中心移动到点击处 this.tranX = this.startX - this.item.width / 2 - this.itemWrap.left; } // 计算Y轴初始位移, 使 item 垂直中心移动到点击处 this.tranY = this.startY - this.item.height / 2 - this.itemWrap.top; this.setData({ cur: index, curZ: index, tranX: this.tranX, tranY: this.tranY, }); wx.vibrateShort(); }
touchmove 每次都是故事的主角, 此次也不列外. 看这满满的代码量就知道了. 首先进来须要判断是否在拖拽中, 不是则须要返回.
而后判断是否超过一屏幕. 这是啥意思呢, 由于咱们的拖拽元素可能会不少甚至超过整个屏幕, 须要滑动来处理. 可是咱们这里使用了 catch:touchmove 事件因此会阻塞页面滑动. 因而咱们须要在元素超过一个屏幕的时候进行处理, 这里分两种状况. 一种是咱们拖拽元素到页面底部时候页面自动向下滚动一个元素高度的距离, 另外一种是当拖拽元素到页面顶部时候页面自动向上滚动一个元素高度的距离.
接着咱们设置已经从新计算好的 tranX 和 tranY, 并获取当前元素的排序关键字 key 做为初始 originKey, 而后经过当前的 tranX 和 tranY 使用 calculateMoving 方法计算出 endKey.
最后咱们调用 this.insert(originKey, endKey) 方法来对数组进行排序
touchMove(e) { if (!this.data.touch) return; let tranX = e.touches[0].pageX - this.startX + this.tranX, tranY = e.touches[0].pageY - this.startY + this.tranY; let overOnePage = this.data.overOnePage; // 判断是否超过一屏幕, 超过则须要判断当前位置动态滚动page的位置 if(overOnePage) { if(e.touches[0].clientY > this.windowHeight - this.item.height) { wx.pageScrollTo({ scrollTop: e.touches[0].pageY + this.item.height - this.windowHeight, duration: 300 }); } else if(e.touches[0].clientY < this.item.height) { wx.pageScrollTo({ scrollTop: e.touches[0].pageY - this.item.height, duration: 300 }); } } this.setData({tranX: tranX, tranY: tranY}); let originKey = e.currentTarget.dataset.key; let endKey = this.calculateMoving(tranX, tranY); // 防止拖拽过程当中发生乱序问题 if (originKey == endKey || this.originKey == originKey) return; this.originKey = originKey; this.insert(originKey, endKey); }
经过以上介绍咱们已经基本完成了拖拽排序的主要功能, 可是还有两个关键函数没有解析. 其中一个就是 calculateMoving 方法, 该方法根据当前偏移量 tranX 和 tranY 来计算 目标key.
具体计算规则:
/** * 根据当前的手指偏移量计算目标key */ calculateMoving(tranX, tranY) { let rows = Math.ceil(this.data.list.length / this.data.columns) - 1, i = Math.round(tranX / this.item.width), j = Math.round(tranY / this.item.height); i = i > (this.data.columns - 1) ? (this.data.columns - 1) : i; i = i < 0 ? 0 : i; j = j < 0 ? 0 : j; j = j > rows ? rows : j; let endKey = i + this.data.columns * j; endKey = endKey >= this.data.list.length ? this.data.list.length - 1 : endKey; return endKey }
拖拽排序中没有解析的另外一个主要函数就是 insert方法. 该方法根据 originKey(起始key) 和 endKey(目标key) 来对数组进行从新排序.
具体排序规则:
/** * 根据起始key和目标key去从新计算每一项的新的key */ insert(origin, end) { let list; if (origin < end) { list = this.data.list.map((item) => { if (item.key > origin && item.key <= end) { item.key = item.key - 1; } else if (item.key == origin) { item.key = end; } return item }); this.getPosition(list); } else if (origin > end) { list = this.data.list.map((item) => { if (item.key >= end && item.key < origin) { item.key = item.key + 1; } else if (item.key == origin) { item.key = end; } return item }); this.getPosition(list); } }
以上 insert 方法中咱们最后调用了 getPosition 方法, 该方法用于计算每一项元素的 tranX 和 tranY 并进行渲染, 该函数在初始化渲染时候也须要调用. 因此加了一个 vibrate 变量进行不一样的处理判断.
该函数执行逻辑:
最后注意, 该函数并未改变 list 中真正的排序, 而是根据 key 来进行伪排序, 由于若是改变 list 中每个项的顺序 dom结构会发生变化, 这样就达不到咱们要的丝滑效果了. 可是最后 this.triggerEvent('change', {listData: listData}) 时候是真正排序后的数据, 而且是已经去掉了 key, tranX, tranY 的原始数据信息(这里每一项数据有key, tranX, tranY 是由于初始化时候作了处理, 因此使用时无需考虑)
/** * 根据排序后 list 数据进行位移计算 */ getPosition(data, vibrate = true) { let list = data.map((item, index) => { item.tranX = this.item.width * (item.key % this.data.columns); item.tranY = Math.floor(item.key / this.data.columns) * this.item.height; return item }); this.setData({ list: list }); if(!vibrate) return; this.setData({ itemTransition: true }) wx.vibrateShort(); let listData= []; list.forEach((item) => { listData[item.key] = item.data }); this.triggerEvent('change', {listData: listData}); }
写了这么久, 三兄弟就剩最后一个了, 这个兄dei貌似不怎么努力嘛, 就两行代码?
是的, 就两行... 一行判断是否在拖拽, 另外一行清除缓存数据
touchEnd() { if (!this.data.touch) return; this.clearData(); }
由于有重复使用, 因此选择把这些逻辑包装了一层.
/** * 清除参数 */ clearData() { this.originKey = -1; this.setData({ touch: false, cur: -1, tranX: 0, tranY: 0 }); // 延迟清空 setTimeout(() => { this.setData({ curZ: -1, }) }, 300) }
介绍完三兄弟以及他们的表亲后, 故事就剩咱们的 init 方法了.
init 方法执行逻辑:
init() { // 遍历数据源增长扩展项, 以用做排序使用 let list = this.data.listData.map((item, index) => { let data = { key: index, tranX: 0, tranY: 0, data: item } return data }); this.setData({ list: list, itemTransition: false }); this.windowHeight = wx.getSystemInfoSync().windowHeight; // 获取每一项的宽高等属性 this.createSelectorQuery().select(".item").boundingClientRect((res) => { let rows = Math.ceil(this.data.list.length / this.data.columns); this.item = res; this.getPosition(this.data.list, false); let itemWrapHeight = rows * res.height; this.setData({ itemWrapHeight: itemWrapHeight }); this.createSelectorQuery().select(".item-wrap").boundingClientRect((res) => { this.itemWrap = res; let overOnePage = itemWrapHeight + res.top > this.windowHeight; this.setData({ overOnePage: overOnePage }); }).exec(); }).exec(); }
如下是整个组件的 wxml, 其中具体渲染部分使用了抽象节点 <item item="{{item.data}}"></item>
并传入了每一项的数据, 使用抽象节点是为了具体展现的效果和该组件自己代码解耦. 若是要到性能问题或者以为麻烦, 可直接在该组件下编写样式代码.
最新实现中已经删除了抽象节点, 经测试抽象节点会在某些老款机型如: iphone 6s 及如下型号机器上产生巨大性能问题, 因此这里直接把渲染逻辑写入 wxml 中. 须要使用该组件直接修改 .info 部分样式和内容便可.
<view> <view style="overflow-x: {{overOnePage ? 'hidden' : 'initial'}}"> <view class="item-wrap" style="height: {{ itemWrapHeight }}px;"> <view class="item {{cur == index? 'cur':''}} {{curZ == index? 'zIndex':''}} {{itemTransition ? 'itemTransition':''}}" wx:for="{{list}}" wx:key="{{index}}" id="item{{index}}" data-key="{{item.key}}" data-index="{{index}}" style="transform: translate3d({{index === cur ? tranX : item.tranX}}px, {{index === cur ? tranY: item.tranY}}px, 0px);width: {{100 / columns}}%" bind:longpress="longPress" catch:touchmove="touchMove" catch:touchend="touchEnd"> <view class="info"> <view> <image src="{{item.data.images}}"></image> </view> </view> </view> </view> </view> <view wx:if="{{overOnePage}}" class="indicator"> <view>滑动此区域滚动页面</view> </view> </view>
这里我直接把 scss 代码拉出来了, 这样看的更清楚, 具体完整代码文末会给出地址
@import "../../assets/css/variables"; .item-wrap { position: relative; .item { position: absolute; width: 100%; z-index: 1; &.itemTransition { transition: transform 0.3s; } &.zIndex { z-index: 2; } &.cur { background: #c6c6c6; transition: initial; } } } .info { position: relative; padding-top: 100%; background: #ffffff; & > view { position: absolute; border: 1rpx solid $lineColor; top: 0; left: 0; width: 100%; height: 100%; overflow: hidden; padding: 10rpx; box-sizing: border-box; image { width: 100%; height: 100%; } } } .indicator { position: fixed; z-index: 99999; right: 0rpx; top: 50%; margin-top: -250rpx; padding: 20rpx; & > view { width: 36rpx; height: 500rpx; background: #ffffff; border-radius: 30rpx; box-shadow: 0 0 10rpx -4rpx rgba(0, 0, 0, 0.5); color: $mainColor; padding-top: 90rpx; box-sizing: border-box; font-size: 24rpx; text-align: center; opacity: 0.8; } }
该拖拽组件来来回回花了我好几周时间, 算的上是该组件库中最有质量的一个组件了. 因此若是您看了以为还不错欢迎star. 固然遇到问题在 issues 提给我就好了, 我回复仍是蛮快的~~
还有就是该组件受限制于微信自己的 api 以及一些特性, 在超出一屏时候会没法滑动. 这里我作了个判断超出一屏时候加了个指示器辅助滑动, 使用时可对样式稍作修改(由于感受有点丑...) 最新版本已经支持为所欲为的滑动体验了, 也去除了滑动指示器
其余的好像没啥了...
补充一句, 该组件基本上没怎么使用太多小程序相关的特性, 因此按照这个思路用h5实现应该也是能够的, 若是有h5方面的需求应该也是能够知足的...