主题
按首字母排序的列表
数据解析
后端
数据格式
json[ { title: 'A', list: [ { id: 1, name: 'alin', pic: 'http://www.xxx.com/pic/alin.png' }, { id: 2, name: '阿杜', pic: 'http://www.xxx.com/pic/adu.png' } ] }, { title: 'B', list: [ { id: 3, name: 'BY2', pic: 'http://www.xxx.com/pic/BY2.png' } ] }, ]
数据模拟
shellnpm i mockjs --save-dev
获取首字母
shellnpm i pinyin --save-dev
封装滚动组件
- 需要的库
shell
npm i @better-scroll/core @better-scroll/observe-dom --save
- UI 组件
vue
<template>
<div ref="rootRef">
<slot></slot>
</div>
</template>
<script>
import useScroll from './useScroll'
export default {
name: 'scroll',
props: {
// 0~3,数字越大,滚动事件触发的频率越频繁
probeType: {
type: Number,
default: 0
}
},
emits: ['scroll'],
setup(props, { emit }) {
const { scroll, rootRef } = useScroll(props, emit)
return { scroll, rootRef }
},
}
</script>
- 逻辑组件
js
import BScroll from '@better-scroll/core'
import ObserveDOM from '@better-scroll/observe-dom'
import { onMounted, onUnmounted, onActivated, onDeactivated, ref } from 'vue'
// 使用监听,当监听到 dom 发生变化,重新构造滚动组件
BScroll.use(ObserveDOM)
export default function useScroll(options, emit) {
const rootRef = ref(null)
const scroll = ref(null)
onMounted(() => {
const scrollVal = scroll.value = new BScroll(rootRef.value, {
observeDOM: true,
...options
})
// 当 probeType 大于 0,则派发 scroll 事件
if (options.probeType > 0) {
scrollVal.on('scroll', position => {
emit('scroll', position)
})
}
})
onUnmounted(() => {
scroll.value.destroy()
})
onActivated(() => {
scroll.value.enable()
scroll.value.refresh()
})
onDeactivated(() => {
scroll.value.disable()
})
return {
rootRef, scroll
}
}
- 使用
vue
<template>
<scroll
class="wrapper"
probeType="3"
@scroll="handleScroll"
>
<div>......</div>
</scroll>
</template>
<script>
import Scroll from './components/scroll/scroll.vue'
export default {
components: { Scroll },
setup() {
const handleScroll = (pos) => {
console.log(pos)
}
return { handleScroll }
}
}
</script>
<style lang="less">
// 给容器一个高度
.wrapper {
height: 100vh;
width: 100%;
margin: 0 auto;
overflow: hidden;
}
</style>
基础样式参考
- 只放比较重要的代码
vue
<template>
<scroll
class="wrapper"
probeType="3"
@scroll="handleScroll"
>
<ul>
<li v-for="item of dataRef">
<div class="title">{{ item.title }}</div>
<ul>
<li v-for="singer of item.list">
<img :src="singer.pic" />
<span>{{ singer.name }}</span>
</li>
</ul>
</li>
</ul>
</scroll>
</template>
<script>
import { onMounted, ref } from 'vue'
import Scroll from './components/scroll/scroll.vue'
import axios from 'axios'
export default {
components: {
Scroll
},
setup() {
const dataRef = ref([])
onMounted(async () => {
const { data: { data } } = await axios.get('/api/list')
dataRef.value = data
})
const handleScroll = (pos) => {
console.log(pos)
}
return { dataRef, handleScroll }
}
}
</script>
顶部显示当前正在处于哪个首字母区域
创建 useIndex.js 封装逻辑
js
export default function useIndex() {}
计算每一个区域相对视口顶部的距离
每次滚动事件都判断,当前滚动距离数据哪两个区域之间,则可知道要显示那个区域的首字母
js
import { ref, watch, computed, nextTick } from "vue";
/*
* dataRef: 列表数据的 ref 对象
* contentRef: 所有区域的父节点
*/
export default function useIndex(dataRef, contentRef) {
// 每个区域相对视口顶部距离的合集
const heights = ref([])
const currentIndex = ref(0)
// 当 dataRef 发生变化时,应该重新计算 heights
watch(dataRef, () => {
nextTick(init)
})
// 重新计算每个 heights
function init() {
const heightsVal = heights.value = []
const lis = contentRef.value.children
for (let i = 0; i < lis.length; i++) {
heightsVal.push( lis[i].getBoundingClientRect().top )
}
heightsVal.push(Number.MAX_SAFE_INTEGER)
}
function handleScroll (position) {
const scrollY = -position.y
const heightsVal = heights.value
for (let i = 0; i < heightsVal.length - 1; i++) {
const heightBottom = heightsVal[i + 1]
if (scrollY >= heightsVal[i] && scrollY <= heightBottom) {
currentIndex.value = i
break;
}
}
}
return { handleScroll, }
}
使用计算属性,计算对应 currentIndex 的 title
js
import { ref, watch, computed, nextTick } from "vue";
export default function useIndex(dataRef, contentRef) {
const heights = ref([])
const currentIndex = ref(0)
watch(dataRef, () => {
nextTick(init)
})
// 当前标题,通过 currentIndex 计算得出
const currentTitle = computed(() => {
return currentIndex.value === -1 ? '' : (dataRef.value[currentIndex.value]?.title || '')
})
function init() {
const heightsVal = heights.value = []
const lis = contentRef.value.children
for (let i = 0; i < lis.length; i++) {
heightsVal.push( lis[i].getBoundingClientRect().top )
}
heightsVal.push(Number.MAX_SAFE_INTEGER)
}
function handleScroll (position) {
const scrollY = -position.y
// 因为 better-scroll 允许滚动到最顶部后,继续向上拉取一段距离
// 而向上拉取时,应当不显示 index DOM,且不用向下继续计算
// 当出现以下情况时,设置成 -1,方便使用
if (scrollY < 0) {
currentIndex.value = -1
return
}
const heightsVal = heights.value
for (let i = 0; i < heightsVal.length - 1; i++) {
const heightBottom = heightsVal[i + 1]
if (scrollY >= heightsVal[i] && scrollY <= heightBottom) {
currentIndex.value = i
break;
}
}
}
return { handleScroll, currentTitle }
}
增强动画效果
js
import { ref, watch, computed, nextTick } from "vue";
export default function useIndex(dataRef, contentRef) {
// translate 功能发生大的最小间隔,一般设置为 index DOM 的 clientHeight
const MIN_DISTANCE = 30
const heights = ref([])
const currentIndex = ref(0)
const transformStyle = ref({
transform: 'translateY(0)'
})
const currentTitle = computed(() => {
return currentIndex.value === -1 ? '' : (dataRef.value[currentIndex.value]?.title || '')
})
watch(dataRef, () => {
nextTick(init)
})
function init() {
const lis = contentRef.value.children
const heightsVal = heights.value = []
for (let i = 0; i < lis.length; i++) {
heightsVal.push( lis[i].getBoundingClientRect().top )
}
heightsVal.push(Number.MAX_SAFE_INTEGER)
}
function handleScroll (position) {
const scrollY = -position.y
if (scrollY < 0) {
currentIndex.value = -1
return
}
const heightsVal = heights.value
for (let i = 0; i < heightsVal.length - 1; i++) {
const heightBottom = heightsVal[i + 1]
if (scrollY >= heightsVal[i] && scrollY <= heightBottom) {
currentIndex.value = i
// 把增强动画效果,封装到一个函数中
changeTransformStyle(scrollY, heightBottom)
break;
}
}
}
// 动态改变 index DOM 的 translate,产生更好看的动画效果
function changeTransformStyle(scrollY, heightBottom) {
const distance = heightBottom - scrollY
const transformY = (distance > 0 && distance <= MIN_DISTANCE) ? distance - MIN_DISTANCE : 0
transformStyle.value = {
transform: `translateY(${transformY}px)`
}
}
return { handleScroll, currentTitle, transformStyle }
}
使用
vue
<template>
<scroll
:probeType="3"
@scroll="handleScroll"
>
<ul ref="contentRef">...</ul>
<div
class="index"
:style="transformStyle"
v-show="currentTitle"
>{{ currentTitle }}</div>
</scroll>
</template>
<script>
import { onMounted, ref } from 'vue'
import Scroll from './components/scroll/scroll.vue'
import useIndex from './components/index/useIndex'
import axios from 'axios'
export default {
components: {
Scroll
},
setup() {
const contentRef = ref(null)
const dataRef = ref([])
onMounted(async () => {
const { data: { data } } = await axios.get('/api/list')
dataRef.value = data
})
const { handleScroll, currentTitle, transformStyle } = useIndex(dataRef, contentRef)
return {
dataRef, contentRef,
handleScroll, currentTitle, transformStyle
}
}
}
</script>
<style lang="less">
.index {
position: fixed;
top: 0;
left: 0;
}
</style>
辅助点击的首字母导航栏
产生对应的 DOM 结构
- 生成首字母列表
js
export default function useLetterJump(dataRef) {
const letters = computed(() => dataRef.value.map(item => item.title))
return { letters }
}
- 循环生成 DOM
vue
<template>
<scroll>
<ul>...</ul>
<div class="index">...</div>
<ul class="letter-wrapper">
<li
v-for="(letter, index) of letters"
:key="letter"
:data-index="index"
:class="{'letter-active': currentIndex === index}"
>{{ letter }}</li>
</ul>
</scroll>
</template>
<script>
export default {
setup() {
// 需要导出 currentIndex,用以判断哪个字母需要高亮
const { currentIndex, ... } = useIndex(dataRef, contentRef)
const { letters } = useLetterJump(dataRef, contentRef)
return { dataRefcurrentIndex,letters }
}
}
</script>
<style lang="less">
.letter-wrapper {
position: fixed;
top: 50%;
right: 4px;
transform: translateY(-50%);
width: 20px;
padding: 10px 0;
text-align: center;
color: #fff;
background: rgba(0, 0, 0, .7);
border-radius: 10px;
list-style: none;
}
.letter-active {
color: pink;
}
</style>
点击某个字母,并跳到对应区域
- 把点击事件绑定在父容器中,代理每个字母的事件
- 获取点击字母的 data-index
- 获取 dataRef 对应 data=index 的 dom,通过 better-scroll 提供的 scrollToElement 跳转
js
import { ref, computed } from "vue"
export default function useLetterJump(dataRef, contentRef) {
// 为获取 better-scroll 实例
const scrollRef = ref(null)
const letters = computed(() => dataRef.value.map(item => item.title))
function handleClick(e) {
const index = Number(e.target.dataset.index)
// 有可能点到父容器,父容器上没有 dataset.index,所以会返回 NaN,此时什么都不用做
if (index) {
scrollTo(index)
}
}
function scrollTo(index) {
const scroll = scrollRef.value.scroll
const targetEl = contentRef.value.children[index]
scroll.scrollToElement(targetEl)
}
return { letters, scrollRef, handleClick }
}
- 在组件中使用
vue
<template>
<scroll ref="scrollRef">
<ul @touchstart.stop.prevent="handleClick">
<li></li>
</ul>
</scroll>
</template>
<script>
export default {
setup() {
const { letters, scrollRef, handleClick } = useLetterJump()
return { letters, scrollRef, handleClick }
}
}
</script>
移动首字母导航栏,也可以跳转到滑动时对应的区域
- 点击时,记录点击的位置,和点击对应的 index
- 移动时,计算移动时的位置,减去点击时的位置,再除于字母的高度,既可以知道移动了多少个 index
- 再加上点击时的 index,即可得出移动后的 index
js
import { ref, computed } from "vue"
export default function useLetterJump(dataRef, contentRef) {
const ANCHOR_HEIGHT = 20
const scrollRef = ref(null)
const letters = computed(() => dataRef.value.map(item => item.title))
const touch = {
y1: -1
}
function handleClick(e) {
const index = Number(e.target.dataset.index)
if (index) {
// 记录位置
touch.y1 = e.touches[0].pageY
touch.startIndex = index
scrollTo(index)
}
}
function handleMove(e) {
if (touch.y1 >= 0) {
// 计算跳转到的 index
touch.y2 = e.touches[0].pageY
const distance = Math.floor( (touch.y2 - touch.y1) / ANCHOR_HEIGHT )
const endIndex = touch.startIndex + distance
scrollTo(endIndex)
}
}
function handleEnd() {
touch.y1 = -1
}
function scrollTo(index) {
const scroll = scrollRef.value.scroll
const targetEl = contentRef.value.children[index]
scroll.scrollToElement(targetEl)
}
return { letters, scrollRef, handleClick, handleMove, handleEnd }
}
使用
vue
<template>
<scroll
class="wrapper"
:probeType="3"
@scroll="handleScroll"
ref="scrollRef"
>
<ul
@touchstart.stop.prevent="handleClick"
@touchmove.stop.prevent="handleMove"
@touchend.stop.prevent="handleEnd"
>
<li
v-for="(letter, index) of letters"
:key="letter"
:data-index="index"
:class="{'letter-active': currentIndex === index}"
>{{ letter }}</li>
</ul>
</scroll>
</template>
<script>
export default {
setup() {
const { currentIndex } = useIndex(dataRef, contentRef)
const { letters, scrollRef, handleClick, handleMove, handleEnd } = useLetterJump(dataRef, contentRef)
return { currentIndex, letters, scrollRef, handleClick, handleMove, handleEnd }
}
}
</script>