19

vuejs实现spa页面组件滑动特效

写在前面的一些废话

其实通过vuejs非常容易实现spa中路由的过渡效果,网上也有不少教程。但是考虑一下以下需求

  1. 即将离开的组件和将要进入的组合同时出现在页面中
  2. 用手指拖动页面可以切换路由,而不仅仅是点击链接
  3. 结合以上两点,拖动过程中同时显示两个组件,手指离开屏幕后执行切换路由或者返回

好像也不是很简单
最近几个天在仿DiDi应用写一个web app,写到“顺风车”组件的时候发现在其组件下的两个子组件:乘客组件以及车主组件,他们的切换方式正是满足上面3点要求。好几番尝试后终于写出了满足要求的代码,来和小伙伴分享~

我是正文

首先简单的过渡效果我就不多做介绍了。通过vue提供的transition组件,我们可以很容易的实现一个普通的动画效果。具体的可以参考vuejs官方文档

Step 0

我们先大致了解一下html结构

<div class="content-wrapper">
  <!-- router-view指向组件driver和passenger -->
  <router-view class="content"></router-view>
</div>

Step 1

来实现第一个需求。
这个需求不难,分别给2个组件定义不同transition动画即可。
对于左边的passenger组件,他是从左边进入/离开的,相应的代码为transform: translateX(-100%),同理对于右边的driver组件,相应代码为transform: translateX(100%)
特别注意的是,需要对router-view添加一行css代码(写在.content中)position: absolute来使driver组件元素和passenger组件元素脱离文档流,否则达不到2个组件同时出现在页面中的效果。
效果如下:
效果图1
上一个touch事件完成后,已滑动距离。实际在这个设计里,因为我们手指离开后,页面不会停留在中间,不是滑过去切换路由,就是滑回去恢复原样。所以currentDistance并没有什么卵用,但是如果要即停即走,这个变量不可少。

Step 2

对于移动端,肯定少不了手指滑动效果。在左右滑动页面的时候,理应也能切换到对应的路由。滴滴app就是这样。如何滑动?实现思路就是以3个touch事件:touchstart,touchmove,touchend是核心,配合transform: translate来实现。额外要求:松手后判断滑动距离,达到一定距即进行路由的切换,否则页面“滑”回去。
如何让元素跟着你的指尖走?我们以driver组件为例,先了解一下组件中各项数据

data() {
  return {
    touch: {}, // 保存着起始位置x1和变化的位置x2
    touchStartTime: 0, // touch开始
    touchEndTime: 0, // touch结束时间
    currentDistance: 0 // 上一个touch事件完成后,已滑动距离。实际在这个设计里,因为我们手指离开后, 页面不会停留在中间,不是滑过去切换路由,就是滑回去恢复原样。所以这个变量并没有什么卵用,但是如果要*即停即走*,这个变量不可少。
    totalDiff: 0 // 总滑动距离
  }
},

首先,我们在监听元素的touchstart事件,在用户touch页面的时候记录下位置信息,因为我们是左右滑动,所以只关心x轴方向。回调函数如下

function touchStart(ev) {
  let touch = ev.changedTouches[0]
  this.touch.x1 = touch.pageX // 本文中所有this指向vue组件实例
                                                          // 这里是driver组件或是passenger
}

然后,也是重点,监听touchmove事件。

function touchMove(ev) {
  let touch = ev.changedTouches[0]
  this.touch.x2 = touch.pageX
  let diff = this.touch.x2 - this.touch.x1 // 差值,表示手指移动的距离
  this.totalDiff = diff + this.currentDistance // 总差值,表示手指移动的距离,正表示右滑,负左滑
  if (this.totalDiff < 0) { // driver组件是右滑,所以totalDiff不能小于0
    this.totalDiff = 0
  } else if (this.totalDiff > this.maxMoveDistance) { // 这里maxMoveDistance为屏幕宽度
    this.totalDiff = this.maxMoveDistance
  }
  let el = ev.currentTarget
  translate(el, this.totalDiff, 0) // 对组件进行滑动
  translate(this.leftEl, this.totalDiff, 0) // leftEl后面再做解释
}

关于translate函数,具体实现如下:

/**
 * 简单的移动函数
 * @param {HTML Object} el 目标节点
 * @param {number} x 水平方向的移动
 * @param {number} y 垂直方向的移动
 * @param {Object} options 可选参数
 * @param {Boolean} options.useTransfrom 是否通过transfrom来移动元素
 * @param {Boolean} options.transitionTimingFunction transition的timingFunction
 * @param {String} options.transitionDuration transition时间
 */
function translate(el, x, y, options) {
  const defaultOptions = {
    useTransfrom: true,
    transitionTimingFunction: 'cubic-bezier(0.165, 0.84, 0.44, 1)',
    transitionDuration: '0s'
  }
  for (let option in options) {
    defaultOptions[option] = options[option]
  }
  if (defaultOptions.useTransfrom) {
    el.style.transform = `translate3d(${x}px,${y}px,0)`
    el.style.transitionProperty = 'transform'
    el.style.transitionTimingFunction = defaultOptions.transitionTimingFunction
    el.style.transitionDuration = defaultOptions.transitionDuration
  } else {
    el.style.left = x
    el.style.top = y
  }
}

接下来就是touchend事件

function touchEnd(ev) {
  let touch = ev.changedTouches[0]
  this.touch.x2 = touch.pageX
  let diff = this.touch.x2 - this.touch.x1
  this.touchEndTime = Date.now()
  this.totalDiff = diff + this.currentDistance
  this.currentDistance = this.totalDiff
  let el = ev.currentTarget
  let touchTime = this.touchEndTime - this.touchStartTime
  // 当滑动距离超过一半或者快速滑动一段距离时,就进行完整的滑动,否则回弹
  // 快速滑动的数据是自己尝试的,体验可能不是很好^ ^
  if (this.totalDiff > this.maxMoveDistance / 2 || (touchTime < 150 && this.totalDiff > this.maxMoveDistance / 10)) {
    translate(el, this.maxMoveDistance, 0, {
      transitionTimingFunction: 'linear',
      transitionDuration: '.1s'
    })
    translate(this.leftEl, this.maxMoveDistance, 0, {
      transitionTimingFunction: 'linear',
      transitionDuration: '.1s'
    })
    this.$emit('dragedSlide') // 通知父组件进行路由切换
  } else {
    this.totalDiff = this.currentDistance = 0
    translate(el, this.totalDiff, 0)
    translate(this.leftEl, this.totalDiff, 0)
  }
}

效果图:
效果图2

Step 3

在拖动时,左右都是“白边”,难看。怎么处理?也不难。
我们写过或者了解过轮播图,其中一种写法就是在第一张图(元素)的左边插入最后一张图(元素),最后一张图的右边插入第一张。在拖动当前元素时,同时拖动其左/右的元素(也就是Step 2中代码里的leftEl,当然还有righEl),来达到我们要的效果。所以现在我们的html结构是这样的:

<div class="content-wrapper">
    <passenger class="out_of_screen out_of_screen-left"/>
    <!-- router-view指向组件driver和passenger -->
    <router-view class="content"/>
    <driver class="out_of_screen out_of_screen-right"/>
</div>

到这里好像就大功告成了?看一下效果:
效果图3
等等,是你撸多了吗?怎么有两个动画?
其实是因为路由切换后会自动触发组件本身的transition,再加上自己写的translate(this.leftEl, ...),就有2个啦。
知道原因就很好处理了,去掉其中一个动画即可。我的选择是去掉组件本身的过渡效果。
具体做法就是给组件动态绑定transitionname属性,来选择性的使组件开启/关闭过渡效果。同时对左右插入的元素监听其transitionend事件,配合上父组件的dragedSlide事件,来实现动态过渡,上代码

<div class="content-wrapper">
  <passenger class="out_of_screen out_of_screen-left" @transitionend.native="updateRouter($event, 'passenger')/>
  <!-- router-view指向组件driver和passenger -->
  <router-view class="content" 
    @dragedSlide="confirmDragSlide"
    :transitionName="transitionName"
  />
  <driver class="out_of_screen out_of_screen-right" @transitionend.native="updateRouter($event, 'passenger')/>
</div>

父组件js部分

export default {
  beforeRouteUpdate(to, from, next) {
    this.transitionName = this.isDragedSlide ? '' : 'slide'
    next()
  },
 data() {
    return {
      transitionName: 'slide',
      isDragedSlide: false
    }
  },
  methods: {
        // ...
    // 是否通过手指拖动触发滑屏
    confirmDragSlide() {
      this.isDragedSlide = true
    },
    updateRouter(ev, routeName) {
      if (this.isDragedSlide) {
        let el = ev.target
        this.$router.push(routeName)
        el.style.transform = ''
        el.style.transitionDuration = '0s'
        this.isDragedSlide = false
      }
    }
  }
}

最终效果
效果图4


这里有内容更新


End


HaHa
525 声望11 粉丝