一、前言
去年负责的一个项目,把一部分模块改用compose实现,那时候的AI Coding还没那么火热,所以代码基本是手搓,现在拿出来分享。
上拉加载,下拉刷新是老生常谈了,但是compose没有专门的组件来实现,尽管有RefreshBox和Page3这样的辅助,但是实现完整的组件,仍然需要手搓。况且page3在使用过程中,并没法实现真正意义上的state按需更新,这是一大槽点。
二、贴代码
1、针对PullToRefreshBox进行封装
kotlin
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PullToRefreshLayout(
modifier: Modifier = Modifier,
isRefreshing: Boolean,
state: PullToRefreshState = rememberPullToRefreshState(),
onRefresh: () -> Unit,
content: @Composable () -> Unit
) {
PullToRefreshBox(
modifier = modifier,
state = state,
isRefreshing = isRefreshing,
onRefresh = {
onRefresh()
},
indicator = {
Indicator(
modifier = Modifier.align(Alignment.TopCenter),
isRefreshing = isRefreshing,
containerColor = White,
color = primaryColor,
state = state
)
},
) {
content()
}
}
组件很简单,核心是我们需要接受onRefresh方法实现下拉刷新动作
2、加入分页加载逻辑
kotlin
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun <ITEM> ElderlyRefreshLoadMoreLayout(
modifier: Modifier = Modifier,
paddingValues: PaddingValues = PaddingValues(0.dp),
lazyListState: LazyListState = rememberLazyListState(),
state: PaginationState<ITEM>,
hasHeader: Boolean = false,
showNoMoreFooter: Boolean = true,
header: @Composable () -> Unit = {},
item: @Composable (Modifier, Int, ITEM) -> Unit,
key: ((ITEM) -> Any)? = null,
onPullRefresh: () -> Unit,
onLoadMore: () -> Unit = {},
onRetry: () -> Unit = {},
emptyContent: (@Composable () -> Unit) = {
Column(
modifier = modifier
.fillMaxSize()
.padding(8.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
painter = painterResource(id = R.drawable.icon_message_empty),
contentDescription = null,
)
Spacer(modifier = Modifier.height(20.dp))
Text(
text = "暂无消息",
style = MaterialTheme.typography.titleMedium,
color = Color999999
)
}
},
errorContent: (@Composable () -> Unit) = {
Column(
modifier = modifier
.fillMaxSize()
.padding(8.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
painter = painterResource(id = R.drawable.icon_message_empty),
contentDescription = null,
)
Spacer(modifier = Modifier.height(20.dp))
Text(
text = "加载失败,请重试",
style = MaterialTheme.typography.titleMedium,
color = Color999999
)
Spacer(modifier = Modifier.height(20.dp))
ElderlyButton(
modifier = Modifier.size(100.dp, 35.dp),
text = "刷新",
onClick = onRetry,
radius = 6.dp,
)
}
},
) {
// val showFooter by remember {
// derivedStateOf {
// // 如果列表为空,则不显示
// if (lazyListState.layoutInfo.totalItemsCount == 0) {
// return@derivedStateOf false
// }
// val lastVisibleItem = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()
// ?: return@derivedStateOf false // 如果没有可见的 item,不显示
// val isLastItemVisible =
// lastVisibleItem.index == lazyListState.layoutInfo.totalItemsCount - 1
// val contentFitsInViewport = isLastItemVisible &&
// (lastVisibleItem.offset + lastVisibleItem.size <= lazyListState.layoutInfo.viewportEndOffset)
// !contentFitsInViewport
// }
// }
//
val reachedBottom by remember(lazyListState) {
derivedStateOf {
val lastVisibleItemIndex = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
val totalItemCount = lazyListState.layoutInfo.totalItemsCount
lastVisibleItemIndex == totalItemCount - 1 &&
lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.let {
it.offset + it.size >= lazyListState.layoutInfo.viewportEndOffset
} == true
}
}
LaunchedEffect(reachedBottom) {
if (reachedBottom && !state.isLoading && !state.endReached) {
onLoadMore()
}
}
val itemAnimationSpecFade = nonSpatialExpressiveSpring<Float>()
val itemPlacementSpec = spatialExpressiveSpring<IntOffset>()
ElderlyPullToRefreshLayout(
modifier = modifier
.fillMaxSize()
.background(ColorF6F6F6)
.padding(paddingValues),
isRefreshing = state.isRefreshing,
onRefresh = onPullRefresh,
) {
LazyColumn(
state = lazyListState,
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (hasHeader && state.items.isNotEmpty()) {
item {
header()
}
}
// 判断条件需要写在item{}外层,否则下拉刷新有异常
if (state.items.isEmpty() && !state.isLoading) {
item {
Box(modifier = Modifier.fillParentMaxSize()) {
if (state.error != null) {
errorContent()
} else {
emptyContent()
}
}
}
}
key?.let {
itemsIndexed(
items = state.items,
key = { index, item ->
key(item)
}
) { index, item ->
if (item != null) {
item(
Modifier.animateItem(
fadeInSpec = itemAnimationSpecFade,
fadeOutSpec = itemAnimationSpecFade,
placementSpec = itemPlacementSpec,
), index, item
)
}
}
}
if (!state.endReached && state.isLoadingMore) {
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.Center
) {
CircularProgressIndicator(
color = primaryColor
)
}
}
}
if (showNoMoreFooter && state.items.isNotEmpty() && state.endReached) {
item {
Text(
text = "没有更多数据",
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleMedium,
fontSize = 14.sp,
color = Color999999
)
}
}
}
}
}
fun <T> spatialExpressiveSpring() = spring<T>(
dampingRatio = 0.8f,
stiffness = 380f,
)
fun <T> nonSpatialExpressiveSpring() = spring<T>(
dampingRatio = 1f,
stiffness = 1600f,
)
我对组件给出了更多的定制参数,包括头部尾部、空视图、重试等,组件的灵活性尚可。
######3、组件使用方
kotlin
ElderlyRefreshLoadMoreLayout(
modifier = Modifier.weight(1f),
lazyListState = lazyListState,
state = viewModel.messagePagenator.notificationsState,
onPullRefresh = {
viewModel.getMessageList(conversationId, true, isReverse = isReverse)
},
onLoadMore = {
// do nothing
},
onRetry = {
viewModel.getMessageList(conversationId, isReverse = isReverse)
},
showNoMoreFooter = !shouldScrollToBottom, // 懒法 如果需要滚动到底部,则不显示底部加载更多
key = { it.messageClientId },
item = { modifier, index, item ->
Spacer(modifier = Modifier.height(4.dp))
messageItem(index, item)
}
)
重点关注state这个参数,作为分页加载的核心,实现状态的流转,包括对item的局部刷新,不仅是性能的提升,也有更美观的更新效果。
在viiewmodel中
kotlin
val messagePagenator by lazy {
MessagePagenator(
scope = viewModelScope,
onRequestList = { page, pageSize ->
ConversationRepo.getMessageList(_conversationId, _isReverse)
}
)
}
kotlin
class MessagePagenator<T>(
val scope: CoroutineScope,
onRequestList: suspend (page: Int, size: Int) -> Result<List<T>>
) {
var notificationsState by mutableStateOf(PaginationState<T>(page = DefaultPaginator.INITIAL_KEY))
val currentDevicePaginate = DefaultPaginator(
initialKey = notificationsState.page,
onLoadUpdated = {
notificationsState = notificationsState.copy(isRefreshing = it)
},
onRequest = { nextPage ->
onRequestList(nextPage, DefaultPaginator.PAGE_SIZE)
},
getNextKey = {
notificationsState.page
},
onError = {
notificationsState = notificationsState.copy(error = it)
},
onLoadMore = {
notificationsState = notificationsState.copy(isLoadingMore = it)
},
onSuccess = { items, newKey ->
notificationsState = notificationsState.copy(
items = items,
page = newKey,
endReached = true,
)
}
)
fun requestNextItems(isRefresh: Boolean) {
scope.launch {
if (isRefresh) {
currentDevicePaginate.reset()
notificationsState = notificationsState.copy(
page = DefaultPaginator.INITIAL_KEY,
isRefreshing = true
)
delay(DefaultPaginator.REFRESH_DELAY)
}
currentDevicePaginate.loadNextItems()
}
}
}
kotlin
class DefaultPaginator<Item>(
private val initialKey: Int,
private val onLoadUpdated: (Boolean) -> Unit,
private val onLoadMore: (Boolean) -> Unit,
private val onRequest: suspend (nextKey: Int) -> Result<List<Item>>,
private val getNextKey: suspend (List<Item>) -> Int,
private val onError: suspend (String?) -> Unit,
private val onSuccess: suspend (items: List<Item>, newKey: Int) -> Unit
) : Paginator<Item> {
val TAG = "列表加载器日志"
private var currentKey = initialKey
private var isMakingRequest = false
companion object{
internal const val PAGE_SIZE = 20
internal const val INITIAL_KEY = 1
internal const val REFRESH_DELAY = 500L
}
override suspend fun loadNextItems(isRefresh: Boolean) {
if (isMakingRequest) {
return
}
isMakingRequest = true
onLoadMore(currentKey > initialKey)
// onLoadUpdated(true)
val result = onRequest(currentKey)
isMakingRequest = false
val items = result.getOrElse {
onError(it)
onLoadUpdated(false)
onLoadMore(false)
return
}
currentKey = getNextKey(items)
onSuccess(items, currentKey)
onLoadUpdated(false)
onLoadMore(false)
}
override fun reset() {
currentKey = INITIAL_KEY
}
}
interface Paginator<Item> {
suspend fun loadNextItems(isRefresh: Boolean = false)
fun reset()
}
三、结语
不得不说,如今的技术博客已经无人问津,个人的输出固然重要,也要考虑历史的进程,抛砖引玉,如果有观看量,我会持续更新。