需求是写一个小程序,用来实现一个app大部分功能。里面有个功能是列表的左划删除,可能app那边有现成的组件/库可以用,小程序官方没有提供这样的组件,于是求助于google。

搜到了这个 ,看起来挺好的,但是要求高度固定,我列表里面有图片,我无法确定图片的高度固定,跟相关人员沟通起来又是一堆废话,算是备选。

然后找到了这个

nutui的swipe组件,源码 vue写的,我项目用的是react,不过没关系,vue3的Composition api与react hooks是师出同门。看懂它然后翻译一下(我又找到了一个适合自己的巨人)。

源码解读

布局

html、css、js永远是一家人,不要看不起html、css,优秀的布局可以省好多代码。

布局分为3部分,content、left、right,content为列表的主要内容,left/right分别表示向右/左滑所展示的额外内容,然后left、right分别绝对定位,并且配合translate移出content的可见范围。

逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<template>
<view
:class="classes"
:style="touchStyle"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
@touchcancel="onTouchEnd"
>
<view class="nut-swipe__left" ref="leftRef" :id="'leftRef-' + refRandomId">
<slot name="left"></slot>
</view>

<view class="nut-swipe__content">
<slot name="default"></slot>
</view>

<view class="nut-swipe__right" ref="rightRef" :id="'rightRef-' + refRandomId">
<slot name="right"></slot>
</view>
</view>
</template>
1
2
3
4
5
const touchStyle = computed(() => {
return {
transform: `translate3d(${state.offset}px, 0, 0)`
};
});

一目了然。有三个slot,分别对应上述布局中的content、left、right。监听了最外层view的touch事件,目的是根据touch时的各种值来控制offset的值,从而生成touchStyle,使最外层的view移动。

那么如何得到touch事件的各种值呢,他们封装了个useTouch

代码还是挺简的

1
2
3
4
5
6
7
const startX = ref(0); //滑动起始x值
const startY = ref(0);// 滑动起始y值
const deltaX = ref(0);// 滑动的x值
const deltaY = ref(0); // 滑动的y值
const offsetX = ref(0);// deltaX的绝对值
const offsetY = ref(0); // deltaY的绝对值
const direction = ref<Direction>(''); // 滑动的方向

有三个函数,reset/start/move,start与move分别在touchStart与touchMove时调用,用来记录滑动的距离,调用start时会调用reset。

值得一提的是,这里计算滑动方向的方法

1
2
3
4
5
6
7
8
9
function getDirection(x: number, y: number) {
if (x > y && x > MIN_DISTANCE) {
return 'horizontal';
}
if (y > x && y > MIN_DISTANCE) {
return 'vertical';
}
return '';
}

是根据每次滑动offsetX与offsetY的大小以及滑动距离是否大于10决定的。

再来看swipe组件处理滑动的逻辑

1
2
3
4
5
6
7
8
9
const initWidth = async () => {
leftRefWidth.value = await getRefWidth(leftRef);
rightRefWidth.value = await getRefWidth(rightRef);
};
onMounted(() => {
setTimeout(() => {
initWidth();
}, 100);
});

首先拿到left slot与right slot的宽度,如果没有,就是0。然后会监听touchstart、touchmove、touchend事件。

touchstart

1
2
3
4
onTouchStart(event: Event) {
if (props.disabled) return;
touch.start(event);
}

平淡无奇,核心是调用了touch.start方法。

onTouchMove

1
2
3
4
5
6
7
8
9
10
11
12
13
14
async onTouchMove(event: Event) {
if (props.disabled) return;
touch.move(event);
if (touch.isHorizontal()) {
state.moving = true;
setoffset(touch.deltaX.value);
if (props.touchMovePreventDefault) {
event.preventDefault();
}
if (props.touchMoveStopPropagation) {
event.stopPropagation();
}
}
}

核心是调用touch.move以及setoffset方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const setoffset = (deltaX: number) => {
position = deltaX > 0 ? 'right' : 'left';
let offset = deltaX;
switch (position) {
case 'left':
if (opened && oldPosition === position) {
offset = -rightRefWidth.value;
} else {
offset = Math.abs(deltaX) > rightRefWidth.value ? -rightRefWidth.value : deltaX;
}
break;
case 'right':
if (opened && oldPosition === position) {
offset = leftRefWidth.value;
} else {
offset = Math.abs(deltaX) > leftRefWidth.value ? leftRefWidth.value : deltaX;
}
break;
}
state.offset = offset;
};

这几行if else写的很精妙。swipe组件同时支持左/右滑,先来看左划的逻辑。

opened表示是否已经打开,如果是true并且本次滑动方向与上一次一致,那么offset永远等于-rightRefWidth。

假设是第一次滑,并且right slot不为空,走的是这句代码。

1
offset = Math.abs(deltaX) > rightRefWidth.value ? -rightRefWidth.value : deltaX;

精妙的三元表达式,无形中处理了很多边界情况,先看最正常的:第一次滑,并且right slot不为空。

这时Math.abs(deltaX) < rightRefWidth,所以offset为deltaX。

那什么时候Math.abs(deltaX) >= rightRefWidth呢,要么right slot完全被划出,要么right slot 为空。假设right slot为空,这时再左滑是滑不动的。

右滑同理。

onTouchEnd

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
onTouchEnd() {
if (state.moving) {
state.moving = false;
oldPosition = position;
switch (position) {
case 'left':
if (Math.abs(state.offset) <= rightRefWidth.value / 2) {
close();
} else {
state.offset = -rightRefWidth.value;
open();
}
break;
case 'right':
if (Math.abs(state.offset) <= leftRefWidth.value / 2) {
close();
} else {
state.offset = leftRefWidth.value;
open();
}
break;
}
}
}

这个方法隐藏着一个小小的交互体验。假设onTouchEnd触发时,左滑的距离小于rightRefWidth的一半,会自动close,反之open。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const open = (p: SwipePosition = '') => {
opened = true;
if (p) {
state.offset = p === 'left' ? -rightRefWidth.value : leftRefWidth.value;
}
// emit('open', {
// name: props.name,
// position: position || p
// });
};
const close = () => {
state.offset = 0;
opened = false;
// emit('close', {
// name: props.name,
// position
// });
};

open与close所做的事是分别让offset达到最大值或者最小值。