前言
在安卓开发中,下拉刷新是一个非常常用的功能,几乎只要是涉及到列表展示数据的界面都会用到它。
而 Compose 却直到 2022年10月份才在 compose.material:1.3.0
中添加了对下拉刷新的支持:Modifier.pullRefresh
。
在此之前,我们只能使用 accompanist-swiperefresh
来实现下拉刷新。
然而,更坑的是,Compose 对下拉刷新的支持是添加到 material
中的,而现在谷歌主推的却是 material3
,你猜怎么着,诶,material3
不支持下拉刷新。并且由于 material
添加了 Modifier.pullRefresh
,accompanist-swiperefresh
就直接废弃了,不再维护:
This library is deprecated, with official pull refresh support in androidx.compose.material.pullrefresh.
我查了一下,虽然 material3
的路线图中有对下拉刷新的计划,但是等到发布不知道还得等几年,而且在谷歌的 Issue Trcker 也有人询问了这个进度,官方的回答是:
PullToRefresh is not currently on the M3 radar however you are welcome to file a feature request.
哈哈,这意思就是甚至还没有新建文件夹呗。
所以,本文介绍的为下拉刷新添加自定义指示器的代码将主要以 accompanist-swiperefresh
的 SwipeRefresh
为例子,但是其实 material
的 Modifier.pullRefresh
和 accompanist-swiperefresh
的 SwipeRefresh
都是差不多的,稍微改一下就可以通用了。
正文
一个简单的下拉刷新 demo
以下为前言中两种不同的实现方式的运行效果,它们的运行效果其实都是一样的:
使用 Modifier.pullRefresh(refreshState)
实现:
kotlin
@Composable
fun PullRefresh() {
var refreshing by remember { mutableStateOf(false) }
val refreshState = rememberPullRefreshState(
refreshing = refreshing,
onRefresh = {
refreshing = true
}
)
LaunchedEffect(refreshing) {
if (refreshing) {
delay(2000)
refreshing = false
}
}
Box(
modifier = Modifier.pullRefresh(refreshState)
) {
LazyColumn {
items(30) {
Row(Modifier.padding(16.dp)) {
Text(
text = "Text $it",
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
)
}
}
}
PullRefreshIndicator(refreshing = refreshing, state = refreshState, Modifier.align(Alignment.TopCenter))
}
}
由于 MD 的实现采用的是添加修饰符,而不是一个单独的组件,所以我们需要添加一个父容器来存放列表和下拉指示器,因为下拉指示器是重叠在列表上的,所以这里的父容器,显而易见应该使用 Box
。
你也可以试试把 Box
换成其他的非堆叠组件,例如 Column
看看会发生什么
接下来是使用 SwipeRefresh
实现的:
kotlin
@Composable
fun PullRefresh2() {
var refreshing by remember { mutableStateOf(false) }
val refreshState = rememberSwipeRefreshState(isRefreshing = refreshing)
LaunchedEffect(refreshing) {
if (refreshing) {
delay(2000)
refreshing = false
}
}
SwipeRefresh(
state = refreshState,
onRefresh = { refreshing = true },
) {
LazyColumn {
items(30) {
Row(Modifier.padding(16.dp)) {
Text(
text = "Text $it",
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
)
}
}
}
}
}
加一个怎样的指示器?
先看最终实现效果:
接下来我们拆解一下这个下拉刷新指示器都有些啥。
- 最显而易见的,指示器主体是一个动画
- 下拉时动画主体随着下拉进度逐渐往下显现(位移和透明度)
- 下拉时列表内容跟随下拉进度下移
- 下拉时动画随着下拉或收回进度播放或倒退
- 下拉触发刷新时动画自动播放
- 动画下方有一行文字指示当前下拉状态(下拉中、下拉完成、正在刷新)
其中的加载动画,我们就不自己实现了(当然你想自己实现也不是不行),我们直接使用 lottie
,这是我随便找的一个 lottie
的加载动画:
text
{"v":"5.4.1","fr":29.9700012207031,"ip":0,"op":57.0000023216576,"w":203,"h":109,"nm":"Pre-comp 2","ddd":0,"assets":[{"id":"comp_0","layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Pre-comp 1","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[231.25,243,0],"ix":2},"a":{"a":0,"k":[100.25,243,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[130.5,161.5],[70,161.5],[70,324.5],[130.5,324.5]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"w":500,"h":500,"ip":51.0000020772726,"op":219.000008920053,"st":51.0000020772726,"bm":0},{"ddd":0,"ind":2,"ty":0,"nm":"Pre-comp 1","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[195.25,243,0],"ix":2},"a":{"a":0,"k":[100.25,243,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[130.5,161.5],[70,161.5],[70,324.5],[130.5,324.5]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"w":500,"h":500,"ip":38.0000015477717,"op":206.000008390552,"st":38.0000015477717,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"Pre-comp 1","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[157.25,243,0],"ix":2},"a":{"a":0,"k":[100.25,243,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[130.5,161.5],[70,161.5],[70,324.5],[130.5,324.5]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"w":500,"h":500,"ip":25.0000010182709,"op":193.000007861051,"st":25.0000010182709,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"Pre-comp 1","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[119.25,243,0],"ix":2},"a":{"a":0,"k":[100.25,243,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[130.5,161.5],[70,161.5],[70,324.5],[130.5,324.5]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"w":500,"h":500,"ip":12.00000048877,"op":180.00000733155,"st":12.00000048877,"bm":0},{"ddd":0,"ind":5,"ty":0,"nm":"Pre-comp 1","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[100.25,243,0],"ix":2},"a":{"a":0,"k":[100.25,243,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[130.5,161.5],[70,161.5],[70,324.5],[130.5,324.5]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"w":500,"h":500,"ip":0,"op":168.00000684278,"st":0,"bm":0}]},{"id":"comp_1","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 3","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":146,"s":[100],"e":[22]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":153,"s":[22],"e":[100]},{"t":159.000006476203}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.762,"y":1},"o":{"x":0.179,"y":0},"n":"0p762_1_0p179_0","t":112,"s":[106,212,0],"e":[106,292,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.795,"y":0.987},"o":{"x":0.167,"y":0},"n":"0p795_0p987_0p167_0","t":141,"s":[106,292,0],"e":[106,212,0],"to":[0,0,0],"ti":[0,0,0]},{"t":167.000006802049}],"ix":2},"a":{"a":0,"k":[-144,-8,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.481,0.481,0.667],"y":[1,1,1]},"o":{"x":[0.021,0.021,0.333],"y":[0,0,0]},"n":["0p481_1_0p021_0","0p481_1_0p021_0","0p667_1_0p333_0"],"t":112,"s":[30,30,100],"e":[73.121,73.121,100]},{"i":{"x":[0.695,0.695,0.667],"y":[0.614,0.614,1]},"o":{"x":[0.453,0.453,0.333],"y":[0,0,0]},"n":["0p695_0p614_0p453_0","0p695_0p614_0p453_0","0p667_1_0p333_0"],"t":126,"s":[73.121,73.121,100],"e":[30,30,100]},{"i":{"x":[0.578,0.578,0.578],"y":[1,1,-18.949]},"o":{"x":[0.278,0.278,0.333],"y":[0.472,0.472,0]},"n":["0p578_1_0p278_0p472","0p578_1_0p278_0p472","0p578_-18p949_0p333_0"],"t":141,"s":[30,30,100],"e":[0,0,100]},{"i":{"x":[0.975,0.975,0.703],"y":[1,1,1]},"o":{"x":[0.344,0.344,0.344],"y":[0,0,13.959]},"n":["0p975_1_0p344_0","0p975_1_0p344_0","0p703_1_0p344_13p959"],"t":155,"s":[0,0,100],"e":[30,30,100]},{"t":167.000006802049}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[26,26],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.266666636747,0.456470654058,1],"ix":3},"o":{"a":0,"k":0,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":1,"lj":1,"ml":4,"ml2":{"a":0,"k":4,"ix":8},"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0.282353001015,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-144,-8],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":112.000004561854,"op":168.00000684278,"st":112.000004561854,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Shape Layer 2","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":90,"s":[100],"e":[22]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":97,"s":[22],"e":[100]},{"t":103.000004195276}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.762,"y":1},"o":{"x":0.179,"y":0},"n":"0p762_1_0p179_0","t":56,"s":[106,212,0],"e":[106,292,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.795,"y":0.987},"o":{"x":0.167,"y":0},"n":"0p795_0p987_0p167_0","t":85,"s":[106,292,0],"e":[106,212,0],"to":[0,0,0],"ti":[0,0,0]},{"t":111.000004521123}],"ix":2},"a":{"a":0,"k":[-144,-8,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.481,0.481,0.667],"y":[1,1,1]},"o":{"x":[0.021,0.021,0.333],"y":[0,0,0]},"n":["0p481_1_0p021_0","0p481_1_0p021_0","0p667_1_0p333_0"],"t":56,"s":[30,30,100],"e":[73.121,73.121,100]},{"i":{"x":[0.695,0.695,0.667],"y":[0.614,0.614,1]},"o":{"x":[0.453,0.453,0.333],"y":[0,0,0]},"n":["0p695_0p614_0p453_0","0p695_0p614_0p453_0","0p667_1_0p333_0"],"t":70,"s":[73.121,73.121,100],"e":[30,30,100]},{"i":{"x":[0.578,0.578,0.578],"y":[1,1,-18.949]},"o":{"x":[0.278,0.278,0.333],"y":[0.472,0.472,0]},"n":["0p578_1_0p278_0p472","0p578_1_0p278_0p472","0p578_-18p949_0p333_0"],"t":85,"s":[30,30,100],"e":[0,0,100]},{"i":{"x":[0.975,0.975,0.703],"y":[1,1,1]},"o":{"x":[0.344,0.344,0.344],"y":[0,0,13.959]},"n":["0p975_1_0p344_0","0p975_1_0p344_0","0p703_1_0p344_13p959"],"t":99,"s":[0,0,100],"e":[30,30,100]},{"t":111.000004521123}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[26,26],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.266666636747,0.456470654058,1],"ix":3},"o":{"a":0,"k":0,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":1,"lj":1,"ml":4,"ml2":{"a":0,"k":4,"ix":8},"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0.282353001015,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-144,-8],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":56.0000022809268,"op":112.000004561854,"st":56.0000022809268,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":34,"s":[100],"e":[22]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":41,"s":[22],"e":[100]},{"t":47.0000019143492}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.762,"y":1},"o":{"x":0.179,"y":0},"n":"0p762_1_0p179_0","t":0,"s":[106,212,0],"e":[106,292,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.795,"y":0.987},"o":{"x":0.167,"y":0},"n":"0p795_0p987_0p167_0","t":29,"s":[106,292,0],"e":[106,212,0],"to":[0,0,0],"ti":[0,0,0]},{"t":55.0000022401959}],"ix":2},"a":{"a":0,"k":[-144,-8,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.481,0.481,0.667],"y":[1,1,1]},"o":{"x":[0.021,0.021,0.333],"y":[0,0,0]},"n":["0p481_1_0p021_0","0p481_1_0p021_0","0p667_1_0p333_0"],"t":0,"s":[30,30,100],"e":[73.121,73.121,100]},{"i":{"x":[0.695,0.695,0.667],"y":[0.614,0.614,1]},"o":{"x":[0.453,0.453,0.333],"y":[0,0,0]},"n":["0p695_0p614_0p453_0","0p695_0p614_0p453_0","0p667_1_0p333_0"],"t":14,"s":[73.121,73.121,100],"e":[30,30,100]},{"i":{"x":[0.578,0.578,0.578],"y":[1,1,-18.949]},"o":{"x":[0.278,0.278,0.333],"y":[0.472,0.472,0]},"n":["0p578_1_0p278_0p472","0p578_1_0p278_0p472","0p578_-18p949_0p333_0"],"t":29,"s":[30,30,100],"e":[0,0,100]},{"i":{"x":[0.975,0.975,0.703],"y":[1,1,1]},"o":{"x":[0.344,0.344,0.344],"y":[0,0,13.959]},"n":["0p975_1_0p344_0","0p975_1_0p344_0","0p703_1_0p344_13p959"],"t":43,"s":[0,0,100],"e":[30,30,100]},{"t":55.0000022401959}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[26,26],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.266666636747,0.456470654058,1],"ix":3},"o":{"a":0,"k":0,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":1,"lj":1,"ml":4,"ml2":{"a":0,"k":4,"ix":8},"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0,0.282353001015,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-144,-8],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":56.0000022809268,"st":0,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Pre-comp 3","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":31,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[203,49,0],"ix":2},"a":{"a":0,"k":[250,250,0],"ix":1},"s":{"a":0,"k":[100,-100,100],"ix":6}},"ao":0,"w":500,"h":500,"ip":0,"op":194.000007901782,"st":-25.0000010182709,"bm":0},{"ddd":0,"ind":2,"ty":0,"nm":"Pre-comp 3","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[157,58,0],"ix":2},"a":{"a":0,"k":[250,250,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":500,"h":500,"ip":0,"op":194.000007901782,"st":-25.0000010182709,"bm":0}],"markers":[]}
把它保存为 loding.json
放到项目的 /res/raw
目录下备用。
先加上动画
为了使用 lottie
,我们需要加上依赖: implementation("com.airbnb.android:lottie-compose:6.0.0")
。
然后,我们就可以非常方便的播放这个动画了:
kotlin
@Composable
fun AnimationIndicator(
swipeRefreshState: SwipeRefreshState,
refreshTriggerDistance: Dp,
) {
val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.loading))
val animationProgress by animateLottieCompositionAsState(composition, iterations = LottieConstants.IterateForever, isPlaying = isPlaying)
LottieAnimation(
composition = composition,
progress = {
animationProgress
}
)
}
我们将这个函数添加到 SwipeRefresh
的 indicator
参数中:
kotlin
SwipeRefresh(
indicator = { state, trigger ->
AnimationIndicator(state, trigger)
}
)
此时,这个动画就会在列表上方无限循环播放,而不管当前下拉状态或刷新状态,这是因为我们压根没有关联当前的下拉状态:
我们可以通过 indicator
这个匿名函数中附带的两个参数获取到当前的下拉状态,其中:
swipeRefreshState
其实就是 SwipeRefreshState
它包括了:当前是否正在刷新(swipeRefreshState.isRefreshing
)、指示器偏移位置(swipeRefreshState.indicatorOffset
)、当前是否正在被滑动(swipeRefreshState.isSwipeInProgress
) 三个状态。
refreshTrigger
表示下拉到多长的距离后触发刷新。
有了这些参数,我们很容易就能给动画加上联动刷新状态,只有在触发刷新时才播放,其他时候不播放。
为此,我们可以给 animationProgress
添加上一个参数 isPlaying
,然后通过 swipeRefreshState.isRefreshing
更改这个值即可:
diff
@Composable
fun AnimationIndicator(
swipeRefreshState: SwipeRefreshState,
refreshTriggerDistance: Dp,
) {
+ var isPlaying by remember { mutableStateOf(false) }
val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.loading))
+ val animationProgress by animateLottieCompositionAsState(composition, iterations = LottieConstants.IterateForever, isPlaying = isPlaying)
+ if (swipeRefreshState.isRefreshing) {
+ isPlaying = true
+ }
+ else {
+ isPlaying = false
+ }
LottieAnimation(
composition = composition,
progress = {
animationProgress
}
)
}
此时,这个动画只有在我们下拉触发刷新时才会播放,其它时候都不会播放:
当然,我们也仅仅是加了联动控制是否播放,此时无论是否触发刷新,这个动画都是一直存在的,而且它也还没有联动下拉位置偏移,所以压根看不出是否下拉了。
因此,我们下一步就是加上下拉位置偏移。
让内容按照下拉位置联动偏移
为了让动画能够跟着跟随我们的滑动一起偏移,我们需要用到 refreshTriggerDistance
和 swipeRefreshState.indicatorOffset
两个参数。
首先依旧是定义一个 var animationTopOffset by remember { mutableStateOf(-refreshTriggerDistance) }
用于表示偏移动画的位置。
然后分别在触发刷新时和未触发刷新时更新这个值:
kotlin
if (swipeRefreshState.isRefreshing) {
// ......
animationTopOffset = 0.dp
}
else {
// ......
animationTopOffset = with(LocalDensity.current) {
(swipeRefreshState.indicatorOffset.toDp() - refreshTriggerDistance).coerceAtMost(0.dp)
}
}
在刷新时设置为 0 ,表示不偏移,即完整的显示出来这个动画。
在没有刷新时按照下拉的距离 swipeRefreshState.indicatorOffset
减去触发刷新的距离。举个例子,如果触发刷新的距离为 80 dp,下拉了 1 dp,则动画需要在 Y 轴偏移 -79 dp,即向上移动 79 dp,并且随着下拉距离增加,逐渐向下偏移直至完全显示。
另外,需要注意 swipeRefreshState.indicatorOffset
返回值的单位是 px,而我们使用时需要的是 dp,所以需要做一下单位换算,在 LocalDensity.current
作用域中直接提供了 px.toDp()
扩展方法。
最后,将计算出来的偏移值加到 LottieAnimation
的 modifier
中:
kotlin
LottieAnimation(
// ......
modifier = Modifier
.height(refreshTriggerDistance)
.offset(y = animationTopOffset)
)
对了,在上面的代码中,为了确保动画组件尺寸能够在触发刷新时恰好完全显示,我们还需要把 LottieAnimation
的高度设置为 refreshTriggerDistance
。
此时运行效果:
可以看到动画已经能够完美的联动我们的下拉位置跟随偏移了,但是此时列表还不会联动一起下移,看起来不太美观,所以下一步就是给它加上。
在 SwipeRefresh
所在的函数中,添加:
kotlin
// ......
var contentYOffsetTarget by remember { mutableStateOf(0.dp) }
if (refreshState.isRefreshing) {
contentYOffsetTarget = refreshTriggerDistance
}
else {
with(LocalDensity.current) {
contentYOffsetTarget = refreshState.indicatorOffset.toDp().coerceAtMost(refreshTriggerDistance + SPACE.dp)
}
}
// ......
SwipeRefresh(
// ......
) {
LazyColumn(modifier = Modifier.offset(y = contentYOffsetTarget)) {
// ......
}
}
与为动画添加不同的是,这里的初始偏移值是 0,当正在刷新时的偏移值是 refreshTriggerDistance
,没有在刷新时则跟随滑动距离逐渐趋向 refreshTriggerDistance
,此时运行效果:
接下来,就是给它添加亿点小细节。
细节完善
首先,在动画组件逐渐从上往下出现时,应该根据下拉进度,更改透明度,将其由完全透明逐渐趋向于不透明,即完全显示。
而下拉进度可以通过当前下拉距离除以触发刷新距离得到:
kotlin
val trigger = with(LocalDensity.current) { refreshTriggerDistance.toPx() }
val totalProgress = (swipeRefreshState.indicatorOffset / trigger).coerceIn(0f, 1f)
因为下拉距离是可以超出刷新距离的,所以需要限制进度范围在 0 - 1 之间。
之后,定义一个状态 var animationAlpha by remember { mutableStateOf(1f) }
用于表示动画组件的透明度。
然后根据刷新状态更改它的值:
kotlin
if (swipeRefreshState.isRefreshing) {
// ......
animationAlpha = 1f
}
else {
// ......
animationAlpha = totalProgress
}
最后,给 LottieAnimation
加上 alpha
:
diff
LottieAnimation(
// ......
modifier = Modifier
.height(refreshTriggerDistance)
+ .alpha(animationAlpha)
.offset(y = animationTopOffset)
)
运行效果:
接下来,我们完善一下刷新完成列表返回顶部时的动画。
不知道你们发现没有,截至目前,如果刷新完成后,列表回到顶部没有添加过渡效果或动画,导致列表是直接突然出现在顶部的,这显然不够优雅,所以我们给它加上一个刷新完成时返回顶部的动画。
首先增加一个动画状态:
kotlin
val contentYOffset by animateDpAsState(
targetValue = contentYOffsetTarget,
label = "contentYOffset",
animationSpec = tween(
durationMillis = contentYOffsetDuration
)
)
然后将 LazyColumn
的偏移值由 contentYOffsetTarget
改为使用 contentYOffset
。
此时,刷新完成后列表返回顶部不再是突然出现,而是有一个动画滑动缓动效果。
但是,出现了一个新的问题,下拉进行时列表有点 "不跟手" 了,即我们滑动时,列表联动滑动总是会稍微慢半拍。
不过这不是 BUG,只是因为我们把偏移值改为了使用动画状态,这就导致每次偏移值改变时都会添加动画过渡。但是显然我们只是想在刷新完成时添加过渡效果而已。
因此,这里我们改一下偏移动画状态:
diff
val contentYOffset by animateDpAsState(
targetValue = contentYOffsetTarget,
label = "contentYOffset",
+ animationSpec = tween(
+ durationMillis = contentYOffsetDuration
+ )
)
我们为这个动画状态添加了自定义的动画效果 tween
, tween
可以设置动画的持续时间,只要我们在不需要动画的地方将持续时间改成 0 就相当于没有动画了。
定义动画持续时间: var contentYOffsetDuration by remember { mutableStateOf(0) }
。
然后在相应位置更改该时间:
kotlin
if (refreshState.isRefreshing) {
// ......
}
else {
if (refreshState.isSwipeInProgress) {
// ......
contentYOffsetDuration = 0
}
else {
// ......
contentYOffsetDuration = 300
}
}
此时效果:
最后还有一个小细节,我们希望能在下拉时动画也能随着我们的下拉进度同步播放。
这个非常好实现,因为下拉进度我们已经有了,即 totalProgress
。
而 LottieAnimation
也允许我们自定义播放进度,所以我们只需要判断当前是否正在刷新,如果正在刷新则使用 animationProgress
自动循环播放,否则就使用 totalProgress
手动控制播放:
diff
LottieAnimation(
composition = composition,
progress = {
+ if (swipeRefreshState.isRefreshing) {
+ animationProgress
+ }
+ else {
+ totalProgress
+ }
},
modifier = Modifier
.height(refreshTriggerDistance - SPACE.dp)
.alpha(animationAlpha)
)
增加提示文字
最后,我们在动画组件下方添加一行提示文字,如果你没有跳过上面内容的话,添加文字对你来说不过是小菜一碟。
不过是在动画下面加上文字,然后根据当前状态更改文字内容而已嘛:
diff
+ var tipText by remember { mutableStateOf("") }
// ......
if (swipeRefreshState.isRefreshing) {
// ......
+ tipText = "正在加载中......"
}
else {
// ......
+ tipText = if (swipeRefreshState.indicatorOffset < trigger) {
+ "继续下拉以刷新"
+ } else {
+ "松手立即刷新"
+ }
}
LottieAnimation(
composition = composition,
progress = {
animationProgress
},
modifier = Modifier
.height(refreshTriggerDistance)
.alpha(animationAlpha)
.offset(y = animationTopOffset)
)
+ Text(text = tipText)
真的吗?哈哈哈,如果直接加上去的话你会发现文字和动画叠在一起了:
显然这里需要加一个 Column
作为它俩的父容器,并且相应的位置偏移等也需要改变,这里就不一一列举了,直接放完整代码。
完整代码
kotlin
const val SPACE = 20
@Suppress("DEPRECATION")
@Composable
fun Sample() {
val refreshTriggerDistance = 180.dp
var refreshing by remember { mutableStateOf(false) }
val refreshState = rememberSwipeRefreshState(isRefreshing = refreshing)
var contentYOffsetDuration by remember { mutableStateOf(0) }
var contentYOffsetTarget by remember { mutableStateOf(0.dp) }
val contentYOffset by animateDpAsState(
targetValue = contentYOffsetTarget,
label = "contentYOffset",
animationSpec = tween(
durationMillis = contentYOffsetDuration
)
)
LaunchedEffect(refreshing) {
if (refreshing) {
delay(2000)
refreshing = false
}
}
SwipeRefresh(
state = refreshState,
onRefresh = { refreshing = true },
indicator = { state, trigger ->
AnimationIndicator(
swipeRefreshState = state,
refreshTriggerDistance = trigger
)
},
refreshTriggerDistance = refreshTriggerDistance
) {
if (refreshState.isRefreshing) {
contentYOffsetTarget = refreshTriggerDistance + SPACE.dp
}
else {
if (refreshState.isSwipeInProgress) {
with(LocalDensity.current) {
contentYOffsetTarget = refreshState.indicatorOffset.toDp().coerceAtMost(refreshTriggerDistance + SPACE.dp)
}
contentYOffsetDuration = 0
}
else {
contentYOffsetTarget = 0.dp
contentYOffsetDuration = 300
}
}
LazyColumn(
modifier = Modifier.offset(y = contentYOffset)
) {
items(30) {
Row(Modifier.padding(16.dp)) {
Text(
text = "Text $it",
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
)
}
}
}
}
}
@Composable
fun AnimationIndicator(
swipeRefreshState: SwipeRefreshState,
refreshTriggerDistance: Dp,
) {
var tipText by remember { mutableStateOf("") }
val trigger = with(LocalDensity.current) { refreshTriggerDistance.toPx() }
val totalProgress = (swipeRefreshState.indicatorOffset / (trigger + with(LocalDensity.current) { SPACE.dp.toPx() * 3 })).coerceIn(0f, 1f)
var animationAlpha by remember { mutableStateOf(1f) }
var isPlaying by remember { mutableStateOf(false) }
val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.loading))
val animationProgress by animateLottieCompositionAsState(composition, iterations = LottieConstants.IterateForever, isPlaying = isPlaying)
var animationTopOffset by remember { mutableStateOf(-refreshTriggerDistance) }
if (swipeRefreshState.isRefreshing) {
tipText = "正在加载中......"
animationTopOffset = 0.dp
isPlaying = true
animationAlpha = 1f
}
else {
animationTopOffset = with(LocalDensity.current) {
(swipeRefreshState.indicatorOffset.toDp() - refreshTriggerDistance).coerceAtMost(0.dp)
}
isPlaying = false
animationAlpha = totalProgress // FastOutSlowInEasing.transform(totalProgress)
tipText = if (swipeRefreshState.indicatorOffset < trigger) {
"继续下拉以刷新"
} else {
"松手立即刷新"
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.height(refreshTriggerDistance)
.offset(y = animationTopOffset),
horizontalAlignment = Alignment.CenterHorizontally
) {
LottieAnimation(
composition = composition,
progress = {
if (swipeRefreshState.isRefreshing) {
animationProgress
}
else {
totalProgress
}
},
modifier = Modifier
.height(refreshTriggerDistance - SPACE.dp)
.alpha(animationAlpha)
)
Text(text = tipText)
}
}
总结
在上述内容中我们以 accompanist-swiperefresh
为例,讲解了如何给下拉刷新组件添加自定义的指示器。
可以看出,自定义下拉指示器有很大的自由度,能够使用的参数也很多,几乎可以满足我们的所有需求,能不能把指示器玩出花来完全取决于我们的想象力。
当然,本文只说了如何为 accompanist-swiperefresh
的下拉刷新添加自定义指示器,而没有说如何为 material
的 Modifier.pullRefresh
添加指示器,本来我原计划是打算两个一起讲的,但是看了一下, material
的下拉刷新少了几个参数,对于编写自定义指示器有点不太方便,所以我就没有继续尝试了,感兴趣的可以自己试试。