作为一名前端,经常使用网易音乐听歌的人,看到网易左右滑动即可切换页面的效果真的有点好奇。所以就去git
找了点资料学习了一下。
声明:本文是我在
git
上面找到的 scroll-tab-bar 组件上加以自己的思考后写出的。
需求:实现左右滑动切换页面
最终效果图
功能分析:
- 点击顶部菜单可以跳转到对应的页面。
- 按压屏幕,左右滑动可以切换页面。
- 首次,只有首页的内容才会加载。
- 其他页面,只有进入之后,才会加载。
思考分析:
- 需要一个菜单组件,点击后修改
tabIndex
(显示哪个页面) - 需要一个
ScrollTab
组件,控制 手指按压事件,控制显示哪个tabIndex
- 需要一个
ScrollTabCol
组件,包裹真正的Page
组件,来控制是否加载。
结果:
ScrollTab
- 需要接受一个参数(
tabIndex
),来控制显示那个页面。 - 左右滑动切换页面后,需要有一个事件,来同步
tabIndex
- 实现手指按压事件的逻辑
- 需要接受一个参数(
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
组件实现
思考分析:
-
接收一个参数和控制事件,很明显是
props
和emit
。 -
实现左右滑动效果,很容易想到轮播图。
-
手指按压三个事件:
touchstart
、touchmove
、touchend
。
大概的逻辑流程是
- 根据
子组件
的数量和屏幕的宽度,先实现一个横向的“轮播图” - 实现手指按压的时间,根据手指按压下时的鼠标位置和手指抬起时的鼠标位置,计算出是:左移,右移,不移。
- 切换对应
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
生命周期里。
clientWidth
和 clientHeight
会在多个文件中用到,所有抽离出去了。
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
组件实现
思考分析:
- 接收一个 参数控制 页面是否加载
实现:
<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
,之后就不需要监听,并且需要立即执行。
watch
和 watchEffect
的一点差别就在watchEffect立即执行
想使用 watch
,逻辑就像下面
const stop = watch(props, () => {
if (props.loading) {
show.value = true
setTimeout(() => {
stop()
})
}
},{
immediate: true
})