前言

最近在写小程序的过程中遇到一个拖拽排序菜单项的功能,搜索了一些组件,结果强差人意,所以借鉴网上的一些实现方案,去自己写一个例子,以此记录,方便以后学习使用。

确定需求

1.首先实现拖拽。
2.拖拽之后实现重新排序,且动态平滑。

思路

1.首先,基于简易化,拖拽项大小是一样的,不规则的先不考虑,先爬再跑!!
2.然后是既然动态平滑,肯定是是使用过渡 transition,
3.实现拖拽的话,那这边就需要脱离文档流了,定位选择transform实现
4.使用自定义手势, 如 touchstart, touchmove, touchend, 使用自定义手势可以方便我们控制每一个细节.
5.排序的话就是通过上面 touchstart, touchmove, touchend 这三兄弟拿到触摸信息后动态计算出当前元素的排序位置,然后根据当前激活元素的排序位置去动态更换数组内其他元素的位置. 大概意思就是十个兄弟做一排, 老大起来跑到老三的位置, 老三看了看往前移了移, 老二看了看也往前移了移. 当然这是正序, 还有逆序, 比如老十跑到了老大的位置, 那么老大到老九都得顺序后移一个位置 好了这就开始吧。

实现

1.首先是操作项列表我们不能直接操作,可能会污染数据,毕竟我们排序完成后,这边的数据还是需要给后端管理的,故我们克隆一个数据list去操作,这样就不会影响了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//初始化数据
init() {
// 遍历数据源增加扩展项, 以用作排序使用
//this.data.listData 为后台给的数据
let list = this.data.listData.map((item, index) => {
let data = {
key: index,
tranX: 0,//设置当前x轴偏移
tranY: 0,//设置当前y轴偏移
data: item
}
return data
});
this.setData({
list
})
//设置父块高度
wx.createSelectorQuery().select(".dragItem").boundingClientRect((res) => {
let rows = Math.ceil(this.data.list.length / this.data.columns); //行数
let itemWrapHeight = rows * res.height; //设置父块高度
this.item = res; //获取单个元素项高度
this.setData({
itemWrapHeight: itemWrapHeight
});
this.getPosition(this.data.list, false);
}).exec()
}

2.根据排序后 list 数据进行位移计算
以上 insert 方法中我们最后调用了 getPosition 方法, 该方法用于计算每一项元素的 tranX 和 tranY 并进行渲染, 该函数在初始化渲染时候也需要调用. 所以加了一个 vibrate 变量进行不同的处理判断.

该函数执行逻辑:

首先对传入的 data 数据进行循环处理, 根据以下公式计算出每个元素的 tranX 和 tranY (this.item.width, this.item.height 分别是元素的宽和高, this.data.columns 是列数, item.key 是当前元素的排序key值)
item.tranX = this.item.width * (item.key % this.data.columns);
item.tranY = Math.floor(item.key / this.data.columns) * this.item.height;
设置处理后的列表数据 list
判断是否需要执行抖动以及触发事件逻辑, 该判断用于区分初始化调用和insert方法中调用, 初始化时候不需要后面逻辑
首先设置 itemTransition 为 true 让 item 变换时候加有动画效果
然后抖一下, wx.vibrateShort(), 嗯~, 这是个好东西
最后copy一份 listData 然后出发 change 事件把排序后的数据抛出去
最后注意, 该函数并未改变 list 中真正的排序, 而是根据 key 来进行伪排序, 因为如果改变 list 中每一个项的顺序 dom结构会发生变化, 这样就达不到我们要的丝滑效果了. 但是最后 this.triggerEvent('change', {listData: listData}) 时候是真正排序后的数据, 并且是已经去掉了 key, tranX, tranY 的原始数据信息(这里每一项数据有key, tranX, tranY 是因为初始化时候做了处理, 所以使用时无需考虑)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
getPosition(data, vibrate = true) {
//根据key去生成当前元素的位置
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
});
this.setData({
list: list
});
if (!vibrate) return;
this.setData({
itemTransition: true
})
wx.vibrateShort();
let listData = [];
list.forEach((item) => {
listData[item.key] = item.data
});
this.setData({
listData
})
},

3.页面显示和绑定事件

1
2
3
4
5
6
7
<view class="dragBox" style="height: {{itemWrapHeight}}px;">
<view class="dragItem {{cur==index?'cur':''}} {{itemTransition ? 'itemTransition':''}}" id="item{{index}}" data-index="{{index}}" data-key="{{item.key}}" bind:longpress="longPress" wx:for="{{list}}" wx:key="index" bind:touchend="touchend" bind:touchmove="touchmove" style="transform:translate3d{{cur==index?'('+tranX+'px,'+tranY+'px,0)':'('+item.tranX+'px,'+item.tranY+'px,0)'}};width: {{100 / columns+'%'}}">
<view class="dragImgBox">
<image src="{{item.data.images}}"></image>
</view>
</view>
</view>

绑定相关事件,设置样式。这里为了体验把 touchstart 换成了 longpress 长按触发,同时通过cur来判断当前操作项是哪个元素,值得注意的是wx:key="index",这个必须要写对,不然会导致更改list排序和位置的时候产生问题。

4.事件

longPress
首先我们需要设置一个状态 touch 表示我们在拖拽了. 然后就是获取 pageX, pageY 注意这里获取 pageX, pageY 而不是 clientX, clientY 因为我们的 drag 组件有可能会有 margin 或者顶部仍有其他元素, 这时候如果获取 clientX, clientY 就会出现偏差了. 这里把当前 pageX, pageY 设置为初始触摸点 startX, startY.

然后需要计算下初始化的激活元素的偏移位置 tranX 和 tranY, 这里为了优化体验在列数为1的时候初始化 tranX 不做位移, tranY 移动到当前激活元素中间位置, 多列的时候把 tranX 和 tranY 全部位移到当前激活元素中间位置.

具体参看 wxml 中代码以及 clearData 方法中对应的代码) 以及偏移量 tranX, tranY. 然后震动一下下 wx.vibrateShort() 体验美美哒.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 长按触发移动排序
*/
longPress(e) {
this.setData({
touch: true
});
// 获取当前点击的位置
this.startX = e.changedTouches[0].pageX;
this.startY = e.changedTouches[0].pageY;
// 当前索引
let index = e.currentTarget.dataset.index;
//计算X轴初始位移, 使 item 水平中心移动到点击处
this.tranX = this.startX - this.item.width / 2;
this.tranY = this.startY - this.item.height / 2;
// 设置点击的索引值
this.setData({
cur: index,
tranX: this.tranX,
tranY: this.tranY,
});
wx.vibrateShort();
return false;
}

touchmove

touchmove 每次都是故事的主角, 这次也不列外. 看这满满的代码量就知道了. 首先进来需要判断是否在拖拽中, 不是则需要返回.

然后判断是否超过一屏幕. 这是啥意思呢, 因为我们的拖拽元素可能会很多甚至超过整个屏幕, 需要滑动来处理. 但是我们这里使用了 catch:touchmove 事件所以会阻塞页面滑动. 于是我们需要在元素超过一个屏幕的时候进行处理, 这里分两种情况. 一种是我们拖拽元素到页面底部时候页面自动向下滚动一个元素高度的距离, 另一种是当拖拽元素到页面顶部时候页面自动向上滚动一个元素高度的距离.

接着我们设置已经重新计算好的 tranX 和 tranY, 并获取当前元素的排序关键字 key 作为初始 originKey, 然后通过当前的 tranX 和 tranY 使用 calculateMoving 方法计算出 endKey.

最后我们调用 this.insert(originKey, endKey) 方法来对数组进行排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//移动
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;
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);
}

touchend

1
2
3
4
5
//松开
touchend(e) {
if (!this.data.touch) return;
this.clearData();
},

calculateMoving
通过以上介绍我们已经基本完成了拖拽排序的主要功能, 但是还有两个关键函数没有解析. 其中一个就是 calculateMoving 方法, 该方法根据当前偏移量 tranX 和 tranY 来计算 目标key.
具体计算规则:

  • 根据列表的长度以及列数计算出当前的拖拽元素行数 rows
  • 根据 tranX 和 当前元素的宽度 计算出 x 轴上的偏移数 i
  • 根据 tranY 和 当前元素的高度 计算出 y 轴上的偏移数 j
  • 判断 i 和 j 的最大值和最小值
  • 根据公式 endKey = i + columns * j 计算出 目标key
  • 返回 目标key
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 根据当前的手指偏移量计算目标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
拖拽排序中没有解析的另一个主要函数就是 insert方法. 该方法根据 originKey(起始key) 和 endKey(目标key) 来对数组进行重新排序.

具体排序规则:

  • 首先判断 origin 和 end 的大小进行不同的逻辑处理
  • 循环列表 list 进行逻辑处理
  • 如果是 origin 小于 end 则把 origin 到 end 之间(不包含 origin 包含 end) 所有元素的 key 减去 1, 并把 origin 的key值设置为 end
  • 如果是 origin 大于 end 则把 end 到 origin 之间(不包含 origin 包含 end) 所有元素的 key 加上 1, 并把 origin 的key值设置为 end
  • 调用 getPosition 方法进行渲染
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* 根据起始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);
}
}

touchEnd
写了这么久, 三兄弟就剩最后一个了, 这个兄dei貌似不怎么努力嘛, 就两行代码?
是的, 就两行… 一行判断是否在拖拽, 另一行清除缓存数据

1
2
3
4
5
6

touchEnd() {
if (!this.data.touch) return;

this.clearData();
}

clearData
因为有重复使用, 所以选择把这些逻辑包装了一层

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 清除参数
*/
clearData() {
this.originKey = -1;

this.setData({
touch: false,
cur: -1,
tranX: 0,
tranY: 0
});
}

这时候方式wxss代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
.dragBox{
overflow: hidden;
position: relative;
}

.dragItem {
padding: 10rpx;
box-sizing: border-box;
position: absolute;
top: 0;
left: 0;
z-index: 1;
transform: translate3d(0,0,0);
}
.dragItem.itemTransition{
transition: all 0.3s;

}
.dragItem.cur{
z-index: 2;
transition: all 0s;
}
.dragImgBox {
width: 100%;
height: 0;
padding-bottom: 100%;
position: relative;
}

.dragImgBox image {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}

好了,这就完成了