vue3打造接近原生体验的抽屉指令

813 阅读8分钟

源码

vue3打造近乎原生体验的手势交互体验探索指南

扯淡

  • 我:jym想死你们了,沉寂多天,我带着高质量文章回来了,
  • jym:你谁啊? 爱写不写!!

额,写写写,即使你们不看,那我也得写给自己看,将自己的心得体会、问题踩坑归纳总结,来塑造自己的职业经历

因为我一直在笃信人都是经历塑造的,你干了什么事,有什么职业经历,永远比你卷了什么题重要。 此时我相信,很多痛斥八股的jym准备哆哆嗦嗦站起来,大声喊道:终于该我演了!!

额,那个,坐下,别这么激动,我还没说完, 虽然,干了什么事情很重要, 但不是说卷题不重要啊,因为不卷题,你就干不了什么事,单位你都进不去,你能干个锤子,

有jym说,进不去不进了,我干个体户,同志们,个体户现在也不好混啊,那些个技术个体户,也天天接不到广告,挣不到钱,急眼了,天天靠着chatgpt 使劲的挥舞着镰刀呢!!!

所以大家还是要辩证的看问题,只是对于整个职业生涯来说,经历很重要,

因为你会老啊,你终究干不过年轻人啊,你加班到底还是加不过他啊,属于你的时代最终会结束啊,但是洗尽铅华之后,能够留下来的,或者说能够在这个行当,扎下根来不被替代的,一定是你的宝贵经验,你干了什么事,踩过什么坑,写过什么项目,总结出来什么方案

这都是你不世出的经历,这才是年轻人代替不了的

把这些东西,嘡!嘡!嘡!往桌子上一摆,老板能不给你晋升专家?

  • 老板:干得了就干,干不了就滚!
  • 我:干(行情不好,劳资忍了)!

想说的话说完了,我们言归正传

为什么webapp体验很差

在我们现在的大多数app中,大家都会发现,基本清一色的使用原生开发,只有在不重要的页面中,才会使用webapp,也就是所谓的h5页面

之所以是h5无法替代原生除了审核因素之外,原因很简单,它不能编译成native,只能通过容器这个介质,也就是webview,去运行h5页面,但是这样的话性能就会大大折扣

你想啊,我去打开一个页面,还需要先初始化容器,然后要下载页面,然后还要受webview的限制,他能好的了吗?

其实,理论上来说web页面是可以通过编译器编译编译成native的,然而现实是,谷歌的这帮大佬玩了命的优化,折腾,于是诞生了flutter这种从头再来的产物,这就说明,理论他就是一坨....

当然,值得庆幸的是,web技术的快速发展中,我们可以无限接近,根据我骥某人的钻研,在交互比较复杂h5页面中,我们可以利用以下三点

  • 1、利用css3
  • 2、利用requestAnimationFrame
  • 3、利用离线包,解决下载资源问题

前面两个不过多解释了DDDD(懂得都懂)

我们来解释一下离线包的问题,所谓离线包,就是在app中利用资源请求拦截匹配本地资源,从而大大提升页面加载速度

所谓光说不练假把式,我们手把手打造一个

手把手打造抽屉指令组件

Kapture 2023-06-09 at 16.12.41.gif

滑动抽屉是常用的交互体验,也在app中随处可见,那么我们h5该如何实现呢?

且听我慢慢将来

基本布局

image.png

如上图所示,我们首先要实现一个基本布局,来做一个抽屉收起的状态

代码如下:

<template>
  <div class="box">
    <div class="list" v-swipe-action="action">
      <div class="darg">拖动区域</div>
      <div class="content1">
        <div class="content">好好学习</div>
      </div>
    </div>
  </div>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
const action = reactive({
  hotRegion: '.darg',
  slide: '.content1',
  safeDistance: 0,
  initHeight: '200',
})
setTimeout(() => {
  action.safeDistance = 200
  // action.value = {
  //   hotRegion: '.darg',
  //   slide: '.content1',
  //   safeDistance: 200,
  //   initHeight: '200',
  // }
}, 2000)
</script>
<style scoped lang="scss">
.box {
  height: 100vh;
  position: relative;
  overflow: hidden;
  .list {
    position: absolute;
    width: 100%;
    left: 0;
    bottom: 0;
    background-color: rgba(255, 255, 255, 1);
    border-top-left-radius: 16px;
    border-top-right-radius: 16px;
    z-index: 10;
    padding: 0 19px;
    padding-top: 30px;
    // 解决ios 中由于vw vh 布局方式导致的边线裁剪问题
    margin: 0 1px;
    box-sizing: border-box;
    //transition: bottom 0.2s cubic-bezier(0, 0, 0.48, 1);

    #dataCollectionId {
      overflow: hidden;
      height: 100%;
      overscroll-behavior: none;
      -webkit-overflow-scrolling: touch;

      &::-webkit-scrollbar {
        display: none;
        width: 0;
        height: 0;
      }
    }
    .darg {
      font-size: 20px;
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      text-align: center;
      color: #000;
    }
    .content1 {
      overflow: auto;
      height: 100%;
      box-sizing: border-box;
    }
    .content {
      color: #000;
      font-size: 150px;
      height: 2000px;
    }
  }
}
</style>

当基本布局完成后我们就可以开始处理逻辑了

手势

既然是抽屉,那么必须要有滑动拖动,等手势操作,于是在经过一番筛选之后,我选择了腾讯的一个手势开源插件

alloyfinger

之所以选择它,没有什么特殊的理由,原因很简单,他是中国人写的啊,亲切,我就乐意用!!

所以说,这个世上很多事,本不需要什么冠冕堂皇的理由,之所以需要理由,是因为很多人喜欢找抽,抽久了,就需要理由了。

使用方式也非常简单,代码如下:

var af = new AlloyFinger(element, {
    touchStart: function () { },
    touchMove: function () { },
    touchEnd:  function () { },
    touchCancel: function () { },
    multipointStart: function () { },
    multipointEnd: function () { },
    tap: function () { },
    doubleTap: function () { },
    longTap: function () { },
    singleTap: function () { },
    rotate: function (evt) {
        console.log(evt.angle);
    },
    pinch: function (evt) {
        console.log(evt.zoom);
    },
    pressMove: function (evt) {
        console.log(evt.deltaX);
        console.log(evt.deltaY);
    },
    swipe: function (evt) {
        console.log("swipe" + evt.direction);
    }
});

而在我们的代码中使用的手势远没有这么多我们只需要滑动拖动足以,代码如下:


       const pressMove = (event) => {
          if (isScroll(event.target)) return
          bottom += -event.deltaY
          bottom >= 0 && (bottom = 0)
          // 设置动画
          setTransition(el, false)
          setBottom(el, bottom)
        } 
         const touchEnd = (event) => {
          if (isScroll(event.target)) return
          if (event.direction == 'Up') {
            bottom = 0
          } else if (event.direction == 'Down') {
            bottom = minBottom
          } else if (typeof event.direction == 'undefined') {
            bottom = lastHeight
          }
          // 设置动画
          setTransition(el, true)
          setBottom(el, bottom)
          lastHeight = bottom
        }
   af = new AlloyFinger(el, {
          pressMove,//拖动手势
          touchEnd,
          swipe: touchEnd,// 滑动手势
        })

好了,我们一个抽屉的基本功能就完成了,

但是让你值钱的,不是这个玩意,这玩意你能干,别人也能干

去优化体验问题的经验才是我们应该学习的,这才是各位jym安身立命的资本!

为了优化体验问题,我们还需要解决几个问题,才能形成一个接近原生体验的组件

需要解决的问题

  • 1、抽屉内的滚动条滑动和拖动冲突问题如何解决?
  • 2、抽屉拖动的性能问题如何解决
  • 3、手势滑动抽屉的动效问题该如何解决

jym不要着急,我们接下来一个个来,从丘处机路过牛家村开始

抽屉内的滚动条滑动和拖动冲突问题如何解决?

当我们使用了简单的抽屉体验之后,大家就会发现,抽屉中一旦有滚动条就歇菜了,滚动条会和拖动事件冲突,

那么怎么办呢?

其实,细想一下,我们就可以发现,我们可以判定滚动条是否已经到顶部,当滚动条不在顶部的时候,我们就关闭拖动事件,当他在顶部的时候,我们就开启

这样一来,就可以将滚动和拖动事件,变成相当于单线程的事件,判断代码如下:


    const isScroll = (target) => {
          setOverflow(slideTarget, bottom >= 0)
          const scrollHeight = slideTarget.scrollHeight
          const clientHeight = slideTarget.clientHeight
          const scrollTop = slideTarget.scrollTop
          if (
            slideTarget &&
            slideTarget.contains(target) &&
            scrollHeight != clientHeight &&
            bottom >= 0 &&
            scrollTop != 0
          ) {
            return true
          }
          return false
        }

抽屉拖动的性能问题如何解决

我们知道,在web页面中,由于频繁的操作dom会引起页面的卡顿,大家通识的优化方案有两个

  • 1、节流函数,节制事件的触发评率
  • 2、利用requestAnimationFrame函数优化性能

别急我们一个个分析

理论上来说,节流函数,其实是最优的选择,将事件的触发频率降低,那么操作dom频率就会降低,从而解决性能问题,在辅以requestAnimationFrame函数

可谓巧夺天工

然而,在实际的使用过程中,拖动抽屉的时候,在粗鲁之辈的暴力测试中,由于节流函数的限制,他却不跟手,性能是好了,体验却极差

这是两瓶毒药啊?

怎么办?

遵循两权相害取其轻原则,更遵循有一个能跑原则

我们只能取消节流函数!只使用利用requestAnimationFrame函数

代码如下:

export const requestAnimationFrame = (callback) => {
  if (!window.requestAnimationFrame) {
    window.requestAnimationFrame = function (callback) {
      return window.setTimeout(callback, 1000 / 60)
    }
  }
  window.requestAnimationFrame(callback)
}

这说明什么?,大家以后千万不要有技术人的执念,总是,事事技术为先性能为尊,殊不知,你好我好大家好,才是最优解!

性能不好又怎么样呢? 测试同学开心了,不好吗?

手势滑动抽屉的动效问题该如何解决

这个问题就比较好解决了,之所以需要解决这个问题,原因很简单,我们拖动的时候,是不能有动画的,因为它是js 的实时计算,为了让他能跟手

但是,但是当我们划动的时候,就需要有一个效果缓缓弹出和收起了。

而我们的处理方式也非常简单,只需要在合适时机,添加动画即可

代码如下:

export const setTransition = (el, open) =>
  (el.style.transition = open ? 'bottom 0.2s cubic-bezier(0, 0, 0.48, 1)' : '')

好了,整个组件的难点全部讲解完毕了,希望各位jym有所收获

我将上述组件抽成了一个指令,附上完整代码,方便理解

完整指令代码

import AlloyFinger from 'alloyfinger'
import {
  setOverflow,
  setBottom,
  addEventListener,
  setTransition,
} from './utils'
export default {
  install(app) {
    let af = null
    let bottom = 0
    let minBottom = 0
    let height = 0
    let lastHeight = 0
    const setHeight = (el, value) => {
      height = document.body.clientHeight - (value?.safeDistance || 0)
      minBottom = -(height - (value?.initHeight || height / 2))
      bottom = minBottom
      lastHeight = bottom
      el.style.height = `${height}px`
      setBottom(el, bottom)
    }
    app.directive('swipe-action', {
      mounted(el, binding) {
        // 带滚动条的容器
        const slideTarget =
          (binding.value?.slide &&
            document.querySelector(binding.value.slide)) ||
          el
        setOverflow(slideTarget, false)
        // 设置默认高度
        setHeight(el, binding.value)
        // 设置动画
        setTransition(el, true)
        if (binding.value?.hotRegion) {
          const target = document.querySelector(binding.value.hotRegion)
          addEventListener(
            'click',
            () => {
              bottom >= 0 ? (bottom = minBottom) : (bottom = 0)
              setOverflow(slideTarget, bottom >= 0)
              setBottom(el, bottom)
              lastHeight = bottom
            },
            {
              target,
            },
          )
        }
        const isScroll = (target) => {
          setOverflow(slideTarget, bottom >= 0)
          const scrollHeight = slideTarget.scrollHeight
          const clientHeight = slideTarget.clientHeight
          const scrollTop = slideTarget.scrollTop
          if (
            slideTarget &&
            slideTarget.contains(target) &&
            scrollHeight != clientHeight &&
            bottom >= 0 &&
            scrollTop != 0
          ) {
            return true
          }
          return false
        }
        const pressMove = (event) => {
          if (isScroll(event.target)) return
          bottom += -event.deltaY
          bottom >= 0 && (bottom = 0)
          // 设置动画
          setTransition(el, false)
          setBottom(el, bottom)
        }
        const touchEnd = (event) => {
          if (isScroll(event.target)) return
          if (event.direction == 'Up') {
            bottom = 0
          } else if (event.direction == 'Down') {
            bottom = minBottom
          } else if (typeof event.direction == 'undefined') {
            bottom = lastHeight
          }
          // 设置动画
          setTransition(el, true)
          setBottom(el, bottom)
          lastHeight = bottom
        }
        af = new AlloyFinger(el, {
          pressMove,
          touchEnd,
          swipe: touchEnd,
        })
      },
      updated(el, binding) {
        if (binding.value.safeDistance) {
          setHeight(el, binding.value)
        }
      },
      unmounted() {
        af && af.destroy()
      },
    })
  },
}