分享一个移动端拖拽加载的实现:先上 PRD 和组件 DEMO
产品需求
- 实现像移动端的拖拽加载,能在移动端使用
- 拖拽的时候播放特定的一帧
成果和下图一样:
拽动下方容器会出现一个加载区。当加载区拽到一个阈值会执行加载。反之会自动收回去。整来来说就是这个流程图。
技术方案与拆解
我们先做拆解这个问题
- 【内容】需要一个加载区在容器上面,一般看不见。监听容器的拖拽时间来移动它
- 【内容】需要兼容容器拖拽程度,播放特定的动画帧
- 【流程上】需要完成下方的流程图,来实现产品体验
- 【边缘】兼容移动端环境,必要时阻止默认事件
- 【边缘】保证组件的复用性
因为很有趣我就全程手搓了,其实代码很简单。
监听事件
这问题很好解决,给容器监听 touchmove,touchend 事件。我记录了两个值 [起始点,终止点] 其中终止点是实时更新的。
技术上没什么好说的,放一个伪代码吧。可能细节比较多
// 起始点,终止点
const record = [0, 0]
// 使用的 vue3 的语法,但基本不需要 vue 知识点
// containerRef 逻辑等于 document.getElementById('container')
containerRef.value?.addEventListener('touchstart',(e) => {
const {clientY} = e.changedTouches[0]
record[0] = clientY
isSwiping = true
})
containerRef.value?.addEventListener('touchmove',(e) => {
// 阻止原生事件
e.preventDefault()
const {clientY} = e.changedTouches[0]
const last = record[1]
record[1] = clientY
offset.value = Math.max(record[1] - record[0], 0)
if(contentRef.value.scrollTop <= 0 && record[1] > record[0]){
// 拖拽中
isPulling.value = true
} else {
// 正常浏览下方代码
const curTop = contentRef.value.scrollTop + (last - record[1])
contentRef.value.scrollTop = curTop
}
})
containerRef.value?.addEventListener('touchend', (e) => {
isSwiping = false
if(isPulling){
const curOffset = record[1] - record[0]
if(curOffset > 100){
isLoading = true
// 执行 loading 的方法,这里用 setTimeout 模拟一下,当然实际也会这么用。好处
// 是有加载阶段的反馈
setTimeout(() => {
if(props.refresh.constructor.name === 'AsyncFunction'){
props.refresh().finally(() => {
isLoading = false
})
} else {
props.refresh()
isLoading = false
}
}, 1000)
}
record = [0,0]
offset = record[1] - record[0]
}
isPulling = false
}, false)
通过 Record 的值我们就能移动容器来获得拖动效果,有很多实现方式。我的方法是容器上面粘一个加载区。默认 transformY 一个加载区的高这样就会遮住加载区。
这样第一个问题就解决了,事实如果你能说服你的 PM 加载区放一个 Spin 就已经是很好的组件了。但我们让它有趣一些。
滚动播放动画帧
同步滚动事件
已经有 Record 来获取当前拖拽的情况了,我们需要让动画跟着 Record 播放。动画是由一组序列帧组成:就是一组图片。首先让设计把图片都给你。我们通过程序拼接起来。当然你也可以让设计加点班或是用 Photoshop 等工具,图省事我直接用 python 写了一个简单的拼接脚本。大家直接改改使用吧。
import sys
from PIL import Image
images = [Image.open(x) for x in ['Test1.jpg', 'Test2.jpg', 'Test3.jpg']]
widths, heights = zip(*(i.size for i in images))
total_width = sum(widths)
max_height = max(heights)
new_im = Image.new('RGB', (total_width, max_height))
x_offset = 0
for im in images:
new_im.paste(im, (x_offset,0))
x_offset += im.size[0]
new_im.save('test.jpg')
你会获得一个很长的图片。这时我们就要实现一个老的电影放映机了。使用 background-position-y 属性我们可以轻松移动窗口位置。
<div
:style="{background: `url(${loading})`, 'background-position-y': `${loadingOffset}px`,'background-size': 'cover'}"
class="loading">
</div>
...
const loadingOffset = computed(() => {
return Math.floor(拖拽比例 * 动画总帧数) * 每一帧高度
})
通过 Math.floor,我们可以将线性滑动转化成离散跳动。
自动播放
css 动画的 step 属性能帮我们快速完成自动播放的需求。
.loadingAnimate {
animation: loop steps(19, end) .8s infinite forwards;
}
/* n 张图片 */
@keyframes loop {
from {background-position-y: 0}
to {background-position-y: calc(n * var(--loading-height))}
}
手感
这时你滑动一下。你会觉得发现「手感很差」。手感可以是个「玄学」,因为见仁见智。从我的角度来看,手感差来源于
- 滑动太快
- 拖拽体验缺少层次
- 缺少反馈
我的体验设计是像按键盘一样:先开始键没按下去,反馈很慢。中间顺畅,反馈很快,快到底部了,反馈又变慢。对于平面视觉来说,反馈就是变化。变化越大反馈越大。那么我的体验就是,开始拖动很大,时间滑动很少,中期很快,后期又很少。根据这个描述,我们可以根据我们滑动值附加一个贝塞尔变量。
现在,拖拽有了前后慢中间快的层次感,但是滑动太快的问题还是没有解决。这时候我们增加一个摩擦(阻尼)系数来增加斜率。
const bc = new Bezier(0,0,.17,.67,.83,.67,1,1)
const dragRatio = computed(() => {
const offsetOpt = offset.value / friction
// 我们只取 [0,1] 的部分
const ratio = offsetOpt <= props.maxDraggble? offsetOpt /props.maxDraggble : 1
const realDrag = offset.value * bc.get(ratio).y
return Math.min(realDrag , props.maxDraggble)
})
边缘问题
- 移动端有些默认拖拽事件,比如 Safari 会默认拖动整个 webview。通过增加 preventDefault 能解决。
- 复用性上,注意一些系数的配置。一般出错都是 hardcore 一些系数导致。如果你的动画不符合预期,请检查一下是否是那里 hardcore 的参数