在Android上使用Jetpack Compose定制下拉刷新

news2024/11/15 16:29:52

在Android上使用Jetpack Compose定制下拉刷新

在Jetpack Compose中向LazyList添加下拉刷新非常简单。说真的,只需几行代码。然而,默认的外观和感觉并不是那么令人满意。我们希望做得更好一些,类似于iOS版本:当用户向下拉动列表时,移动列表并向用户提供反馈,告诉用户如果继续下拉,列表将要刷新,并显示上次刷新的时间。我们还希望增加默认的刷新阈值,因为在向上滚动时我们意外地刷新了页面。

幸运的是,使用Compose实现这一点相当容易。在本文中,我将尝试展示如何构建一个简化的演示应用程序,如下所示:

我准备了一个简单的演示样本。我将尝试以下逐步解释它,但如果你想直接跳转到最终代码,这是样本的链接。

因此,使用Compose的默认基本下拉刷新实现如下所示:我们有一个pullRefreshState,我们将其作为修饰符传递给容器,并且有一个与之同步的PullRefreshIndicator。

val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()
val pullToRefreshState = rememberPullToRefreshState(
    refreshing = isRefreshing,
    onRefresh = {
        viewModel.refresh()
    }
)

Box(
    modifier = Modifier
        .pullToRefresh(pullToRefreshState),
    contentAlignment = Alignment.Center
) {
    LazyColumn {..}

    PullRefreshIndicator(
        isRefreshing,
        pullToRefreshState,
    )
}

首先,让我们增加默认的阈值,使用该库非常容易做到。rememberPullRefreshState有一个名为refreshThreshold的参数:

val pullToRefreshState = rememberPullToRefreshState(
    refreshing = isRefreshing,
    refreshThreshold = 120.dp,
    onRefresh = {
        viewModel.refresh()
    }
)

现在用户需要下拉更多才能进行刷新。这将修复在向上滚动时不期望的刷新。

我们将不再使用库提供的默认PullRefreshIndicator,可以将其移除。相反,我们将在列表顶部显示一个指示器,当用户向下拉时会将内容向下推。如果你想一下,这可以是一个简单的可组合元素,放在列表的顶部,随着用户向下拉屏幕,其高度会增加。

Column(
    modifier = Modifier
        .pullToRefresh(pullToRefreshState),
) {
    MyCustomPullToRefreshIndicator()
    LazyColumn {..}
}

指示器默认高度为0。当用户向下拉屏幕时,我们将同时增加此可组合元素的高度,从而将列表向下推。为了观察用户拉动屏幕的程度,我们可以使用:

pullToRefreshState.progress

这是一个百分比的浮点数,从默认位置开始为0,达到阈值时为1,甚至可以超出。如果你想将其转换为高度,只需将其乘以100即可:

Column(
    modifier = Modifier
        .pullToRefresh(pullToRefreshState),
) {
    MyCustomPullToRefreshIndicator(
        modifier = Modifier
            .fillMaxWidth()
            .height((pullToRefreshState.progress * 100).roundToInt().dp)
    )
    LazyColumn {..}
}

这足以在用户下拉列表时将其向下推。你可以在指示器的位置放置任何你喜欢的内容。但是在这个演示中,我们将构建以下行为:

  • 当用户首次下拉时,显示"下拉刷新",同时显示最后刷新的时间。
  • 当用户达到阈值时,显示"释放以刷新"。
  • 在刷新过程中,显示"正在刷新"并显示加载图标。
  • 刷新完成后,指示器消失,列表返回到原始位置。

为了轻松区分这些状态,我们使用了一个枚举:

enum class RefreshIndicatorState(@StringRes val messageRes: Int) {
    Default(R.string.pull_to_refresh_complete_label),
    PullingDown(R.string.pull_to_refresh_pull_label),
    ReachedThreshold(R.string.pull_to_refresh_release_label),
    Refreshing(R.string.pull_to_refresh_refreshing_label)
}

将所有这些放在一起,我们的下拉刷新指示器看起来像这样:

private const val maxHeight = 100

@Composable
fun PullToRefreshIndicator(
    modifier: Modifier = Modifier,
    indicatorState: RefreshIndicatorState,
    pullToRefreshProgress: Float,
    timeElapsed: String,
) {
    val heightModifier = when (indicatorState) {
        RefreshIndicatorState.PullingDown -> {
            Modifier.height(
                (pullToRefreshProgress * 100)
                    .roundToInt()
                    .coerceAtMost(maxHeight).dp,
            )
        }
        RefreshIndicatorState.ReachedThreshold -> Modifier.height(maxHeight.dp)
        RefreshIndicatorState.Refreshing -> Modifier.wrapContentHeight()
        RefreshIndicatorState.Default -> Modifier.height(0.dp)
    }
    Box(
        modifier = modifier
            .fillMaxWidth()
            .animateContentSize()
            .then(heightModifier)
            .padding(15.dp),
        contentAlignment = Alignment.BottomStart,
    ) {
        Column(
            modifier = Modifier.fillMaxWidth(),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(4.dp),
        ) {
            Text(
                text = stringResource(indicatorState.messageRes),
                style = MaterialTheme.typography.labelMedium,
                color = Color.Black,
            )
            if (indicatorState == RefreshIndicatorState.Refreshing) {
                CircularProgressIndicator(
                    modifier = Modifier.size(16.dp),
                    color = Color.Black,
                    trackColor = Color.Gray,
                    strokeWidth = 2.dp,
                )
            } else {
                Text(
                    text = stringResource(R.string.last_updated, timeElapsed),
                    style = MaterialTheme.typography.labelSmall,
                    color = Color.Black,
                )
            }
        }
    }
}

因此,当用户下拉时,我们同时应用动态高度。我倾向于将其限制在最大100dp。你可以根据自己的喜好进行调整。请注意animateContentSize()修饰符,它提供了这些状态之间的平滑过渡。

timeElapsed是自上次刷新屏幕以来经过的时间。您可以跟踪刷新时间,计算现在和上次刷新时间之间的时间,并将其转换为相应的文本。在本文中,我不会详细介绍这一点,但你可以在示例中看到一个实现的例子。

indicatorState是上面提到的四种状态之一:Default、Pulling、ReachedThreshold、Refreshing。如果我们观察到pullRefreshState.progress大于0,那意味着用户正在向下拉。如果进度达到1,那就意味着用户已经达到阈值。0表示默认状态,没有下拉。

当用户在达到阈值后松开手指,它将进入刷新状态。该库已经为此提供了回调。我们可以使用库的onRefresh回调来更新我们的指示器状态为Refreshing

val refreshIndicatorState by viewModel.refreshIndicatorState.collectAsState()

val pullToRefreshState = rememberPullRefreshState(
    refreshing = refreshIndicatorState == RefreshIndicatorState.Refreshing,
    refreshThreshold = 140.dp,
    onRefresh = {
        // will start fetching data and also will update indicator state
        viewModel.refresh()
    })

LaunchedEffect(pullToRefreshState.progress) {
     when {
        pullToRefreshState.progress >= 1 -> {
            viewModel.updateRefreshState(RefreshIndicatorState.ReachedThreshold)
        }

       pullToRefreshState.progress > 0 -> {
            viewModel.updateRefreshState(RefreshIndicatorState.PullingDown)
        }
    }
}

val timeElapsedSinceLastRefresh by viewModel.lastRefreshText.collectAsState()

Column(
        modifier = Modifier
            .pullRefresh(pullToRefreshState),
    ) {
        PullToRefreshIndicator(
            modifier = modifier,
            uiState = refreshIndicatorState,
            pullToRefreshProgress = pullToRefreshState.progress,
            timeElapsed = timeElapsedSinceLastRefresh
        )
        LazyColumn {..}
    }

使这个功能正常工作的最后一步是在刷新完成时将指示器状态更改回默认状态。否则它将永远显示刷新中。在哪里做这件事?这取决于你的情况。在这个示例中,我在viewmodel中完成了这个操作,当结果到达时(包括成功和错误的情况)。但在我们真实的应用程序中,我们使用Compose分页,它是在主组合内完成的。

你可以直接在你的viewmodel中保留与下拉刷新相关的状态和函数,比如refreshIndicatorStatelastRefreshTime。然而,在我的情况下,这相当啰嗨,因为我不得不在许多屏幕中实现相同的东西,所以我更喜欢创建一个可重用的组合并将相关数据包装在一个状态持有类中。

这是我们可重用的PullToRefreshLayout

@Composable
fun PullToRefreshLayout(
    modifier: Modifier = Modifier,
    pullRefreshLayoutState: PullToRefreshLayoutState,
    onRefresh: () -> Unit,
    content: @Composable () -> Unit,
) {
    val refreshIndicatorState by pullRefreshLayoutState.refreshIndicatorState
    val timeElapsedSinceLastRefresh by pullRefreshLayoutState.lastRefreshText

    val pullToRefreshState = rememberPullRefreshState(
        refreshing = refreshIndicatorState == RefreshIndicatorState.Refreshing,
        refreshThreshold = 120.dp,
        onRefresh = {
            onRefresh()
            pullRefreshLayoutState.refresh()
        },
    )

    LaunchedEffect(key1 = pullToRefreshState.progress) {
        when {
            pullToRefreshState.progress >= 1 -> {
                pullRefreshLayoutState.updateRefreshState(RefreshIndicatorState.ReachedThreshold)
            }

            pullToRefreshState.progress > 0 -> {
                pullRefreshLayoutState.updateRefreshState(RefreshIndicatorState.PullingDown)
            }
        }
    }

    Column(
        modifier = modifier
            .fillMaxSize()
            .pullRefresh(pullToRefreshState),
    ) {
        PullToRefreshIndicator(
            indicatorState = refreshIndicatorState,
            pullToRefreshProgress = pullToRefreshState.progress,
            timeElapsed = timeElapsedSinceLastRefresh,
        )
        Box(modifier = Modifier.weight(1f)) {
            content()
        }
    }
}

这是我们为此布局使用的状态持有类:

class PullToRefreshLayoutState(
    val onTimeUpdated: (Long) -> String,
) {

    private val _lastRefreshTime: MutableStateFlow<Long> = MutableStateFlow(System.currentTimeMillis())

    var refreshIndicatorState = mutableStateOf(RefreshIndicatorState.Default)
        private set

    var lastRefreshText = mutableStateOf("")
        private set

    fun updateRefreshState(refreshState: RefreshIndicatorState) {
        val now = System.currentTimeMillis()
        val timeElapsed = now - _lastRefreshTime.value
        lastRefreshText.value = onTimeUpdated(timeElapsed)
        refreshIndicatorState.value = refreshState
    }

    fun refresh() {
        _lastRefreshTime.value = System.currentTimeMillis()
        updateRefreshState(RefreshIndicatorState.Refreshing)
    }
}

@Composable
fun rememberPullToRefreshState(
    onTimeUpdated: (Long) -> String,
): PullToRefreshLayoutState =
    remember {
        PullToRefreshLayoutState(onTimeUpdated)
    }

通过上述所有步骤,当您想在一个屏幕上添加下拉刷新时,代码看起来像这样:

val pullToRefreshState = viewModel.pullToRefreshState

PullToRefreshLayout(
    modifier = Modifier.fillMaxSize(),
    pullRefreshLayoutState = pullToRefreshState,
    onRefresh = {
        viewModel.refresh()
    },
) {
    LazyColumn {}
}

额外功能 - 动画新项

如果在刷新列表时有新的项,您可以简单地通过为您的LazyLayout添加修饰符.animateItemPlacement()来使它们正确地进行动画。您还应该为您的项提供适当的ID,以使其正常工作。

额外功能 - 如果您有一个 UiState 呢?

如果您在同一个屏幕上还有一个带有加载、成功和错误状态的 uiState,那该怎么办呢?在这种情况下,刷新应该放在哪里?您可能会试图将刷新状态映射到 UiState.Loading,但您可能不希望在初始加载期间显示相同的刷新指示器。

如果您将刷新作为新的 UiState 添加进去呢?或者作为加载的子类型?您可能设法让它起作用,但请注意,如果您在这些状态之间切换您的组合,并且仅在成功情况下显示您的列表,那么在刷新期间您的列表将消失。这并不是我们在这种情况下想要的。我们希望列表保持在那里并向下移动。这就是为什么我更倾向于将刷新状态与我们已有的 ui state 分开。但是我仍然使用了一个变量来区分初始加载和刷新情况。这是因为数据层在获取开始时会发出一个加载状态,我不想将其映射到 UiState.Loading(以保持列表在那里)。

额外功能 - 使用 Compose Paging

如果您在同一个屏幕上还有Compose分页,那该怎么办呢?这就是我们的情况。所以您可能在 viewmodel 中有类似以下方式的分页流:

    val myItems = Pager(
            PagingConfig(pageSize = 20),
            pagingSourceFactory = {
                MyPagingSource(myUseCase)
            },
        ).flow.cachedIn(viewModelScope)

这是一个流,保存为一个变量。如何刷新它呢?我最初考虑将其作为一个返回 pager 流的函数,并在刷新时再次调用它,但是我会失去 cachedIn(viewModelScope) 部分,这对于保存分页状态和滚动位置非常重要。
我找到的解决办法是,将这个流从另一个在我想要刷新时改变的变量进行映射:

    private val _lastRefreshTime = pullToRefreshState.lastRefreshTime

    // 从 lastRefreshTime 进行映射的原因是为了强制执行刷新
    // 实际上,查询并不依赖于上次刷新时间。
    val myItems = _lastRefreshTime.flatMapLatest { _ ->
        Pager(
            PagingConfig(pageSize = 20),
            pagingSourceFactory = {
                MyPagingSource(myUseCase)
            },
        ).flow
    }.cachedIn(viewModelScope)

因此,当用户释放以进行刷新时,我们更新 lastRefreshTime,这个分页流就会被重新触发。(如果您的实现不关心上次刷新时间,您可以使用另一个变量。)

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

opencv差值法检测移动物体代码

void CrelaxMyFriendDlg::OnBnClickedOk() {hdc this->GetDC()->GetSafeHdc();// TODO: 在此添加控件通知处理程序代码string addrImg "c:/Users/actorsun/Pictures/";string addrVideo "c:/Users/actorsun/Videos/";string addr addrVideo &qu…

使用阿里云服务器学习Docker

首先我这里选择的系统服务器是CentOS 7.9 64位 因为centos系统里面的安装指令是&#xff1a;yum,而非apt-get. yum install docker -y试着建立一个容器&#xff1a; docker run -d -p 80:80 httpd启动docker的守护进程&#xff1a; sudo systemctl start docker 查看Docke…

基于springboot实现结合疫情情况的婚恋系统【项目源码】计算机毕业设计

基于springboot实现结合疫情情况的婚恋系统演示 SpringBoot框架 SpringBoot是一个全新开源的轻量级框架。基于Spring4.0设计&#xff0c;其不仅继承了Spring框架原来有的优秀特性&#xff0c;而且还通过简化配置文件来进一步简化了Spring应用的整个搭建以及开发过程。另外在原…

人机功能分配困难的原因之一

人机功能分配困难的原因之一是人类与机器都不能同时确定事实与价值的大小。事实是指客观存在的数据、信息和情况&#xff0c;而价值是指对这些事实的评判和喜好。人类和机器在确定功能分配时&#xff0c;都需要考虑到这两个方面。 首先&#xff0c;人类和机器在事实方面的认知能…

​ArcGIS Pro怎么生成山顶点

山顶点是指山脉、山丘或山脉系统中最高的地点&#xff0c;通常是山的最高峰&#xff0c;这是山地地貌中的最高点&#xff0c;往往是山脉的标志性特征之一&#xff0c;这里为大家介绍一下如何使用ArcGIS Pro获取山顶点&#xff0c;希望能对你有所帮助。 数据来源 本教程所使用…

axios1.5取消请求,中断请求的方法

给input的onchange绑定事件 引入axios,使用axios.CancelToken.source()创建标记 实例中,把cancelToken的值填上

Windows配置wxWidgets开发

1、编译 从官网下载wxWidgets源码,解压后进入build/msw目录,按自己安装的VS版本去选择sln打开,在VS的菜单拦找到【生成】菜单下的【批生成】菜单,点击进入, 点选【全选】然后点【生成】按钮。等上两、三个小时在项目目录的lib文件夹就可以看到生成的dll与lib目录,如下: …

【C语法学习】23 - strlen()函数

文章目录 1 函数原型2 参数3 返回值4 示例4.1 示例1 1 函数原型 strlen()&#xff1a;计算指针str所指向的字符串的长度&#xff0c;函数原型如下&#xff1a; size_t strlen(const char *str);2 参数 strlen()函数只有一个参数str&#xff1a; 参数str是指向待计算长度的字…

机器学习算法实战实战案例代码详解

文章目录 1.问题建模数据预处理 结果分析数据探索特征工程特征选择模型融合 1.问题建模 导入库 import numpy as np import pandas as pd from sklearn.model_selection import KFold from sklearn.metrics import mean_squared_error from sklearn.preprocessing import One…

包装类、自动装箱、自动拆箱是什么?有哪些注意点?

1、包装类 Java中的数据类型总体上分为基本数据类型和引用数据类型。引用类型的数据可以通过对象的属性和方法来进行操作&#xff0c;但对于基本数据类型的数据&#xff0c;我们能不能像操作对象那样来操作呢&#xff1f;为了实现这个目标&#xff0c;Java为8种基本数据类型分…

SMART PLC编码器转速测量功能块(高速计数器配置+梯形图)

SMART PLC高速计数器测量请查看下面文章链接: 西门子SMART PLC高速脉冲计数采集编码器速度(RC滤波)-CSDN博客文章浏览阅读2.5k次。这篇文章主要讲解西门子 SMART PLC高速计数采集编码器脉冲信号计算速度,根据编码器脉冲数计算速度的原理和注意事项在其它文章里已经有非常详细…

从替代走向引领,永洪科技迈向全球化

对于数据分析领域而言&#xff0c;这是一个最好的时代。 《全球数字经济白皮书&#xff08;2023年&#xff09;》介绍&#xff0c;2016年-2022年&#xff0c;中国数字经济年均复合增长率为14.2%&#xff0c;数字经济发展增速和规模兼具。随着数字基础实施持续夯实、数字应用不…

常见面试题-双亲委派模型

双亲委派模型 class 文件是通过 类加载器 装在到 JVM 中的&#xff0c;为了防止内存中存在多份同样的字节码&#xff0c;使用了双亲委派模型 双亲委派模型对于保证 Java 程序的稳定运行至关重要 双亲委派模型的原理为&#xff1a; &#xff08;1&#xff09;如果一个类加载…

淘宝店铺订单插旗接口(taobao.trade.memo.update淘宝店铺订单交易备注修改接口)

淘宝店铺订单插旗接口是指可以在淘宝店铺的订单系统中进行订单备注的接口。通过该接口&#xff0c;您可以根据用户的身份&#xff08;买家或卖家&#xff09;&#xff0c;添加相应的交易备注&#xff0c;用于区分不同订单类型等。 具体使用方法可以参考淘宝开放平台的API接口文…

欧洲大学专场黑客松,获奖者们在Sui上构建链上物品验证并改进荷兰拍卖

在由区块链学生协会&#xff08;Blockchain Student Association&#xff09;与Sui合作的黑客松上&#xff0c;获奖者们构建了旨在吸引新用户并弥合区块链技术与实物资产之间差距的项目。这些新项目展示了Sui的发展前景和灵活性&#xff0c;以及它如何解决现实世界的问题。 来…

深度学习 大数据 股票预测系统 - python lstm 计算机竞赛

文章目录 0 前言1 课题意义1.1 股票预测主流方法 2 什么是LSTM2.1 循环神经网络2.1 LSTM诞生 2 如何用LSTM做股票预测2.1 算法构建流程2.2 部分代码 3 实现效果3.1 数据3.2 预测结果项目运行展示开发环境数据获取 最后 0 前言 &#x1f525; 优质竞赛项目系列&#xff0c;今天…

Linux常用的压缩命令

笑小枫的专属目录 少整花活&#xff0c;直接干货Linux gzip命令语法功能参数 Linux zip命令语法参数 少整花活&#xff0c;直接干货 本文的来源就是因为上篇文章Linux常用的解压命令&#xff0c;解压整了&#xff0c;顺手整理了一波压缩命令。 Linux gzip命令 减少文件大小有…

Linux常用的解压命令

笑小枫的专属目录 整啥幺蛾子Linux tar命令tar命令的参数范例 Linux unzip命令语法参数 整啥幺蛾子 今天在linux解压一个文件&#xff0c;顺手就来tar -zxvf xxxx &#xff0c;哦吼&#xff0c;爆竿了&#xff0c;套他猴子的。 好吧&#xff0c;承认 .gz文件解压多了&#xff…

运行程序报错 system/bin/linker: No such file or direct

使用CLion写了一个测试程序&#xff0c; cmake 编译完成后 &#xff0c; ./test 运行程序报错system/bin/linker: No such file or direct 解决 修改编译链接工具链 重新编译后运行正常

9.25 广读论文 思想汇总

数据集MIMIC-VI Learning Patient Static Information from Time-series EHR and an Approach for Safeguarding Privacy and Fairness The dataset analyzed in this study can be found in https://physionet.org/content/mimiciv/2.2/ and https://eicu-crd.mit.edu/about/…