关于专题【vue开发音乐App】

何为listview?简单来说就是类似于我们手机自带的通讯录列表、音乐APP的歌手列表、外卖APP的分类菜单……,本文介绍的listview组件具备以下特性:

  1. 列表数据按照一定的分组依据(如热门、A、B、C等)进行分组后有序排列;
  2. 每组数据的顶部都有以该组名称命名的头部标签,用户滑动列表,相邻的两个头部标签之间会产生替换动画;
  3. 右侧提供快速定位的导航条,类似锚点,除简单的点击触发以外,还支持手指滑动,十分接近原生体验。

该组件实现难度较大,文章篇幅也较长,但是其中也包含了众多精彩的前端技能,所以还是希望大家能花点时间阅读本文并加以实践。

一、处理数据

数据通过jsonp跨域调用QQ音乐真实线上接口获取( 该部分不是本文重点,所以省略 ),获取到的数据是扁平的:

list: [
  {
    Farea: '1',
    Fattribute_3: '3',
    Fattribute_4: '0',
    Fgenre: '0',
    Findex: 'X',
    Fother_name: 'Joker',
    Fsinger_id: '5062',
    Fsinger_mid: '002J4UUk29y8BY',
    Fsinger_name: '薛之谦',
    Fsinger_tag: '541,555',
    Fsort: '1',
    Ftrend: '0',
    Ftype: '0',
    voc: '0'
  },
  {
    Farea: '0',
    Fattribute_3: '2',
    Fattribute_4: '0',
    Fgenre: '0',
    Findex: 'Z',
    Fother_name: 'Jay Chou',
    Fsinger_id: '4558',
    Fsinger_mid: '0025NhlN2yWrP4',
    Fsinger_name: '周杰伦',
    Fsinger_tag: '541,555',
    Fsort: '2',
    Ftrend: '0',
    Ftype: '0',
    voc: '0'
  },
  // 省略后面98个
]

这种数据不能直接使用,需要处理成如下数据结构:

list: [
  {
    title: '热门',
    items: [
      {
        id: '002J4UUk29y8BY',
        name: '薛之谦',
        avatar: 'https://y.gtimg.cn/music/photo_new/T001R300x300M000002J4UUk29y8BY.jpg?max_age=2592000'
      },
      {
        id: '0025NhlN2yWrP4',
        name: '周杰伦',
        avatar: 'https://y.gtimg.cn/music/photo_new/T001R300x300M0000025NhlN2yWrP4.jpg?max_age=2592000'
      },
      ...
    ]
  },
  {
    title: 'A',
    items: [
      {
        id: '0020PeOh4ZaCw1',
        name: 'Alan Walker (艾伦·沃克)',
        avatar: 'https://y.gtimg.cn/music/photo_new/T001R300x300M0000020PeOh4ZaCw1.jpg?max_age=2592000'
      },
      ...
    ]
  },
  ...
]

如何得到这种结构的数据?代码如下:

import Singer from 'common/js/singer'
const HOT_SINGER_LEN = 10
const HOT_NAME = '热门'

...

_normalizeSinger(list) {
  let map = {
    hot: {
      title: HOT_NAME,
      items: []
    }
  }
  list.forEach((item, index) => {
    // 只取前十名歌手作为热门
    if (index < HOT_SINGER_LEN) {
      // Singer类通过歌手id可以生成avatar链接
      map.hot.items.push(new Singer({
        name: item.Fsinger_name,
        id: item.Fsinger_mid
      }))
    }
    const key = item.Findex
    if (!map[key]) {
      map[key] = {
        title: key,
        items: []
      }
    }
    map[key].items.push(new Singer({
      name: item.Fsinger_name,
      id: item.Fsinger_mid
    }))
  })
  // 为了得到有序列表,我们需要处理 map
  let ret = []
  let hot = []
  for (let key in map) {
    let val = map[key]
    if (val.title.match(/[a-zA-Z]/)) {
      ret.push(val)
    } else if (val.title === HOT_NAME) {
      hot.push(val)
    }
  }
  ret.sort((a, b) => {
    // 按照“A-Z”的字母顺序排列
    return a.title.charCodeAt(0) - b.title.charCodeAt(0)
  })
  return hot.concat(ret)
}
  • Singer是抽象出来的类,作用是统一生成_normalizeSinger()方法中被push进items数组的单个歌手信息(id、name、avatar ),详细实现如下:
// common/js/singer

export default class Singer {
  constructor({id, name}) {
    this.id = id
    this.name = name
    this.avatar = `https://y.gtimg.cn/music/photo_new/T001R300x300M000${id}.jpg?max_age=2592000`
  }
}

二、代码实现

src/base/listview/listview.vue:

<template>
  <scroll class="list-view" :data="data" ref="listview" :probe-type="3" listen-scroll @scroll="scroll">
    <ul>
      <li v-for="group in data" :key="group.title" class="list-group" ref="listGroup">
        <h2 class="list-group-title">{{group.title}}</h2>
        <ul>
          <li v-for="item in group.list" :key="item.id" class="list-group-item" @click="selectItem(item)">
            <img class="avatar" v-lazy="item.avatar" alt="">
            <span class="name">{{item.name}}</span>
          </li>
        </ul>
      </li>
    </ul>
    <div class="list-shortcut" @touchstart="onShortcutTouchStart" @touchmove.stop.prevent="onShortcutTouchMove">
      <ul>
        <li v-for="(item, i) in shortcutList" :key="item" class="item" :class="{current : currIndex === i}" :data-index="i">{{item}}</li>
      </ul>
    </div>
    <div class="list-fixed" v-show="fixedTitle" ref="fixedTitle">
      <h2 class="fixed-title">{{fixedTitle}}</h2>
    </div>
    <div v-show="!data.length" class="loading-container">
      <loading></loading>
    </div>
  </scroll>
</template>

<script type="text/ecmascript-6">
// dom.js传送门:https://blog.wy310.cn/2020/01/16/vue-operate-dom/
import {getData} from 'common/js/dom'
// 每一个字母的高度(加padding)
const ANCHOR_HEIGHT = 16
// 区间标题的高度
const TITLE_HEIGHT = 30

export default {
  name: 'listview',
  props: {
    data: {
      type: Array,
      default () {
        return []
      }
    }
  },
  data () {
    return {
      scrollY: -1, // 滑动列表时,需要实时获取纵坐标,判断该值落在什么区间,从而高亮右侧导航栏中相应的字母
      currIndex: 0, // 导航栏当前高亮的索引
      diff: 0 // 头部标签的偏移量(标签顶部到容器顶部的距离),为了实现头部标签"顶""拉"的动画效果
    }
  },
  computed: {
    // 右侧导航栏的数据
    shortcutList () {
      return this.data.map(group => {
        return group.title.substr(0, 1)
      })
    },
    fixedTitle () {
      // 已至顶部仍然继续下拉,则不显示头部标签
      if (this.scrollY > 0) {
        return ''
      }
      return this.data[this.currIndex] ? this.data[this.currIndex].title : ''
    }
  },
  created () {
    // 定义一个触摸对象,目的是点击、滑动右侧导航栏实现左侧列表的联动
    this.touch = {}
    this.listHeight = [] // 记录左侧列表每个区间相对于顶部的距离,有了这个“距离列表”,才能判断当前滑动到哪个区间
  },
  methods: {
    // 监听导航栏的触摸开始事件
    onShortcutTouchStart (e) {
      let anchorIndex = getData(e.target, 'index')
      let touchStart = e.touches[0]
      this.touch.y1 = touchStart.pageY
      this.touch.anchorIndex = parseInt(anchorIndex)
      this.currIndex = parseInt(anchorIndex)
      this._scrollTo(anchorIndex)
    },
    // 监听导航栏的触摸移动事件
    onShortcutTouchMove (e) {
      let touchMove = e.touches[0]
      this.touch.y2 = touchMove.pageY
      let delta = (this.touch.y2 - this.touch.y1) / ANCHOR_HEIGHT | 0
      let anchorIndex = this.touch.anchorIndex + delta
      if (anchorIndex < 0) {
        anchorIndex = 0
      }
      if (anchorIndex > this.data.length - 1) {
        anchorIndex = this.data.length - 1
      }
      this.currIndex = anchorIndex
      this._scrollTo(anchorIndex)
    },
    // 监听左侧列表的滚动事件,将实时的滚动纵坐标(pos.y)赋给scrollY
    scroll (pos) {
      this.scrollY = pos.y
    },
    // 点击、滑动右侧导航栏,调用better-scroll的scrollToElement()方法,使左侧列表滚动到指定区间
    _scrollTo (index) {
      // 超出范围,不滚动
      if (index < 0 || index > this.data.length - 1 || isNaN(index)) {
        return false
      }
      this.$refs.listview.scrollToElement(this.$refs.listGroup[index], 0) // 第二个参数表示是否添加缓动函数
    },
    // 计算左边每个区间相对于顶部的距离
    _calculateheight () {
      this.listHeight = []
      const list = this.$refs.listGroup
      let height = 0
      // 第一个区间相对于顶部的偏移量是0
      this.listHeight.push(height)
      for (let i = 0; i < list.length; i++) {
        height += list[i].clientHeight
        this.listHeight.push(height)
      }
    },
    // 点击列表,派发事件给调用者
    selectItem (obj) {
      this.$emit('select', obj)
    }
  },
  watch: {
    data () {
      // 数据变化->DOM变化,有一个17毫秒左右的延时
      this.timer = setTimeout(() => {
        this._calculateheight()
      }, 20)
    },
    // 监听左侧滑动的纵坐标,分三种情况讨论,获取当前索引currIndex和头部标签的偏移量diff
    scrollY (newY) {
      const listHeight = this.listHeight
      // 页面已是最上面,仍然下拉,
      if (newY > 0) {
        this.currIndex = 0
        return false
      }
      // 在中间部分滑动(newY是负数)
      for (let i = 0; i < listHeight.length - 1; i++) {
        let height1 = listHeight[i]
        let height2 = listHeight[i + 1]
        if (-newY >= height1 && -newY < height2) {
          // 节流,防止重复赋值
          if (i !== this.currIndex) {
            this.currIndex = i
          }
          this.diff = height2 + newY
          return false
        }
      }
      // 页面已是最下面,仍然上拉,newY > listHeight最后一个值
      this.currIndex = this.data.length - 1 // 或listHeight.length - 2
    },
    diff (newDiff) {
      let fixedTop = (newDiff > 0 && newDiff < TITLE_HEIGHT) ? newDiff - TITLE_HEIGHT : 0
      // 节流,只在"顶"的时候赋新值
      if (this.fixedTop === fixedTop) {
        return false
      }
      this.fixedTop = fixedTop
      // 使用translate3d的目的是开启GPU加速,能使动画更流畅
      this.$refs.fixedTitle.style.transform = `translate3d(0, ${fixedTop}px, 0)`
    }
  },
  destoryed () {
    clearTimeout(this.timer)
  }
}
</script>

<style lang="stylus" rel="stylesheet/stylus">
// variable.styl传送门:https://blog.wy310.cn/2020/01/11/vue-build-basic-style-structure/
@import "~common/stylus/variable"

.list-view
  position: relative
  width: 100%
  height: 100%
  overflow: hidden
  background: $color-background
  .list-group
    padding-bottom: 30px
    .list-group-title
      height: 30px
      line-height: 30px
      padding-left: 20px
      font-size: $font-size-small
      color: $color-text-l
      background: $color-highlight-background
    .list-group-item
      display: flex
      align-items: center
      padding: 20px 0 0 30px
      .avatar
        width: 50px
        height: 50px
        border-radius: 50%
      .name
        margin-left: 20px
        color: $color-text-l
        font-size: $font-size-medium
  .list-shortcut
    position: absolute
    z-index: 30
    right: 2px
    top: 50%
    transform: translateY(-50%)
    text-align: center
    font-family: Helvetica
    .item
      display: flex
      align-items: center
      justify-content: center
      width: 16px
      height: 16px
      line-height: 1
      color: $color-text-l
      font-size: $font-size-small
      &.current
        color: $color-background
        &:before
          position: absolute;
          display: block
          content: ''
          width: 16px
          height: 16px
          border-radius: 50%
          background-color $color-theme
          z-index: -1;
.list-fixed
    position: absolute
    top: 0
    left: 0
    width: 100%
    .fixed-title
      height: 30px
      line-height: 30px
      padding-left: 20px
      font-size: $font-size-small
      color: $color-text-l
      background: $color-highlight-background
  .loading-container
    position: absolute
    width: 100%
    top: 50%
    transform: translateY(-50%)
</style>

三、调用

<listview :data="singerList"></listview>
0
浏览:2,263

0 条评论

发表评论

电子邮件地址不会被公开。

你必须允许浏览器启用JavaScript才能看到验证码

Scroll Up