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

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

使用 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)
                        )
                    }
                }
            }
        }
    }

加一个怎样的指示器?

先看最终实现效果:

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

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

其中的加载动画,我们就不自己实现了(当然你想自己实现也不是不行),我们直接使用 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
            }
        )
    }

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

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

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

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

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

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

为了让动画能够跟着跟随我们的滑动一起偏移,我们需要用到 refreshTriggerDistanceswipeRefreshState.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() 扩展方法。

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

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

我们为这个动画状态添加了自定义的动画效果 tweentween 可以设置动画的持续时间,只要我们在不需要动画的地方将持续时间改成 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 的下拉刷新添加自定义指示器,而没有说如何为 materialModifier.pullRefresh 添加指示器,本来我原计划是打算两个一起讲的,但是看了一下, material 的下拉刷新少了几个参数,对于编写自定义指示器有点不太方便,所以我就没有继续尝试了,感兴趣的可以自己试试。

相关推荐
Yawesh_best14 分钟前
MySQL(5)【数据类型 —— 字符串类型】
android·mysql·adb
鸽鸽程序猿20 分钟前
【算法】【优选算法】二分查找算法(下)
java·算法·二分查找算法
遇见你真好。36 分钟前
自定义注解进行数据脱敏
java·springboot
NMBG2239 分钟前
[JAVAEE] 面试题(四) - 多线程下使用ArrayList涉及到的线程安全问题及解决
java·开发语言·面试·java-ee·intellij-idea
像污秽一样1 小时前
Spring MVC初探
java·spring·mvc
计算机-秋大田1 小时前
基于微信小程序的乡村研学游平台设计与实现,LW+源码+讲解
java·spring boot·微信小程序·小程序·vue
LuckyLay1 小时前
Spring学习笔记_36——@RequestMapping
java·spring boot·笔记·spring·mapping
醉颜凉2 小时前
【NOIP提高组】潜伏者
java·c语言·开发语言·c++·算法
阿维的博客日记2 小时前
java八股-jvm入门-程序计数器,堆,元空间,虚拟机栈,本地方法栈,类加载器,双亲委派,类加载执行过程
java·jvm
qiyi.sky2 小时前
JavaWeb——Web入门(8/9)- Tomcat:基本使用(下载与安装、目录结构介绍、启动与关闭、可能出现的问题及解决方案、总结)
java·前端·笔记·学习·tomcat