在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中保留与下拉刷新相关的状态和函数,比如refreshIndicatorState
或lastRefreshTime
。然而,在我的情况下,这相当啰嗨,因为我不得不在许多屏幕中实现相同的东西,所以我更喜欢创建一个可重用的组合并将相关数据包装在一个状态持有类中。
这是我们可重用的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
,这个分页流就会被重新触发。(如果您的实现不关心上次刷新时间,您可以使用另一个变量。)