得益于移动设备性能越来越强劲,Web不再成为性能瓶颈。各种hybrid方案以及大量webview内嵌页面以及纯H5应用在用户体验上愈发显得重要。本文将介绍基于Vue2.X、BetterScroll开发的TabScroll在处理原生应用中常用的手势操作的案例。阅读本文将快速习得如何借助TabScroll开发拥有媲美原生应用手势操作的移动Web应用
TabScroll 简介
TabScroll是一个基于Vue2.X以及BetterScroll的手势库,宗旨是赋予WebApp复合型的手势操作 以及 相关的极富设计感的视差体验。与此同时,TabScroll希望做到语义清晰 开箱即用,让使用者仅需对BetterScroll有简单的了解和认识即可上手TabScroll。
git地址 https://github.com/a62527776a/tab-scroll
案例展示

demo地址 https://dscsdoj.top/public/unsplash/index.html#/demo1
开始开发!
准备工作
TabScroll依赖BetterScroll 如未安装过BetterScroll的需要同时安装BetterScroll
# shell
$ yarn add better-scroll tab-scroll
or
# 如果安装过better-scroll 仅需安装tab-scroll
$ yarn add tab-scroll
# main.js
import Vue from 'vue'
import tabScroll from 'tab-scroll'
Vue.use(tabScroll)
new Vue({
render: h => h(App)
}).$mount('#app')
起步
TabScroll的特点即是开箱即用,仅用短短几行代码即可完成大部分效果
下面的片段展示了一个具有基本的复合型手势的应用,但它还不能满足正式的需求
还需添加更多的代码才能展示完整的应用

<template>
<div>
<vue-horizontal-scroll offsetY="10vh">
<div slot="header" style="height: 20vh;background: green"></div>
<vue-vertical-scroll>
<div v-for="(i, idx) in 20" :key="idx" class="item-block item-block-red"></div>
</vue-vertical-scroll>
<vue-vertical-scroll>
<div v-for="(i, idx) in 20" :key="idx" class="item-block item-block-yellow"></div>
</vue-vertical-scroll>
<vue-vertical-scroll>
<div v-for="(i, idx) in 20" :key="idx" class="item-block item-block-green"></div>
</vue-vertical-scroll>
</vue-horizontal-scroll>
</div>
</template>
<script>
export default {
name: 'demo2'
}
</script>
<style>
.item-block {
border-bottom: 15px solid #EEE;
height: 30vh;
}
.item-block-green {
background: green
}
.item-block-red {
background: red
}
.item-block-yellow {
background: yellow
}
</style>
对顶部header栏没有特殊要求的,可以直接忽略header的slot
将顶部的节点直接放置 <vue-horizontal-scroll>
上方
TabScroll将自动计算自身高度,以适配界面的高度

不使用slot header实例预览(注意,请使用手机模式打开)
<template>
<div>
<div style="height: 20vh;background: green"></div>
<vue-horizontal-scroll>
<vue-vertical-scroll>
<div v-for="(i, idx) in 20" :key="idx" class="item-block item-block-red"></div>
</vue-vertical-scroll>
<vue-vertical-scroll>
<div v-for="(i, idx) in 20" :key="idx" class="item-block item-block-yellow"></div>
</vue-vertical-scroll>
<vue-vertical-scroll>
<div v-for="(i, idx) in 20" :key="idx" class="item-block item-block-green"></div>
</vue-vertical-scroll>
</vue-horizontal-scroll>
</div>
</template>
如果底部还有菜单栏,或者遇到自动计算无法满足实际需求的情况
TabScroll也提供了height属性用以计算高度
height属性可以为以各种css单位的String型('80vh', '10.5rem', '650px')等 具体为屏幕高度减去界面上放在TabScroll外的各种元素的高度(比如顶部菜单栏、底部Tab栏)
<template>
<div>
<div style="height: 3rem;background: green"></div>
<vue-horizontal-scroll height="calc(100vh - 3rem - 5rem)">
...
<tab-bar style="height: 5rem">
...
</div>
</template>
开发界面
我们将使用pug以及less快速的开发出一个带有搜索栏(当然,我们不会给这个搜索栏加上任何功能)和一个菜单栏的header-bar
我们还将添加一个根据获取豆瓣关键词获取剧信息的接口,这个接口将作为我们应用的真实数据

<template lang="pug">
.wrapper
.header-bar
.search-bar 写给你爱的人的情书
.tab-bar(v-if="menus")
.tab-item(v-for="(value, key, idx) in menus" :class="{'tab-item-active' : idx === currentPageIdx }") {{key}}
.content-wrapper(v-if="menus")
.movie-card(v-for="(item, idx) in menus['日剧'].data")
.movie-cover
// img(:src="item.images.medium")
// 豆瓣的图片做了防盗链,暂时用一张假的图片
img(src="https://i.loli.net/2019/03/12/5c87b97a0b2ce.jpg")
.movie-title {{item.title}}
</template>
<script>
import axios from 'axios'
export default {
name: 'douban-demo',
data () {
return {
menus: null,
// 给mock数据准备一些结构
mockMenusData: {
'日剧': {data: null},
'泰剧': {data: null},
'韩剧': {data: null},
'美剧': {data: null},
'英剧': {data: null}
},
// 作为滚动的下标
currentPageIdx: 0
}
},
methods: {
/**
* @method mockMenus mock菜单数据 为模拟真实环境中从后端获取菜单栏 取800ms延迟
* @return { Promise }
*/
mockMenus: function () {
return new Promise(resolve => {
setTimeout(() => {
resolve(this.mockMenusData)
}, 800)
})
},
// 获取菜单栏
initMenus: async function () {
this.menus = await this.mockMenus()
// 菜单栏获取之后开始调用数据
this.api('日剧', 1)
},
/**
* @method api 从后端获取接口
*/
api: async function (key, page) {
let baseUrl = process.env.NODE_ENV === 'development' ? 'http://localhost:7001' : 'https://dscsdoj.top'
let result = await axios.get(`${baseUrl}/api/douban?key=${key}&page=${page}&size=18`)
if (!this.menus[key].data) this.menus[key].data = []
this.menus[key].data = this.menus[key].data.concat(result.data.data.subjects)
}
},
created () {
this.initMenus()
}
}
</script>
<style lang="less">
.wrapper {
.header-bar {
padding: 8px 12px 0 12px;
.search-bar {
background: #EEE;
border-radius: 50px;
font-size: 12px;
color: #666;
padding: 6px 12px;
}
.tab-bar {
display: flex;
justify-content: space-between;
margin: 0 -12px;
border-bottom: 1px solid #EFEFEF;
.tab-item {
color: #AAA;
padding: 6px 0;
margin: 0 12px;
font-size: 14px;
margin-bottom: -1px;
}
.tab-item-active {
color: #666;
font-weight: bold;
border-bottom: 2px solid #666;
}
}
}
.content-wrapper {
padding: 12px;
display: flex;
width: calc(100% - 24px);
justify-content: space-between;
flex-wrap: wrap;
.movie-card {
width: 49%;
}
.movie-cover {
img {
border-radius: 5px;
}
}
.movie-title {
font-size: 14px;
color: #444;
margin-bottom: 12px;
height: 2.4em;
color: #212121;
}
}
}
</style>
整合TabScroll
<template>
.wrapper
vue-horizontal-scroll(ref="vue-horizontal-scroll") // add 将内容包裹vue-horizontal-scroll组件中
.header-bar(slot="header") // modify 将header作为slot为header的tab顶部栏
.search-bar 写给你爱的人的情书
.tab-bar(v-if="menus")
.tab-item(v-for="(value, key, idx) in menus" :class="{'tab-item-active' : idx === currentPageIdx }") {{key}}
template(v-if="!menus") // add 添加一个当菜单还未加载时的loading占位符
div loading // add
template(v-else) // add
vue-vertical-scroll(v-for="(value, key, idx) in menus" :key="idx") // add 将竖向滚动的内容包裹进vue-vertical-scroll中
.content-wrapper(v-if="value.data") // modify
.movie-card(v-for="(item, idx) in value.data") // modify
.movie-cover
// img(:src="item.images.medium")
// 豆瓣的图片做了防盗链,暂时用一张假的图片
img(src="https://i.loli.net/2019/03/12/5c87b97a0b2ce.jpg")
.movie-title {{item.title}}
</template>
<script>
...
...
...
// 获取菜单栏
initMenus: async function () {
this.menus = await this.mockMenus()
this.$nextTick(() => { // add 因为initBScroll有操作dom 所以包裹进$nextTick中
this.$refs['vue-horizontal-scroll'].initBScroll() // add 因为菜单栏是动态生成的 所以需要在菜单生成之后再次调用initBScroll 如果菜单是写死的 比如固定4个 则可以忽略该操作
}) // add
// 菜单栏获取之后开始调用数据
this.api('日剧', 1)
},
...
...
...
</script>

当套入了这些代码之后 即可快速生成一个具有复合操作的列表 需要注意的是
本文中的案例使用了动态的菜单栏,所以需要手动在菜单加载完成后调用$refs['vue-horizontal-scroll'].initBScroll()
如果用户并没有动态菜单栏的需求 就可以忽略该行,<vue-horizontal-scroll>
将根据其传入的<vue-vertical-scroll>
数量来自动处理
这一步的完整代码可以在/demo/整合tabscroll.vue中找到
横向滚动读取数据以及上拉加载
这一步我们将处理横向滚动的手势 这将使应用的左右滑动变得可用
<template>
...
vue-horizontal-scroll(
ref="vue-horizontal-scroll"
@scrollEnd="handleHorizontalScrollEnd") // modyify 通过监听scrollEnd事件 来处理横向滚动结束的事件
.header-bar(slot="header")
.search-bar 写给你爱的人的情书
.tab-bar(v-if="menus")
.tab-item(v-for="(value, key, idx) in menus" :class="{'tab-item-active' : idx === currentPageIdx }") {{key}}
...
</template>
<script>
...
/**
* @method handleHorizontalScrollEnd 处理横向的滚动事件
* @param { Number } pageIdx 横向滚动的页数
*/
handleHorizontalScrollEnd: function (pageIdx) { // add
this.currentPageIdx = pageIdx // add 每次切换tab页之后 都需要改变菜单栏聚焦的栏目
let keyCode = Object.keys(this.menus)[pageIdx] // add
// 如果this.menus[keyCode].data存在则说明这一栏已经被加载过了
if (this.menus[keyCode].data) return // add
this.api(keyCode, 1) // add 根据当前滚动到的页来请求接口
},
...
</script>
通过监听scrollEnd事件 我们在横向滚动结束的时候加载不同菜单栏的数据

这一步的完整代码可以在/demo/处理横向滚动事件.vue中找到
上拉加载事件
<template>
...
vue-vertical-scroll(
v-for="(value, key, idx) in menus"
@pullingUp="pullingUp(key, arguments)" // modify 通过监听pullingUp事件,来操作每一个vue-vertical-scroll组件的上拉加载事件
:key="idx")
...
</template>
<script>
...
data () {
...
mockMenusData: {
'日剧': {data: null, page: 1}, // modify 当我们需要上拉加载后 我们需要增加页数字段
'泰剧': {data: null, page: 1}, // modify
'韩剧': {data: null, page: 1}, // modify
'美剧': {data: null, page: 1}, // modify
'英剧': {data: null, page: 1} // modify
},
...
...
methods: {
...
/**
* @method pullingUp
* @param { String } key 当前上拉加载数据的类型 为业务层传过来的参数
* @param { Arguments } _arguments 由当前上拉的vue-vertical-scroll组件传上来的参数 由只包含一个BScroll实例的数组组成
* 通过_arguments[0] 即可获取BScroll实例以处理上拉加载的操作 下拉刷新同理
*/
pullingUp: function (key, _arguments) { // add
this.api(key, _arguments[0]) // add
}, // add
...
...
/**
* @method api 从后端获取接口
* @param { BScroll } BScroll 当前操作的vue-vertical-scroll组件中的BScroll实例
*/
api: async function (key, BScroll = null) { // modify 页数参数将由this.menus[key].page字段来读取
let baseUrl = process.env.NODE_ENV === 'development' ? 'http://localhost:7001' : 'https://dscsdoj.top'
let result = await axios.get(`${baseUrl}/api/douban?key=${key}&page=${this.menus[key].page}&size=10`)
// 当请求完毕后,需要手动调用finishPullUp方法 否则BScroll将无法继续上拉加载
this.menus[key].page++ // add 请求成功则增加一页
if (!this.menus[key].data) this.menus[key].data = []
this.menus[key].data = this.menus[key].data.concat(result.data.data.subjects)
if (BScroll) { // add 如果上层传入BScroll 则执行以下函数
this.$nextTick(() => { // add // 由于refresh需要读取dom参数 所以以下操作必须包裹入$nextTick中
BScroll.refresh() // add 将重新计算最大滚动位置
BScroll.finishPullUp() // add 如果不手动执行 上拉加载功能将不再回调
}) // add
} // add
}
...
}
</script>

通过监听<vue-vertical-scroll>
组件的pullingUp事件,我们将可以操作每一个vue-vertical-scroll组件的上拉加载事件
pullingUp事件向上传递了当前组件的BScroll实例。由于我们需要key参数,使用arguments参数,我们就可以通过arguments[0]来同时拿到业务参数key以及<vue-vertical-scroll>
组件提供的BScroll来操作
关于BScroll.refresh() 以及 BScroll.finishPullUp() 通过阅读
https://ustbhuangyi.github.io/better-scroll/doc/zh-hans/api-specific.html#finishpullup
https://ustbhuangyi.github.io/better-scroll/doc/zh-hans/api.html#refresh
来了解两个函数做了什么 为什么这么做
这一步的完整代码可以在/demo/处理上拉加载事件.vue中找到
优化展示效果
TabScroll暴露了多个参数来满足不同的展示需求
比如
offsetY:该参数将产生一定的视差 默认情况下上划 顶部header栏将会隐藏 当填写不同css单位的参数后 将会只隐藏一部分 而这一部分 就是你填入的offsetY的高度部分 在本例中希望通过搜索栏的高度来在下滑的情况下只展示菜单栏
<template>
...
vue-horizontal-scroll(
ref="vue-horizontal-scroll"
offsetY="-33px" // add 差值为菜单栏的高度
@scrollEnd="handleHorizontalScrollEnd")
</template>

比如
lock: 如果希望菜单栏的展示隐藏由是否滚动到顶部决定 而不是由手势决定(默认)
则给vue-horizontal-scroll增加一个lock属性
<template>
...
vue-horizontal-scroll(
ref="vue-horizontal-scroll"
offsetY="-33px"
lock // add 增加lock 往上划时将根据是否滚动到顶部来判断是否打开
@scrollEnd="handleHorizontalScrollEnd")
</template>

网友评论