移动端左右滑动切换页面效果

10,861 阅读4分钟

作为一名前端,经常使用网易音乐听歌的人,看到网易左右滑动即可切换页面的效果真的有点好奇。所以就去git找了点资料学习了一下。

声明:本文是我在 git上面找到的 scroll-tab-bar 组件上加以自己的思考后写出的。

需求:实现左右滑动切换页面

最终效果图

QQ20210906-214901-HD.gif

功能分析:

  1. 点击顶部菜单可以跳转到对应的页面。
  2. 按压屏幕,左右滑动可以切换页面。
  3. 首次,只有首页的内容才会加载。
  4. 其他页面,只有进入之后,才会加载。

思考分析:

  1. 需要一个菜单组件,点击后修改 tabIndex(显示哪个页面)
  2. 需要一个 ScrollTab组件,控制 手指按压事件,控制显示哪个tabIndex
  3. 需要一个 ScrollTabCol组件,包裹真正的 Page组件,来控制是否加载。

结果:

  1. ScrollTab
    1. 需要接受一个参数(tabIndex),来控制显示那个页面。
    2. 左右滑动切换页面后,需要有一个事件,来同步 tabIndex
    3. 实现手指按压事件的逻辑
  2. ScrollTabCol 包裹着每个页面,接受一个参数 load, 来控制当前页面是否加载。

实现过程:

首页不是重点,直接贴代码。

<script setup>

import { ref } from 'vue'
import ScrollTab from './components/ScrollTab.vue'
import ScrollTabCol from './components/ScrollTabCol.vue'
import Page from './components/Page.vue'
let tabIndex = ref(0) // todo 控制显示哪个页面
let loadIndex = ref(0) // todo 控制加载哪个页面
// todo 通知页面切换
const selectChange = (value) => {
  tabIndex.value = value
  loadIndex.value = value
}
</script>

<template>
  <!-- // todo 顶部菜单 -->
  <ul class="label-list">
    <li
      class="label-list-item"
      :class="{ 'select-label-list-item': tabIndex === 0 }"
      @click="tabIndex = 0"
    >1</li>
    <li
      class="label-list-item"
      :class="{ 'select-label-list-item': tabIndex === 1 }"
      @click="tabIndex = 1"
    >2</li>
    <li
      class="label-list-item"
      :class="{ 'select-label-list-item': tabIndex === 2 }"
      @click="tabIndex = 2"
    >3</li>
    <li
      class="label-list-item"
      :class="{ 'select-label-list-item': tabIndex === 3 }"
      @click="tabIndex = 3"
    >4</li>
    <li
      class="label-list-item"
      :class="{ 'select-label-list-item': tabIndex === 4 }"
      @click="tabIndex = 4"
    >5</li>
  </ul>
  <!-- // todo 页面 -->
  <ScrollTab :tabIndex="tabIndex" @selectChange="selectChange">
    <ScrollTabCol class="item" :loading="loadIndex === 0">
      <Page msg="1"></Page>
    </ScrollTabCol>
    <ScrollTabCol class="item" :loading="loadIndex === 1">
      <Page msg="2"></Page>
    </ScrollTabCol>
    <ScrollTabCol class="item" :loading="loadIndex === 2">
      <Page msg="3"></Page>
    </ScrollTabCol>
    <ScrollTabCol class="item" :loading="loadIndex === 3">
      <Page msg="4"></Page>
    </ScrollTabCol>
    <ScrollTabCol class="item" :loading="loadIndex === 4">
      <Page msg="5"></Page>
    </ScrollTabCol>
  </ScrollTab>
</template>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}
html,
body,
div,
body,
ul,
li {
  margin: 0;
  padding: 0;
  list-style: none;
  text-decoration: none;
}
.label-list {
  position: fixed;
  width: 100%;
  z-index: 100;
  display: flex;
  height: 50px;
  line-height: 50px;
}

.label-list-item {
  flex-grow: 1;
  background: #909399;
  color: #fff;
  text-align: center;
}

.select-label-list-item {
  font-weight: 600;
  background: #67c23a;
}

.item {
  text-align: center;
  background: #409eff;
  line-height: 400px;
  color: #fff;
  font-size: 100px;
  font-weight: 600;
}
</style>

ScrollTab 组件实现

思考分析:
  1. 接收一个参数和控制事件,很明显是 propsemit

  2. 实现左右滑动效果,很容易想到轮播图。

  3. 手指按压三个事件:touchstarttouchmovetouchend

大概的逻辑流程是

  1. 根据 子组件的数量和屏幕的宽度,先实现一个横向的“轮播图”
  2. 实现手指按压的时间,根据手指按压下时的鼠标位置和手指抬起时的鼠标位置,计算出是:左移,右移,不移。
  3. 切换对应 tabIndex的事件
Template 和 CSS

这个不是重点,直接粘贴吧

<template>
  <div class="scroll-tab">
    <div
      class="scroll-tab-item"
      :style="{ width: scrollWidth + 'px', transform: `translate(${contentBoxLeft}px, 0)`, transitionDuration: delay + 's' }"
      ref="scrollTabItem"
      @touchstart="handleTouchStart($event)"
      @touchmove="handleTouchMove($event)"
      @touchend="handleTouchEnd($event)"
    >
      <slot></slot>
    </div>
  </div>
</template>
<style>
* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}
.scroll-tab {
  width: 100%;
  overflow: hidden;
}
.scroll-tab-item {
  display: flex;
  flex-wrap: nowrap;
  align-items: flex-start;
}
</style>
逻辑

第一步:点击菜单,切换时,需要滑动到对应的页面。

const props = defineProps({
  tabIndex: Number,
})
const emit = defineEmits(['selectChange'])
watch(props, () => {
  contentBoxLeft.value = calcluateBoxLeft(props.tabIndex)
  tabIndex.value = props.tabIndex
})

第二步:根据子组件的数量和屏幕的宽度,计算出轮播图所需要的宽度 scrollWidth

很明显,这个是立即执行,需要放在 onMounted生命周期里。

clientWidthclientHeight 会在多个文件中用到,所有抽离出去了。

export const clientWidth = document.documentElement.clientWidth ? document.documentElement.clientWidth : document.body.clientWidth
export const clientHeight = document.documentElement.clientHeight ? document.documentElement.clientHeight : document.body.clientHeight
onMounted(() => {
  calculateScrollWidth()
})
// ? 计算 scrollWidth
const calculateScrollWidth = () => {
  children = scrollTabItem.value.children
  length.value = children.length
  scrollWidth.value = children.length * clientWidth 
}

第三步:实现三个手指按压的事件

逻辑流程:

touchstart:

​ 记录状态: 1. 手指是否按下,2. 手指按下时的位置

touchmove:

​ 通过手指按下时的位置与当前手指的位置,计算出滑动的方向是上下还是左右。

如果是左右的话,通过 速率 设置最新的轮播图位置。

touchend:

​ 根据手指最后的位置和开始的位置计算出移动的距离

​ 根据移动的距离tabIndex,判断是否需要切换页面。

/**
 * @param tabIndex 当前显示的 page
 * @param contentBoxLeft 当前轮播的位置
 * @param clientWidth 页面的宽度
 * @param length 页面的数量
 * @param scrollWidth 轮播图总的宽度
 */
const useMoveTouch = (tabIndex, contentBoxLeft, clientWidth, length, scrollWidth) => {
  let moveOptions = reactive({
    direction: '', // ? 方向
    isStart: false, // ? 是否按下
    startPos: null, // ? 记录开始的位置
    endPos: null // ? 记录结束的位置
  })
  const calcluateBoxLeft = (index) => {
    window.scrollTo(0, 0)
    return -(clientWidth * index)
  }

  const handleTouchStart = (e) => {
    moveOptions.isStart = true
    moveOptions.startPos = e.touches[0]
    moveOptions.endPos = e.touches[0]
  }
  const handleTouchMove = (e) => {
    // ? 根据当前 e的位置,判断出是向 上下 还是 左右滑动
    let left = e.touches[0].clientX - moveOptions.endPos.clientX
    let top = e.touches[0].clientY - moveOptions.endPos.clientY
    if (moveOptions.isStart) {
      moveOptions.direction = (Math.abs(left) > Math.abs(top)) ? 'X' : 'Y'
    }
    moveOptions.endPos = e.touches[0]
    if (moveOptions.direction === 'X') {
      e.preventDefault()
      // todo 左右移动
      // ? 1. 定义移动的 速率
      let coefficient = 1
      if (contentBoxLeft.value >= 20 || contentBoxLeft.value < -(scrollWidth.value - clientWidth + 20)) {
        coefficient = 0
      } else if (left >= 0 && tabIndex.value === 0) {
        coefficient = 0.3
      } else if (left <= 0 && tabIndex.value === length.value - 1) {
        coefficient = 0.3
      }
      contentBoxLeft.value += coefficient * left
    }
  }
  const handleTouchEnd = (e) => {
    if (moveOptions.direction !== 'X') return
    // ? 1. 获取移动的距离
    const moveLen = moveOptions.endPos.clientX - moveOptions.startPos.clientX
    if (moveLen > 80 && tabIndex.value !== 0) {
      tabIndex.value--
    } else if (moveLen < -80 && tabIndex.value !== length.value - 1) {
      tabIndex.value++
    }
    // ? 计算出偏移量
    contentBoxLeft.value = calcluateBoxLeft(tabIndex.value)
  }
  return {
    calcluateBoxLeft,
    handleTouchStart,
    handleTouchMove,
    handleTouchEnd
  }
}

第四步:如果需要切换的话,通知父级。

const usetabIndex = (emit) => {
  let tabIndex = ref(0)
  watch(tabIndex, () => {
    emit('selectChange', tabIndex.value)
  })
  return tabIndex
}

ScrollTabCol 组件实现

思考分析:

  1. 接收一个 参数控制 页面是否加载

实现:

<template>
  <div class="scroll-tab-col" :style="{width:clientWidth + 'px', height: clientHeight + 'px'}">
    <!-- // todo 空白页 -->
    <div v-if="!show">进入的时候是空白效果</div>
    <!-- // todo 具体的页面 -->
    <slot v-else></slot> 
  </div>
</template>

<script setup>
import {clientWidth, clientHeight} from './clientParameters'
import {defineProps, watchEffect, ref} from 'vue'
const props = defineProps({
  loading: Boolean
})
// todo 控制显示 空白页,还是挂载
const show = ref(false)
// todo 根据 props.loading 控制挂载
const stop = watchEffect(() => {
  if (props.loading) {
    show.value = true
    setTimeout(() => {
      stop()
    })
  }
})

</script>

<style scoped>
* {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}
</style>

总结思考

为什么需要控制加载?

加快首屏加载的速度,控制加载的话,首次渲染的时候,只会渲染首页的内容,而不会把所有页面的内容都加载和渲染。

为什么使用 watchEffect而不使用 watch?

watch也行,只不过

写的时候想的是,这个监听事件,只需要监听到 props.loading=true,之后就不需要监听,并且需要立即执行。

watchwatchEffect的一点差别就在watchEffect立即执行

想使用 watch,逻辑就像下面

const stop = watch(props, () => {
  if (props.loading) {
    show.value = true
    setTimeout(() => {
      stop()
    })
  }
},{
  immediate: true
})

参考与源码

scroll-tab-bar

源码地址