Compose 实战之为下拉刷新添加自定义指示器

前言

在安卓开发中,下拉刷新是一个非常常用的功能,几乎只要是涉及到列表展示数据的界面都会用到它。

而 Compose 却直到 2022年10月份才在 compose.material:1.3.0 中添加了对下拉刷新的支持:Modifier.pullRefresh

在此之前,我们只能使用 accompanist-swiperefresh 来实现下拉刷新。

然而,更坑的是,Compose 对下拉刷新的支持是添加到 material 中的,而现在谷歌主推的却是 material3 ,你猜怎么着,诶,material3 不支持下拉刷新。并且由于 material 添加了 Modifier.pullRefreshaccompanist-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-swiperefreshSwipeRefresh 为例子,但是其实 materialModifier.pullRefreshaccompanist-swiperefreshSwipeRefresh 都是差不多的,稍微改一下就可以通用了。

正文

一个简单的下拉刷新 demo

以下为前言中两种不同的实现方式的运行效果,它们的运行效果其实都是一样的:

1.gif

使用 Modifier.pullRefresh(refreshState) 实现:

    @Composablefun 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 实现的:

    @Composablefun 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))}}}}}

加一个怎样的指示器?

先看最终实现效果:

2.gif

接下来我们拆解一下这个下拉刷新指示器都有些啥。

  1. 最显而易见的,指示器主体是一个动画
  2. 下拉时动画主体随着下拉进度逐渐往下显现(位移和透明度)
  3. 下拉时列表内容跟随下拉进度下移
  4. 下拉时动画随着下拉或收回进度播放或倒退
  5. 下拉触发刷新时动画自动播放
  6. 动画下方有一行文字指示当前下拉状态(下拉中、下拉完成、正在刷新)

其中的加载动画,我们就不自己实现了(当然你想自己实现也不是不行),我们直接使用 lottie ,这是我随便找的一个 lottie 的加载动画:

{"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")

然后,我们就可以非常方便的播放这个动画了:

    @Composablefun 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})}

我们将这个函数添加到 SwipeRefreshindicator 参数中:

SwipeRefresh(indicator = { state, trigger ->AnimationIndicator(state, trigger)}
)

此时,这个动画就会在列表上方无限循环播放,而不管当前下拉状态或刷新状态,这是因为我们压根没有关联当前的下拉状态:

3.png

我们可以通过 indicator 这个匿名函数中附带的两个参数获取到当前的下拉状态,其中:

swipeRefreshState 其实就是 SwipeRefreshState 它包括了:当前是否正在刷新(swipeRefreshState.isRefreshing)、指示器偏移位置(swipeRefreshState.indicatorOffset)、当前是否正在被滑动(swipeRefreshState.isSwipeInProgress) 三个状态。

refreshTrigger 表示下拉到多长的距离后触发刷新。

有了这些参数,我们很容易就能给动画加上联动刷新状态,只有在触发刷新时才播放,其他时候不播放。

为此,我们可以给 animationProgress 添加上一个参数 isPlaying ,然后通过 swipeRefreshState.isRefreshing 更改这个值即可:

    @Composablefun 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})}

此时,这个动画只有在我们下拉触发刷新时才会播放,其它时候都不会播放:

4.gif

当然,我们也仅仅是加了联动控制是否播放,此时无论是否触发刷新,这个动画都是一直存在的,而且它也还没有联动下拉位置偏移,所以压根看不出是否下拉了。

因此,我们下一步就是加上下拉位置偏移。

让内容按照下拉位置联动偏移

为了让动画能够跟着跟随我们的滑动一起偏移,我们需要用到 refreshTriggerDistanceswipeRefreshState.indicatorOffset 两个参数。

首先依旧是定义一个 var animationTopOffset by remember { mutableStateOf(-refreshTriggerDistance) } 用于表示偏移动画的位置。

然后分别在触发刷新时和未触发刷新时更新这个值:

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() 扩展方法。

最后,将计算出来的偏移值加到 LottieAnimationmodifier 中:

LottieAnimation(// ……modifier = Modifier.height(refreshTriggerDistance).offset(y = animationTopOffset)
)

对了,在上面的代码中,为了确保动画组件尺寸能够在触发刷新时恰好完全显示,我们还需要把 LottieAnimation 的高度设置为 refreshTriggerDistance

此时运行效果:

5.gif

可以看到动画已经能够完美的联动我们的下拉位置跟随偏移了,但是此时列表还不会联动一起下移,看起来不太美观,所以下一步就是给它加上。

SwipeRefresh 所在的函数中,添加:

// ……
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,此时运行效果:

6.gif

接下来,就是给它添加亿点小细节。

细节完善

首先,在动画组件逐渐从上往下出现时,应该根据下拉进度,更改透明度,将其由完全透明逐渐趋向于不透明,即完全显示。

而下拉进度可以通过当前下拉距离除以触发刷新距离得到:

val trigger = with(LocalDensity.current) { refreshTriggerDistance.toPx() }
val totalProgress = (swipeRefreshState.indicatorOffset / trigger).coerceIn(0f, 1f)

因为下拉距离是可以超出刷新距离的,所以需要限制进度范围在 0 - 1 之间。

之后,定义一个状态 var animationAlpha by remember { mutableStateOf(1f) } 用于表示动画组件的透明度。

然后根据刷新状态更改它的值:

if (swipeRefreshState.isRefreshing) {// ……animationAlpha = 1f
}
else {// ……animationAlpha = totalProgress
}

最后,给 LottieAnimation 加上 alpha

LottieAnimation(// ……modifier = Modifier.height(refreshTriggerDistance)
+        .alpha(animationAlpha).offset(y = animationTopOffset)
)

运行效果:

7.gif

接下来,我们完善一下刷新完成列表返回顶部时的动画。

不知道你们发现没有,截至目前,如果刷新完成后,列表回到顶部没有添加过渡效果或动画,导致列表是直接突然出现在顶部的,这显然不够优雅,所以我们给它加上一个刷新完成时返回顶部的动画。

首先增加一个动画状态:

val contentYOffset by animateDpAsState(targetValue = contentYOffsetTarget,label = "contentYOffset",animationSpec = tween(durationMillis = contentYOffsetDuration)
)

然后将 LazyColumn 的偏移值由 contentYOffsetTarget 改为使用 contentYOffset

此时,刷新完成后列表返回顶部不再是突然出现,而是有一个动画滑动缓动效果。

但是,出现了一个新的问题,下拉进行时列表有点 “不跟手” 了,即我们滑动时,列表联动滑动总是会稍微慢半拍。

不过这不是 BUG,只是因为我们把偏移值改为了使用动画状态,这就导致每次偏移值改变时都会添加动画过渡。但是显然我们只是想在刷新完成时添加过渡效果而已。

因此,这里我们改一下偏移动画状态:

val contentYOffset by animateDpAsState(targetValue = contentYOffsetTarget,label = "contentYOffset",
+    animationSpec = tween(
+        durationMillis = contentYOffsetDuration
+    )
)

我们为这个动画状态添加了自定义的动画效果 tweentween 可以设置动画的持续时间,只要我们在不需要动画的地方将持续时间改成 0 就相当于没有动画了。

定义动画持续时间: var contentYOffsetDuration by remember { mutableStateOf(0) }

然后在相应位置更改该时间:

if (refreshState.isRefreshing) {// ……
}
else {if (refreshState.isSwipeInProgress) {// ……contentYOffsetDuration = 0}else {// ……contentYOffsetDuration = 300}
}

此时效果:

8.gif

最后还有一个小细节,我们希望能在下拉时动画也能随着我们的下拉进度同步播放。

这个非常好实现,因为下拉进度我们已经有了,即 totalProgress

LottieAnimation 也允许我们自定义播放进度,所以我们只需要判断当前是否正在刷新,如果正在刷新则使用 animationProgress 自动循环播放,否则就使用 totalProgress 手动控制播放:

LottieAnimation(composition = composition,progress = {
+        if (swipeRefreshState.isRefreshing) {
+            animationProgress
+        }
+        else {
+            totalProgress
+        }},modifier = Modifier.height(refreshTriggerDistance - SPACE.dp).alpha(animationAlpha)
)

增加提示文字

最后,我们在动画组件下方添加一行提示文字,如果你没有跳过上面内容的话,添加文字对你来说不过是小菜一碟。

不过是在动画下面加上文字,然后根据当前状态更改文字内容而已嘛:

+ 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)

真的吗?哈哈哈,如果直接加上去的话你会发现文字和动画叠在一起了:

9.png

显然这里需要加一个 Column 作为它俩的父容器,并且相应的位置偏移等也需要改变,这里就不一一列举了,直接放完整代码。

完整代码

const val SPACE = 20@Suppress("DEPRECATION")
@Composable
fun Sample() {val refreshTriggerDistance = 180.dpvar 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.dpcontentYOffsetDuration = 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.dpisPlaying = trueanimationAlpha = 1f}else {animationTopOffset = with(LocalDensity.current) {(swipeRefreshState.indicatorOffset.toDp() - refreshTriggerDistance).coerceAtMost(0.dp)}isPlaying = falseanimationAlpha = 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 的下拉刷新添加自定义指示器,而没有说如何为 materialModifier.pullRefresh 添加指示器,本来我原计划是打算两个一起讲的,但是看了一下, material 的下拉刷新少了几个参数,对于编写自定义指示器有点不太方便,所以我就没有继续尝试了,感兴趣的可以自己试试。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/161699.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

RTSP协议

1 前言 RTSP协议作为音视频实时监控一个非常重要的协议&#xff0c;具有非常广泛的应用。RTSP由RFC 2326规范化&#xff0c;它允许客户端通过请求不同的媒体资源来控制流媒体服务器。RTSP是一种应用层协议&#xff0c;通常基于TCP连接&#xff0c;用于建立和控制媒体会话。这使…

pyqt5-tools的安装(深度学习)

本篇主要解决上篇深度学习pyqt安装失败的问题 PyQt是一个创建GUI应用程序的工具包。它是Python编程语言和Qt库的成功融合。Qt库是最强大的库之一。PyQt是由Phil Thompson 开发。在使用labelimg对图片做标签时&#xff0c;需要用到pyqt5-tools工具&#xff0c;尝试以下下载方式&…

10数据库-基础

四、数据库 15、MySQL 数据库优化 SQL优化 mysql优化 一、避免不走索引的场景尽量避免在字段开头模糊查询&#xff0c;会导致数据库引擎放弃索引进行全表扫描。尽量避免使用not in&#xff0c;会导致引擎走全表扫描。尽量避免使用 or&#xff0c;会导致数据库引擎放弃索引进行…

YUV图片常见格式

YUV图像 1个亮度量Y2个色度量(UV) 兼容黑白电视 可以通过降低色度的采样率而不会对图像质量影响太大的操作&#xff0c;降低视频传输带宽 有很多格式&#xff0c;所以渲染的时候一定要写对&#xff0c;不然会有很多问题&#xff0c;比如花屏、绿屏 打包格式 一个像素点一…

77.每日一练:迭代器遍历容器(牛客)

目录 问题描述&#xff1a; 代码解决以及思想 知识点 问题描述&#xff1a; 代码解决以及思想 #include <iostream> // write your code here...... #include <vector>using namespace std;int main() {// write your code here......vector<int> v;for (…

怎么使用动态代理IP提升网络安全,动态代理IP有哪些好处呢

随着互联网的普及和数字化时代的到来&#xff0c;网络安全问题越来越受到人们的关注。动态代理IP作为网络安全中的一种技术手段&#xff0c;被越来越多的人所采用。本文将介绍动态代理IP的概念、优势以及如何应用它来提升网络安全。 一、动态代理IP的概念 动态代理IP是指使用代…

六、DHCP实验

拓扑图&#xff1a; DHCP协议&#xff0c;给定一个ip范围使其自动给终端分配IP&#xff0c;提高了IP分配的效率 首先对PC设备选择DHCP分配ip 首先先对路由器的下端配置网关的ip 创建地址池&#xff0c;通过globle的方式实现DHCP ip pool 地址池名称 之后设置地址池的网关地址…

【马蹄集】—— 概率论专题

概率论专题 目录 MT2226 抽奖概率MT2227 饿饿&#xff01;饭饭&#xff01;MT2228 甜甜花的研究MT2229 赌石MT2230 square MT2226 抽奖概率 难度&#xff1a;黄金    时间限制&#xff1a;1秒    占用内存&#xff1a;128M 题目描述 小码哥正在进行抽奖&#xff0c;箱子里有…

抓包工具charles修改请求和返回数据

数据篡改的主要使用场景&#xff1a; &#xff08;1&#xff09;mock场景&#xff0c;mock入参和返回值参数&#xff0c;实现mock测试 &#xff08;2&#xff09;安全测试&#xff0c;对于支付金额等比较重要的字段&#xff0c;可以修改请求参数来进行安全测试 1.首先选择要…

信息系统项目管理师第四版学习笔记——组织通用治理

组织战略 组织战略是组织高质量发展的总体谋略&#xff0c;是组织相关干系方就其发展达成一致认识的重要基础。组织战略是指组织针对其发展进行的全局性、长远性、纲领性目标的策划和选择。 战略目标是组织在一定的战略期内总体发展的总水平和总任务。它决定了组织在该战略期…

程序员,今天你被“投毒”了么?

点击文末“阅读原文”即可参与节目互动 剪辑、音频 / 卷圈 运营 / SandLiu 卷圈 监制 / 姝琦 产品统筹 / bobo 联合制作 / RTE开发者社区 录音间 / 声湃轩北京站 近日&#xff0c;安恒信息 CERT 监测到一起 LNMP 遭受供应链投毒攻击事件。我们发现&#xff0c;在 lnmp.org …

Linux小程序---进度条

一&#xff1a;\r 和 \n \r --- 回车 --- 使光标回到这一行的开头 \n --- 换行 --- 会来到下一行与之平行的位置 缓冲区的问题&#xff1a; <1>: \n 的示例 正常输出 hehehehe 。 <2>: \r 的示例 为了方便观察&#xff0c;加入一个 sleep &#xff08;休眠函数…

IDEA Gradle Lombok错误:找不到符号 setter getter方法没有

今天IDEA2022.3.3开个Gradle&#xff08;7.5版本&#xff09;项目&#xff0c;加入了Lombok依赖&#xff0c;依赖如下&#xff1a; dependencies {implementation group: org.springframework.boot, name: spring-boot-starter-web, version: 3.1.4compileOnly group: org.pro…

Druid连接池最小连接数设置失效问题

问题发现&#xff1a; 配置 当项目启动后 线程池确实是初始化了5条连接&#xff0c;但是当项目运行一段时间后&#xff0c;5条连接确消失了&#xff0c;只会程序用到得时候&#xff0c;再去初始化连接&#xff0c;这样有点违背了参数设置得意义&#xff0c;后来通过查阅资料发…

本地jar打包成maven依赖,上传到私服

本地打包jar成maven依赖 mvn install:install-file -Dfile“\oss\xmlBeans\rvdMsgWrapper.jar” -DgroupId“hk.gov.xmlBeans” -DartifactId“noNamespace” -Dversion“1.0.0” -Dpackaging“jar” 上传到私服 登录进入到Upload 页面 上传 上传完成&#xff0c;到仓库查看…

图像特征之SIFT

SIFT介绍 尺度不变特征转换即SIFT (Scale-invariant feature transform)是一种计算机视觉的算法。它用来侦测与描述影像中的局部性特征&#xff0c;它在空间尺度中寻找极值点&#xff0c;并提取出其位置、尺度、旋转不变量&#xff0c;此算法由 David Lowe在1999年所发表&#…

网络层哪些事?

在本文讲解的网络层中&#xff0c;注意了解一下&#xff1a;IP协议&#xff01; 地址管理&#xff1a;每个网络上的设备&#xff0c;要能分配一个地址&#xff08;唯一&#xff09;路由选择&#xff1a;A给B发消息&#xff0c;具体走哪条路线&#xff1f;&#xff1f; IP地址&…

小学数学题AI自动出题系统源码,支持在线打印及导出PDF!

今天给大家开发了个好东西&#xff0c;小学数学作业练习册AI自动出题网站源码&#xff0c;全面支持打印机打印机转成PDF文件&#xff0c;快给你家娃娃整一套吧&#xff0c;AI自动出题&#xff0c;让娃练习算数&#xff0c;解放双手&#xff0c;让您的孩子成绩蒸蒸日上&#xff…

ESP32单片机环境搭建(VScode + PlatformIO IDE)

一、环境搭建&#xff08;VScode PlatformIO IDE&#xff09; 1、官网下载VScode; 2、安装最新的插件&#xff08;C/C、PlatformIO IDE、python、Chinese&#xff09;&#xff1b; 3、在PlatformIO IDE中新建工程&#xff1a;Platforms——Projects——Create New Project——…

成都爱尔周进院长解读不同近视手术的不同度数要求

常有人这么问&#xff1a;我xx度了还能做近视手术吗?我才xx度也能做近视手术吗? 度数&#xff0c;确实是自己掌握地最清楚“最直观”的一个数据。 要说屈光手术的指标&#xff0c;有角膜曲率、角膜厚度、角膜地形等非常之多。 但这些数据不做详细检查自己根本不知道&#xff…