项目概述
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
|
@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框架的预览图如下:

预期目标
- 默认获取若干条笑话,并渲染到页面上
- 下拉刷新
- 触底加载更多
- 点击返回顶部
实现过程
默认获取若干条笑话
确认数据格式定义接口
我们可以通过接口文档来确认数据格式定义接口,或是通过获取的数据来自行确认数据格式定义接口。两种方式都可以。
通过接口文档确认数据格式定义接口
接口文档:开心一笑接口文档
利用Apifox提供的接口文档,我们可以确认数据格式如下:

由此定义接口
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 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)
|

下拉刷新
下拉刷新需要使用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() })
|
经测试功能正常。
这种编码细节正是我们在学习编程时需要格外注意的。

触底加载更多
List
组件的官方文档:List

触底加载更多需要使用List
组件的onReachEnd
事件,onReachEnd
事件会在列表边缘效果为弹簧效果时,划动经过末尾位置时触发一次,回弹回末尾位置时再触发一次。
由于我对于该事件并不熟悉所以先用弹窗的方式来进行测试。
1 2 3 4 5
| .onReachEnd(()=>{ AlertDialog.show({ message:'触底了' }) })
|

经测试我们理解了它的触发条件,本来我在疑惑它触发两次的效果是什么意思,而在测试中弹出两次弹窗的时机让我更加理解了它的触发条件。
触底加载更多逻辑函数
注意:触底加载更多的逻辑与下拉刷新的逻辑不一样,不能直接套用,需要重新定义。
下拉刷新是重新获取新的笑话,而触底加载更多是在原有笑话的基础上获取新的笑话。
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
事件的触发机制,但我们可以在触底加载更多逻辑函数中进行优化。
思路:
- 定义一个变量
isLoading
,用于判断是否正在加载更多数据。
- 在触底加载更多逻辑函数中,判断
isLoading
是否为true
,如果为true
,则不进行加载更多数据的操作。
- 在触底加载更多逻辑函数中,将
isLoading
设置为true
,表示正在加载更多数据。
- 当异步函数获取数据成功后,将
isLoading
设置为false
,表示加载更多数据完成。
这样就可以避免多次触发触底加载更多逻辑函数。
这种方法称之为变量控制法
。
1 2 3 4 5 6 7 8 9 10 11 12 13
| private 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%') }
|

点击返回顶部
利用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.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'
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() { 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.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) } } }
|