效果图

失帧比较严重,在手机上效果更佳。

列表滚动动画

原理分析

这个滚动页面由两个部分布局(底部固定的Tab页面除外)。一个是顶部的banner轮播,一个是下面的列表。这里的重点是做列表的动画,banner轮播的网上资料很多,请自行查找。

图片名称 图片名称 图片名称

这个动画最重要的是在滚动中实时计算startIndex和endIndex,动画比较简单,就是scale和opacity的变化。向下滚动时,startIndex变小;向上滚动时,endIndex变大时,新露脸的项做该动画。当滚动连起来,就是一个完整的动画了。

涉及的技术

使用better-scroll做滚动以及轮播图

使用create-keyframe-animation做动画控制

实现步骤

  1. vue的template部分

注意:由于IOS渲染速度比较快, 必须把没有展现在首屏的页面上的item隐藏掉,即index比startIndex小、比endIndex大的item都应该隐藏,避免页面动画混乱。

<div class="area-wrapper" ref="areaWrapper">
    <div v-for="(item, index) in areaList" :key="index"
    @click="clickAreaItem(item.id)"
    :ref="'area-' + index" class="area"
    :style="{ backgroundImage: 'url('+item.thumbUrl+')', 'opacity': (index < startIndex || index > endIndex) ? 0 : 1}">
        <div class="content">
        <h2 class="num">{{item.num}}</h2>
        <div style="vertical-align:text-bottom">
            <p class="name">{{item.name}}</p>
            <p class="desc">{{item.desc}}</p>
        </div>
        </div>
    </div>
</div>
  1. 高度预设。用于计算startIndex、endIndex
const AreaItemHeight = 119  // 每一项的高度(这里默认一致,如果不一致请自行修改startIndex、endIndex的计算方式)
const MarginBottom = 15     // 列表项的底部边距
const TopHeight = 160       // banner的高度
const BottomHeight = 50     // 底部Tab的高度
  1. 监听滚动。并实时计算startIndex、endIndex
scroll (position) {
    const scrollY = position.y
    if (scrollY < 0) {
        // startIndex计算
        const currentStartIndex = Math.abs(scrollY) <= TopHeight ? 0 : parseInt((Math.abs(scrollY) - TopHeight) / (AreaItemHeight + MarginBottom))
        // endIndex计算
        let currentEndIndex = Math.floor((window.innerHeight - (TopHeight + scrollY) - BottomHeight) / (AreaItemHeight + MarginBottom))
        if (currentEndIndex > this.areaList.length - 1) {
            currentEndIndex = this.areaList.length - 1
        }
        // 这里使用vue的watch属性监听更好
        if (currentStartIndex !== this.startIndex) {
            if (currentStartIndex < this.startIndex) {
                // 运行动画
                this.runAnimation(currentStartIndex)
            }
            this.startIndex = currentStartIndex
        }
        // 这里使用vue的watch属性监听更好
        if (currentEndIndex !== this.endIndex) {
            if (currentEndIndex > this.endIndex) {
            this.runAnimation(currentEndIndex)
            }
            this.endIndex = currentEndIndex
        }
    }
}
  1. 运行动画
runAnimation (index) {
    animations.registerAnimation({
        name: 'scale',
        animation: [
            {
            scale: 0.5,
            opacity: 0
            },
            {
            scale: 1,
            opacity: 1
            }
        ],
        presets: {
            duration: 300,
            resetWhenDone: true
        }
    })
    animations.runAnimation(this.$refs['area-' + index], 'scale')
}

完整代码

.vue文件

<template>
<div class="address-wrapper" style="height: 100%;">
  <scroll ref="scroll" class="address-content" :data="areaList" @scroll="scroll" :listen-scroll="listenScroll" :probe-type="probeType" :bounce="false">
    <div>
      <div v-if="bannerList.length" style="position: relative;">
        <slider :list="bannerList">
          <div v-for="item in bannerList" :key="item.id" :style="{height: sliderHeight + 'px'}">
            <img class="needsclick" :src="item.thumbUrl" width="100%" height="100%" />
          </div>
        </slider>
        <div class="banner-bg"></div>
        <div class="banner-bg-1"></div>
      </div>

      <div class="area-wrapper" ref="areaWrapper">
        <div v-for="(item, index) in areaList" :key="index"
        @click="clickAreaItem(item.id)"
        :ref="'area-' + index" class="area"
        :style="{ backgroundImage: 'url('+item.thumbUrl+')', 'opacity': (index < startIndex || index > endIndex) ? 0 : 1}">
          <div class="content">
            <h2 class="num">{{item.num}}</h2>
            <div style="vertical-align:text-bottom">
              <p class="name">{{item.name}}</p>
              <p class="desc">{{item.desc}}</p>
            </div>
            <!-- <div></div> -->
          </div>
        </div>
      </div>
    </div>
  </scroll>
  <router-view />
</div>
</template>

<script>
import Slider from '@/components/slider/slider'
import Scroll from '@/components/scroll/scroll'
import { isIphoneX } from '@/assets/js/brower'
import animations from 'create-keyframe-animation'
import axios from '@/api/axiosApi'
import areaList from '@/assets/json/areaList.json'
import bannerList from '@/assets/json/bannerAddress.json'

// 每一个的Area的高度,都是一样的
const AreaItemHeight = 119
const MarginBottom = 15
const TopHeight = 160
const BottomHeight = 50

export default {
  data () {
    return {
      startIndex: 0,
      endIndex: 3,
      bannerList,
      areaList
    }
  },
  components: {
    Slider, Scroll
  },
  created () {
    this.probeType = 3
    this.listenScroll = true
    this.sliderHeight = 210 + 20
    if (isIphoneX()) {
      this.sliderHeight += 34
    }

    this._getBanner()
    this._getAddressList()
  },
  mounted () {
    this.endIndex = Math.floor((window.innerHeight - TopHeight - BottomHeight) / (AreaItemHeight + MarginBottom))
  },
  methods: {
    _getBanner () {
      axios.get(this, '/v1/banner/1', null, (data) => {
        data.forEach(item => {
          item.thumbUrl += '-banner'
        })
        this.bannerList = data
      }, null, false)
    },
    _getAddressList () {
      axios.get(this, '/v1/address/1', {
        pageSize: 30
      }, (data) => {
        // data.forEach(item => {
        //   item.thumbUrl += '-tiaomu'
        // })
        this.areaList = data
      }, null, false)
    },
    scroll (position) {
      const scrollY = position.y
      if (scrollY < 0) {
        const currentStartIndex = Math.abs(scrollY) <= TopHeight ? 0 : parseInt((Math.abs(scrollY) - TopHeight) / (AreaItemHeight + MarginBottom))
        let currentEndIndex = Math.floor((window.innerHeight - (TopHeight + scrollY) - BottomHeight) / (AreaItemHeight + MarginBottom))
        if (currentEndIndex > this.areaList.length - 1) {
          currentEndIndex = this.areaList.length - 1
        }

        if (currentStartIndex !== this.startIndex) {
          if (currentStartIndex < this.startIndex) {
            this.runAnimation(currentStartIndex)
          }
          this.startIndex = currentStartIndex
        }
        if (currentEndIndex !== this.endIndex) {
          if (currentEndIndex > this.endIndex) {
            this.runAnimation(currentEndIndex)
          }
          this.endIndex = currentEndIndex
        }
      }
    },
    runAnimation (index) {
      animations.registerAnimation({
        name: 'scale',
        animation: [
          {
            scale: 0.5,
            opacity: 0
          },
          {
            scale: 1,
            opacity: 1
          }
        ],
        presets: {
          duration: 300,
          resetWhenDone: true
        }
      })
      animations.runAnimation(this.$refs['area-' + index], 'scale')
    },
    clickAreaItem (id) {
      this.$router.push(`address/addressDetail/${id}`)
    }
  }
}
</script>

<style lang="stylus" scoped>
.address-wrapper {
  .address-content {
    height: 100%;
    overflow: hidden;

    .banner-bg {
      height: 50px;
      width: 100%;
      position: absolute;
      bottom: -1px;
      background:-moz-linear-gradient(top, rgba(249, 250, 252, 0.3), rgba(249, 250, 252, 1));/*火狐*/
      background: -webkit-gradient(linear, 0% 0%, 0% 100%, from(rgba(249, 250, 252, 0.3)), to(rgba(249, 250, 252, 1))); /*谷歌*/
      background-image: -webkit-gradient(linear,left bottom,left top,color-start(0, rgba(249, 250, 252, 0.3)),color-stop(1, rgba(249, 250, 252, 1)));/* Safari & Chrome*/
    }

    .banner-bg-1 {
      height: 20px;
      width: 100%;
      position: absolute;
      bottom: 49px;
      background:-moz-linear-gradient(top, rgba(249, 250, 252, 0), rgba(249, 250, 252, 0.3));/*火狐*/
      background: -webkit-gradient(linear, 0% 0%, 0% 100%, from(rgba(249, 250, 252, 0)), to(rgba(249, 250, 252, 0.3))); /*谷歌*/
      background-image: -webkit-gradient(linear,left bottom,left top,color-start(0, rgba(249, 250, 252, 0)),color-stop(1, rgba(249, 250, 252, 0.3)));/* Safari & Chrome*/
    }

    .area-wrapper {
      transform: translateY(-45px)
      padding: 0 15px;
      z-index: 1;

      .area {
        margin-bottom: 15px;
        height: 119px;
        width: 100%;
        border-radius: 10px;
        background-repeat: no-repeat;
        background-size: cover;
        box-shadow: 0 0 10px #a4a3a3;
        display: flex;
        align-items: flex-end;

        .content {
          color: #fff;
          display: flex;
          padding-right: 60px;
          padding-bottom: 15px;
          line-height: 1.2;

          .num {
            bottom: 35px;
            font-size: 48px;
            font-weight: 100;
            padding: 0 15px;
            display:table-cell;
            vertical-align:bottom;
          }

          .name {
            font-size: 21px;
            font-weight: 600;
            line-height: 1.7;
          }

          .desc {
            font-size: 14px;
          }
        }
      }
    }
  }
}
</style>

本地json文件,请自行修改图片路径

bannerAddress.json

[
  {
    "id": 1,
    "contentId": 111111,
    "type": 1,
    "thumbUrl": "./static/img/banner/banner_address_1.jpg"
  },
  {
    "id": 2,
    "contentId": 111111,
    "type": 1,
    "thumbUrl": "./static/img/banner/banner_address_2.jpg"
  },
  {
    "id": 3,
    "contentId": 111111,
    "type": 1,
    "thumbUrl": "./static/img/banner/banner_address_3.jpg"
  }
]

areaList.json

[
  {
    "id": "ba062c32fdf611e7ba2d00163e0c27f8",
    "name": "凯里",
    "desc": "这是凯里哟~",
    "num": 17,
    "thumbUrl": "./static/img/area/kaili.png"
  }, {
    "id": "ba5287a7fdf611e7ba2d00163e0c27f8",
    "name": "丹寨",
    "desc": "这是丹寨哟~",
    "num": 8,
    "thumbUrl": "./static/img/area/danzai.png"
  }, {
    "id": "ba9da079fdf611e7ba2d00163e0c27f8",
    "name": "麻江",
    "desc": "这是麻江哟~",
    "num": 12,
    "thumbUrl": "./static/img/area/majiang.png"
  }, {
    "id": "baeb0926fdf611e7ba2d00163e0c27f8",
    "name": "黄平",
    "desc": "这是黄平哟~",
    "num": 7,
    "thumbUrl": "./static/img/area/huangping.png"
  }, {
    "id": "bb357191fdf611e7ba2d00163e0c27f8",
    "name": "施秉",
    "desc": "这是施秉哟~",
    "num": 6,
    "thumbUrl": "./static/img/area/shibing.png"
  }, {
    "id": "bb842d8ffdf611e7ba2d00163e0c27f8",
    "name": "镇远",
    "desc": "这是镇远哟~",
    "num": 3,
    "thumbUrl": "./static/img/area/zhenyuan.png"
  }, {
    "id": "bbce67dffdf611e7ba2d00163e0c27f8",
    "name": "岑巩",
    "desc": "这是岑巩哟~",
    "num": 23,
    "thumbUrl": "./static/img/area/cengong.png"
  }, {
    "id": "bc198ca9fdf611e7ba2d00163e0c27f8",
    "name": "三穗",
    "desc": "这是三穗哟~",
    "num": 66,
    "thumbUrl": "./static/img/area/sansui.png"
  }, {
    "id": "bc64498bfdf611e7ba2d00163e0c27f8",
    "name": "天柱",
    "desc": "这是天柱哟~",
    "num": 128,
    "thumbUrl": "./static/img/area/tianzhu.png"
  }, {
    "id": "bcaf466bfdf611e7ba2d00163e0c27f8",
    "name": "锦屏",
    "desc": "这是锦屏哟~",
    "num": 107,
    "thumbUrl": "./static/img/area/jinping.png"
  }, {
    "id": "bcfa6f1bfdf611e7ba2d00163e0c27f8",
    "name": "黎平",
    "desc": "这是黎平哟~",
    "num": 211,
    "thumbUrl": "./static/img/area/liping.png"
  }, {
    "id": "bd44cca9fdf611e7ba2d00163e0c27f8",
    "name": "从江",
    "desc": "这是从江哟~",
    "num": 17,
    "thumbUrl": "./static/img/area/congjiang.png"
  }, {
    "id": "bd8f5cd4fdf611e7ba2d00163e0c27f8",
    "name": "榕江",
    "desc": "这是榕江哟~",
    "num": 17,
    "thumbUrl": "./static/img/area/rongjiang.png"
  }, {
    "id": "bdda2928fdf611e7ba2d00163e0c27f8",
    "name": "雷山",
    "desc": "这是雷山哟~",
    "num": 17,
    "thumbUrl": "./static/img/area/leishan.png"
  }, {
    "id": "be25afc0fdf611e7ba2d00163e0c27f8",
    "name": "台江",
    "desc": "这是台江哟~",
    "num": 17,
    "thumbUrl": "./static/img/area/taijiang.png"
  }, {
    "id": "be702db5fdf611e7ba2d00163e0c27f8",
    "name": "剑河",
    "desc": "这是剑河哟~",
    "num": 17,
    "thumbUrl": "./static/img/area/jianhe.png"
  }
]
Logo

前往低代码交流专区

更多推荐