H5 移动端的拖拽加载(滚动动画)

399 阅读4分钟

分享一个移动端拖拽加载的实现:先上 PRD 和组件 DEMO

产品需求

  1. 实现像移动端的拖拽加载,能在移动端使用
  2. 拖拽的时候播放特定的一帧

成果和下图一样:

2024-11-10 21.19.55.gif

拽动下方容器会出现一个加载区。当加载区拽到一个阈值会执行加载。反之会自动收回去。整来来说就是这个流程图。

image.png

技术方案与拆解

我们先做拆解这个问题

  1. 【内容】需要一个加载区在容器上面,一般看不见。监听容器的拖拽时间来移动它
  2. 【内容】需要兼容容器拖拽程度,播放特定的动画帧
  3. 【流程上】需要完成下方的流程图,来实现产品体验
  4. 【边缘】兼容移动端环境,必要时阻止默认事件
  5. 【边缘】保证组件的复用性

因为很有趣我就全程手搓了,其实代码很简单。

监听事件

这问题很好解决,给容器监听 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 一个加载区的高这样就会遮住加载区。

image.png

这样第一个问题就解决了,事实如果你能说服你的 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,我们可以将线性滑动转化成离散跳动。

image.png

自动播放

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))}
    }

手感

这时你滑动一下。你会觉得发现「手感很差」。手感可以是个「玄学」,因为见仁见智。从我的角度来看,手感差来源于

  1. 滑动太快
  2. 拖拽体验缺少层次
  3. 缺少反馈

我的体验设计是像按键盘一样:先开始键没按下去,反馈很慢。中间顺畅,反馈很快,快到底部了,反馈又变慢。对于平面视觉来说,反馈就是变化。变化越大反馈越大。那么我的体验就是,开始拖动很大,时间滑动很少,中期很快,后期又很少。根据这个描述,我们可以根据我们滑动值附加一个贝塞尔变量。

image.png

现在,拖拽有了前后慢中间快的层次感,但是滑动太快的问题还是没有解决。这时候我们增加一个摩擦(阻尼)系数来增加斜率。

TransformY=Bezier[0,0,.17,.67,.83,.67,1,1](frictionDrag)\bigtriangleup TransformY = Bezier_{[0,0,.17,.67,.83,.67,1,1]}(friction \cdot \bigtriangleup Drag)

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)
})

边缘问题

  1. 移动端有些默认拖拽事件,比如 Safari 会默认拖动整个 webview。通过增加 preventDefault 能解决。
  2. 复用性上,注意一些系数的配置。一般出错都是 hardcore 一些系数导致。如果你的动画不符合预期,请检查一下是否是那里 hardcore 的参数