Jectpack Compose项目组件代码分享(1):分页加载组件

一、前言

去年负责的一个项目,把一部分模块改用compose实现,那时候的AI Coding还没那么火热,所以代码基本是手搓,现在拿出来分享。

上拉加载,下拉刷新是老生常谈了,但是compose没有专门的组件来实现,尽管有RefreshBoxPage3这样的辅助,但是实现完整的组件,仍然需要手搓。况且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()
}

三、结语

不得不说,如今的技术博客已经无人问津,个人的输出固然重要,也要考虑历史的进程,抛砖引玉,如果有观看量,我会持续更新。

相关推荐
@北海怪兽2 小时前
SQL常见函数整理 _ STRING_AGG()
android·数据库·sql
鹏晨互联3 小时前
【Compose vs XML:边框内外间距的实现对比】
android·xml
Android系统攻城狮3 小时前
Android tinyalsa深度解析之pcm_plugin_write调用流程与实战(一百七十九)
android·pcm·tinyalsa·android16·音频进阶·android音频进阶
ID_180079054734 小时前
除了JSON,淘宝店铺商品API接口还支持哪些数据格式?
android·数据库
KillerNoBlood4 小时前
2026移动端跨平台开发面经总结
android·算法·flutter·ios·移动开发·鸿蒙·kmp
消失的旧时光-19435 小时前
Android / IoT 面试复盘总结:从 MQTT、TLS 到 JWT 权限体系(标准答案 + 工程理解 + 延伸知识链)
android·物联网·面试
林多6 小时前
【Android】 GPU过度绘制实现原理
android·gpu·性能·实现原理·过度绘制·overdraw
薄荷椰果抹茶6 小时前
手机端Obsidian安装与同步全攻略
android
醇氧6 小时前
CentOS 7安装 mysql-8.0.27-1.el7.x86_64.rpm 安装包
android·mysql·centos