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 的下拉刷新少了几个参数,对于编写自定义指示器有点不太方便,所以我就没有继续尝试了,感兴趣的可以自己试试。

相关推荐
EndingCoder6 分钟前
函数基础:参数和返回类型
linux·前端·ubuntu·typescript
码客前端11 分钟前
理解 Flex 布局中的 flex:1 与 min-width: 0 问题
前端·css·css3
Komorebi゛12 分钟前
【CSS】圆锥渐变流光效果边框样式实现
前端·css
qq_2562470521 分钟前
除了“温度”,如何用 Penalty (惩罚) 治好 AI 的“复读机”毛病?
后端
工藤学编程24 分钟前
零基础学AI大模型之CoT思维链和ReAct推理行动
前端·人工智能·react.js
徐同保25 分钟前
上传文件,在前端用 pdf.js 提取 上传的pdf文件中的图片
前端·javascript·pdf
怕浪猫26 分钟前
React从入门到出门第四章 组件通讯与全局状态管理
前端·javascript·react.js
内存不泄露32 分钟前
基于Spring Boot和Vue 3的智能心理健康咨询平台设计与实现
vue.js·spring boot·后端
qq_124987075332 分钟前
基于Spring Boot的电影票网上购票系统的设计与实现(源码+论文+部署+安装)
java·大数据·spring boot·后端·spring·毕业设计·计算机毕业设计
欧阳天风33 分钟前
用setTimeout代替setInterval
开发语言·前端·javascript