项目概述

UI骨架

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
/**
* 1. 默认加载
* 2. 下拉刷新
* 3. 触底加载更多
* 4. 点击返回顶部
* */
@Entry
@Component
struct Day01_07_Jokes {
@State jokes: string [] = ['笑话 1']
jokeNum: number = 5
@State refreshing: boolean = false
listScroller: Scroller = new Scroller()

build() {
Refresh({ refreshing: $$this.refreshing }) {
Column() {
// 顶部
this.HeaderBuilder()
// 笑话列表
List({ space: 10, scroller: this.listScroller }) {
ForEach(this.jokes, (joke: string) => {
ListItem() {
Column({ space: 10 }) {
Text('笑话标题')
.fontSize(20)
.fontWeight(600)
Row({ space: 15 }) {
titleIcon({ icon: $r('app.media.ic_public_time'), info: '2024-1-1' })
titleIcon({ icon: $r('app.media.ic_public_read'), info: '阅读(6666)' })
titleIcon({ icon: $r('app.media.ic_public_comments'), info: '评论(123)' })
}

Text(joke)
.fontSize(15)
.fontColor(Color.Gray)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding(20)

}
.borderRadius(10)
.backgroundColor(Color.White)
.shadow({ radius: 2, color: Color.Gray })
})

}
.padding(10)
.layoutWeight(1)

}
.width('100%')
.height('100%')
.backgroundColor('#f6f6f6')
}
}

@Builder
HeaderBuilder() {
Row() {
Image($r('app.media.ic_public_drawer_filled'))
.width(25);

Image($r('app.media.ic_public_joke_logo'))
.width(30)

Image($r('app.media.ic_public_search'))
.width(30);
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.height(60)
.padding(10)
.border({ width: { bottom: 2 }, color: '#f0f0f0' })
.backgroundColor(Color.White)
}
}

@Component
struct titleIcon {
icon: ResourceStr = ''
info: string = ''

build() {
Row() {
Image(this.icon)
.width(15)
.fillColor(Color.Gray)
Text(this.info)
.fontSize(14)
.fontColor(Color.Gray)
}
}
}

该UI框架的预览图如下:
骨架预览

预期目标

  1. 默认获取若干条笑话,并渲染到页面上
  2. 下拉刷新
  3. 触底加载更多
  4. 点击返回顶部

实现过程

默认获取若干条笑话

确认数据格式定义接口

我们可以通过接口文档来确认数据格式定义接口,或是通过获取的数据来自行确认数据格式定义接口。两种方式都可以。

通过接口文档确认数据格式定义接口

接口文档:开心一笑接口文档

利用Apifox提供的接口文档,我们可以确认数据格式如下:

2

由此定义接口

1
2
3
4
5
interface JokeResponse {
msg: string
code: number
data: string[]
}
通过获取的数据确认数据格式定义接口

我们可以通过获取的数据来确认数据格式定义接口。
首先获取数据:

1
2
3
4
5
6
7
8
aboutToAppear(): void {
this.req.request(`https://api-vue-base.itheima.net/api/joke/list?num=${this.jokeNum}`)
.then((res)=>{
AlertDialog.show({
message: res.result.toString()
})
})
}

1

由此定义接口

1
2
3
4
5
interface JokeResponse {
msg: string
code: number
data: string[]
}

页面显示自动刷新

在应用启动时,我们需要自动刷新页面,以获取5条随机笑话来进行展示。
我们可以在aboutToAppear生命周期中进行数据获取。

1
2
3
4
5
6
aboutToAppear(): void {
this.req.request(`https://api-vue-base.itheima.net/api/joke/list?num=${this.jokeNum}`)
.then((res)=>{
this.jokes = (JSON.parse(res.result.toString()) as JokeResponse).data
})
}

笑话标题截取

由于笑话内容过长,并且每条笑话服务器返回的都只有笑话的内容,而没有笑话的标题,因此我们需要对笑话内容进行截取,以获取笑话的标题。
这里可以利用字符串内置函数split方法来进行截取。
关于内置函数的用法可以看我的这篇博客:内置函数

1
2
3
Text(joke.split(',')[0])
.fontSize(20)
.fontWeight(600)

5

下拉刷新

下拉刷新需要使用Refresh组件,Refresh组件需要传入refreshing属性,refreshing属性为boolean类型,当refreshing属性为true时,Refresh组件会显示刷新动画。
Refresh组件官方文档:refresh组件

1
2
@State refreshing: boolean = false
Refresh({ refreshing: $$this.refreshing })

利用双向绑定,我们可以在Refresh组件中修改refreshing属性,从而实现下拉刷新。

刷新逻辑函数

在下拉事件发生时,我们需要定义一个刷新逻辑函数来处理获取新笑话的请求,并在Refresh组件中调用。
同时由于刷新动画并不会自动停止,因此我们需要在刷新逻辑函数中手动停止刷新动画。

下拉刷新时的逻辑与获取默认数据是一样的,因此我们直接将aboutToAppear生命周期中的代码封装为getJokeList函数即可。

1
2
3
4
5
6
private getJokeList() {
this.req.request(`https://api-vue-base.itheima.net/api/joke/list?num=${this.jokeNum}`)
.then((res) => {
this.jokes = (JSON.parse(res.result.toString()) as JokeResponse).data
})
}

对应的aboutToAppear生命周期中的代码如下:

1
2
3
aboutToAppear(): void {
this.getJokeList()
}

按照以往的逻辑我们直接将刷新事件的处理函数写为如下形式:

1
2
3
4
.onRefreshing(()=>{
this.getJokeList()
this.refreshing = false
})

这样就会出现一个bug。
在下拉刷新事件触发后刷新动画指挥显示一瞬间,然后就会消失。但此时由于网络请求耗时较长导致笑话列表并没有实现刷新,而是间隔几秒后才会刷新。
这是由于网络请求获取结果的then方法属于异步方法,会在主线程执行完毕后返回结果,而停止刷新动画的代码时同步的,所以会跳过等待异步方法的返回结果,直接执行停止刷新动画的代码。
因此我们需要将停止刷新动画的代码放在异步方法的then方法中执行。

1
2
3
4
5
6
7
8
9
10
11
private getJokeList() {
this.req.request(`https://api-vue-base.itheima.net/api/joke/list?num=${this.jokeNum}`)
.then((res) => {
this.jokes = (JSON.parse(res.result.toString()) as JokeResponse).data
this.refreshing = false
})
}

.onRefreshing(()=>{
this.getJokeList()
})

经测试功能正常。
这种编码细节正是我们在学习编程时需要格外注意的。

6

触底加载更多

List组件的官方文档:List

7

触底加载更多需要使用List组件的onReachEnd事件,onReachEnd事件会在列表边缘效果为弹簧效果时,划动经过末尾位置时触发一次,回弹回末尾位置时再触发一次。

由于我对于该事件并不熟悉所以先用弹窗的方式来进行测试。

1
2
3
4
5
.onReachEnd(()=>{
AlertDialog.show({
message:'触底了'
})
})

8

经测试我们理解了它的触发条件,本来我在疑惑它触发两次的效果是什么意思,而在测试中弹出两次弹窗的时机让我更加理解了它的触发条件。

触底加载更多逻辑函数

注意:触底加载更多的逻辑与下拉刷新的逻辑不一样,不能直接套用,需要重新定义。
下拉刷新是重新获取新的笑话,而触底加载更多是在原有笑话的基础上获取新的笑话。

1
2
3
4
5
6
private reachEnd() {
this.req.request(`https://api-vue-base.itheima.net/api/joke/list?num=${this.jokeNum}`)
.then((res) => {
this.jokes.push(...(JSON.parse(res.result.toString()) as JokeResponse).data)
})
}

但此时会发现它由于onReachEnd事件的触发机制发送了两次请求,获取了十条笑话,并非设定好的五条。
虽然这个现象无伤大雅,但为了使应用的性能更好,就需要减少网络请求的次数,所以我们要进行优化。
我们虽然没有办法更改onReachEnd事件的触发机制,但我们可以在触底加载更多逻辑函数中进行优化。

思路

  1. 定义一个变量isLoading,用于判断是否正在加载更多数据。
  2. 在触底加载更多逻辑函数中,判断isLoading是否为true,如果为true,则不进行加载更多数据的操作。
  3. 在触底加载更多逻辑函数中,将isLoading设置为true,表示正在加载更多数据。
  4. 当异步函数获取数据成功后,将isLoading设置为false,表示加载更多数据完成。

这样就可以避免多次触发触底加载更多逻辑函数。
这种方法称之为变量控制法

1
2
3
4
5
6
7
8
9
10
11
12
13
private reachEnd() {
// AlertDialog.show({
// message:'reachEnd'
// })
if (!this.isLoadingMore){
this.isLoadingMore = true
this.req.request(`https://api-vue-base.itheima.net/api/joke/list?num=${this.jokeNum}`)
.then((res) => {
this.jokes.push(...(JSON.parse(res.result.toString()) as JokeResponse).data)
this.isLoadingMore = false
})
}
}

触底加载更多动画

为了让用户知道我们正在加载更多数据,我们可以在List组件末尾添加一个动画,让用户知道我们正在加载更多数据。

1
2
3
4
5
6
7
if (this.isLoadingMore){
ListItem(){
LoadingProgress()
.width(50)
}
.width('100%')
}

9

点击返回顶部

利用listScroller控制器来实现返回到List组件顶部的功能。

1
2
3
4
5
6
7
8
9
10
Image($r('app.media.ic_public_joke_logo'))
.width(30)
.onClick(()=>{
// this.listScroller.scrollEdge(Edge.Top)
this.listScroller.scrollTo({
xOffset:0,
yOffset:0,
animation:true
})
})

这样虽然可以实现回到顶部,但没有动画效果,是直接闪现回去。
对于整体应用的流畅性来说,这是不够的。

升级至状态管理V2版本

本项目升级较为简单,只需要将@State改为@Local即可。

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
import { http } from '@kit.NetworkKit'

/**
* 1. 默认加载
* 2. 下拉刷新
* 3. 触底加载更多
* 4. 点击返回顶部
* */

interface JokeResponse {
msg: string
code: number
data: string[]
}

@Entry
@ComponentV2
struct Day01_07_Jokes {
req = http.createHttp()
@Local jokes: string [] = []
jokeNum: number = 5
@Local refreshing: boolean = false
listScroller: Scroller = new Scroller()
@Local isLoadingMore:boolean = false

aboutToAppear(): void {
this.getJokeList()
}
private getJokeList() {
this.req.request(`https://api-vue-base.itheima.net/api/joke/list?num=${this.jokeNum}`)
.then((res) => {
this.jokes = (JSON.parse(res.result.toString()) as JokeResponse).data
this.refreshing = false
})
}
private reachEnd() {
// AlertDialog.show({
// message:'reachEnd'
// })
if (!this.isLoadingMore){
this.isLoadingMore = true
this.req.request(`https://api-vue-base.itheima.net/api/joke/list?num=${this.jokeNum}`)
.then((res) => {
this.jokes.push(...(JSON.parse(res.result.toString()) as JokeResponse).data)
this.isLoadingMore = false
})
}
}

build() {
Refresh({ refreshing: $$this.refreshing }) {
Column() {
// 顶部
this.HeaderBuilder()
// 笑话列表
List({ space: 10, scroller: this.listScroller }) {
ForEach(this.jokes, (joke: string) => {
ListItem() {
Column({ space: 10 }) {
Text(joke.split(',')[0])
.fontSize(20)
.fontWeight(600)
Row({ space: 15 }) {
titleIcon({ icon: $r('app.media.ic_public_time'), info: '2024-1-1' })
titleIcon({ icon: $r('app.media.ic_public_read'), info: '阅读(6666)' })
titleIcon({ icon: $r('app.media.ic_public_comments'), info: '评论(123)' })
}

Text(joke)
.fontSize(15)
.fontColor(Color.Gray)
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding(20)

}
.borderRadius(10)
.backgroundColor(Color.White)
.shadow({ radius: 2, color: Color.Gray })
})

if (this.isLoadingMore){
ListItem(){
LoadingProgress()
.width(50)
}
.width('100%')
}


}
.padding(10)
.layoutWeight(1)
.onReachEnd(() => {
this.reachEnd()
})

}
.width('100%')
.height('100%')
.backgroundColor('#f6f6f6')
}
.onRefreshing(() => {
this.getJokeList()
})
}



@Builder
HeaderBuilder() {
Row() {
Image($r('app.media.ic_public_drawer_filled'))
.width(25);

Image($r('app.media.ic_public_joke_logo'))
.width(30)
.onClick(()=>{
// this.listScroller.scrollEdge(Edge.Top)
this.listScroller.scrollTo({
xOffset:0,
yOffset:0,
animation:true
})
})

Image($r('app.media.ic_public_search'))
.width(30);
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.height(60)
.padding(10)
.border({ width: { bottom: 2 }, color: '#f0f0f0' })
.backgroundColor(Color.White)
}
}

@Component
struct titleIcon {
icon: ResourceStr = ''
info: string = ''

build() {
Row() {
Image(this.icon)
.width(15)
.fillColor(Color.Gray)
Text(this.info)
.fontSize(14)
.fontColor(Color.Gray)
}
}
}