Skip to content

按首字母排序的列表

数据解析

  1. 后端

    • 数据格式

      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'
                  }
              ]
          },
      ]
    • 数据模拟

      shell
      npm i mockjs --save-dev
    • 获取首字母

      shell
      npm 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, }
}

使用计算属性,计算对应 currentIndextitle

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 结构

  1. 生成首字母列表
js
export default function useLetterJump(dataRef) {
    const letters = computed(() => dataRef.value.map(item => item.title))
    return { letters }
}
  1. 循环生成 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>

点击某个字母,并跳到对应区域

  1. 把点击事件绑定在父容器中,代理每个字母的事件
  2. 获取点击字母的 data-index
  3. 获取 dataRef 对应 data=indexdom,通过 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 }
}
  1. 在组件中使用
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>

移动首字母导航栏,也可以跳转到滑动时对应的区域

  1. 点击时,记录点击的位置,和点击对应的 index
  2. 移动时,计算移动时的位置,减去点击时的位置,再除于字母的高度,既可以知道移动了多少个 index
  3. 再加上点击时的 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>