Vue列表滚动动画

效果图

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


列表滚动动画

原理分析

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


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

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

涉及的技术

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

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

实现步骤

  1. vue的template部分

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<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

    1
    2
    3
    4
    const AreaItemHeight = 119  // 每一项的高度(这里默认一致,如果不一致请自行修改startIndex、endIndex的计算方式)
    const MarginBottom = 15 // 列表项的底部边距
    const TopHeight = 160 // banner的高度
    const BottomHeight = 50 // 底部Tab的高度
  2. 监听滚动。并实时计算startIndex、endIndex

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    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
    }
    }
    }
  3. 运行动画

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    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文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
<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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[
{
"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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
[
{
"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"
}
]

坚持原创技术分享,您的支持将鼓励我继续创作!