Skip to content

分页列表

介绍

项目的分页列表由 BaseNetWorkListViewModel<T> 管理页码和状态,由 BaseNetWorkListView 处理首屏四态,再由 RefreshLayout 统一封装下拉刷新、上拉加载和列表排版。该实现要求接口返回 NetworkPageData<T>;如果接口没有 listpagination 结构,应在 Repository 或数据模型层完成转换。

本页是 Core 数据链路的最终组合页。开始前应已理解请求结果处理网络请求数据层UI 组件。分页 ViewModel 只消费 Repository Flow,不在 Compose 中管理页码。

模块结构

以下目录展示分页状态、交互组件和 Demo 的对应位置。

text
core/base/
├── state/BaseNetWorkListUiState.kt
└── viewmodel/BaseNetWorkListViewModel.kt
core/ui/component/
├── network/BaseNetWorkListView.kt
└── refresh/
    ├── RefreshLayout.kt
    └── RefreshContent.kt
feature/demo/
├── viewmodel/NetworkListDemoViewModel.kt
└── view/NetworkListDemoScreen.kt

状态与请求链路

页面从状态收集到下一页合并按以下链路执行。

text
Route 收集 uiState/listData/刷新 Flow
  → BaseNetWorkListView 切换 Loading / Empty / Error / Success
  → Success 内容交给 RefreshLayout
  → UltraSwipeRefresh 触发 onRefresh 或 onLoadMore
  → ViewModel 请求当前页并合并列表

页面状态

BaseNetWorkListUiState 是无参数的 sealed class:

状态进入条件默认内容
Loading首次请求或 retryRequest()PageLoading()
Success第 1 页返回非空列表成功内容
Empty第 1 页返回空列表EmptyData(onRetryClick = onRetry)
Error第 1 页失败且当前没有旧数据EmptyNetwork(onRetryClick = onRetry)

第 1 页刷新失败但已有旧列表时,状态保持 Success,同时结束刷新;加载更多失败时不会覆盖首屏状态,并会将 currentPage 回退一页。

StateFlow

Flow类型初始值用途
uiStateStateFlow<BaseNetWorkListUiState>Loading首屏四态
listDataStateFlow<List<T>>emptyList()已合并的列表数据
isRefreshingStateFlow<Boolean>false头部刷新指示器
isLoadingMoreStateFlow<Boolean>false底部加载指示器
hasMoreDataStateFlow<Boolean>false是否显示加载更多

页码规则

BaseNetWorkListViewModel 当前默认 currentPage = 1pageSize = 10。子类可重写 pageSize,但不能直接从 UI 修改受保护的 currentPage

成功响应中的 pagination 用以下公式判断下一页:

text
pagination.size * pagination.page < pagination.total

缺少 pagination 时,hasMoreData 会被设为 false。公式使用服务端返回的 sizepage;字段为空时分别回退到基类的 pageSize 与当前请求页。

ViewModel 示例

文件位置:feature/demo/viewmodel/NetworkListDemoViewModel.kt

以下按页面规范展示分页 ViewModel:首屏最短加载时间打开,接口页大小重写为 15,Repository 返回 Flow<NetworkResponse<NetworkPageData<Goods>>>

kotlin
package com.joker.kit.feature.demo.viewmodel

import com.joker.kit.core.base.viewmodel.BaseNetWorkListViewModel
import com.joker.kit.core.data.repository.GoodsRepository
import com.joker.kit.core.model.entity.Goods
import com.joker.kit.core.model.network.NetworkPageData
import com.joker.kit.core.model.network.NetworkResponse
import com.joker.kit.core.model.request.GoodsSearchRequest
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject

/**
 * Network List Demo ViewModel
 *
 * @param goodsRepository 商品仓库
 */
@HiltViewModel
class NetworkListDemoViewModel @Inject constructor(
    private val goodsRepository: GoodsRepository
) : BaseNetWorkListViewModel<Goods>() {

    /** 首屏加载至少保持 320 ms,避免骨架瞬时消失 */
    override val enableMinLoadingTime: Boolean get() = true

    /** Demo 接口每页请求 15 条 */
    override val pageSize: Int get() = 15

    init {
        // ViewModel 初始化后只启动一次首屏请求
        initLoad()
    }

    /**
     * 请求当前页商品
     *
     * @return 商品分页响应流
     */
    override fun requestListData(): Flow<NetworkResponse<NetworkPageData<Goods>>> {
        return goodsRepository.getGoodsPage(
            GoodsSearchRequest(
                page = currentPage,
                size = pageSize
            )
        )
    }
}

initLoad()currentPagerequestListData() 都位于 ViewModel 层;不要在 Compose 中拼接页码或直接调用 Retrofit。

View 示例

文件位置:feature/demo/view/NetworkListDemoScreen.kt

Route 收集五个状态流,Screen 接收状态和事件,Content 再把列表交给 RefreshLayout。ViewModel 与 View 分开后,列表也能通过预览数据单独预览。

kotlin
package com.joker.kit.feature.demo.view

import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.joker.kit.core.base.state.BaseNetWorkListUiState
import com.joker.kit.core.model.entity.Goods
import com.joker.kit.core.navigation.navigateBack
import com.joker.kit.core.ui.component.network.BaseNetWorkListView
import com.joker.kit.core.ui.component.refresh.RefreshLayout
import com.joker.kit.core.ui.component.scaffold.AppScaffold
import com.joker.kit.feature.demo.skeleton.NetworkListLoadingSkeleton
import com.joker.kit.feature.demo.viewmodel.NetworkListDemoViewModel

/**
 * Network List Demo 路由
 *
 * @param viewModel 分页列表 ViewModel
 */
@Composable
internal fun NetworkListDemoRoute(
    viewModel: NetworkListDemoViewModel = hiltViewModel()
) {
    // 收集页面状态
    val uiState by viewModel.uiState.collectAsState()
    // 收集列表数据
    val listData by viewModel.listData.collectAsState()
    // 收集下拉刷新状态
    val isRefreshing by viewModel.isRefreshing.collectAsState()
    // 收集上拉加载状态
    val isLoadingMore by viewModel.isLoadingMore.collectAsState()
    // 收集是否还有更多数据
    val hasMoreData by viewModel.hasMoreData.collectAsState()

    NetworkListDemoScreen(
        uiState = uiState,
        list = listData,
        isRefreshing = isRefreshing,
        isLoadingMore = isLoadingMore,
        hasMoreData = hasMoreData,
        onRefresh = viewModel::onRefresh,
        onLoadMore = viewModel::onLoadMore,
        onRetry = viewModel::retryRequest
    )
}

Route 只收集状态并传递事件。下面的 Screen 负责页面骨架和四态切换,仍属于同一个 NetworkListDemoScreen.kt 文件,沿用上一个代码块中的 package 与 imports。

kotlin
/**
 * Network List Demo 界面
 *
 * @param uiState 列表页状态
 * @param list 商品列表数据
 * @param isRefreshing 是否正在刷新
 * @param isLoadingMore 是否正在加载更多
 * @param hasMoreData 是否还有更多数据
 * @param onRefresh 刷新回调
 * @param onLoadMore 加载更多回调
 * @param onRetry 重试回调
 */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun NetworkListDemoScreen(
    uiState: BaseNetWorkListUiState = BaseNetWorkListUiState.Loading,
    list: List<Goods> = emptyList(),
    isRefreshing: Boolean = false,
    isLoadingMore: Boolean = false,
    hasMoreData: Boolean = false,
    onRefresh: () -> Unit = {},
    onLoadMore: () -> Unit = {},
    onRetry: () -> Unit = {}
) {
    AppScaffold(
        titleText = "Network List Demo",
        onBackClick = { navigateBack() }
    ) {
        BaseNetWorkListView(
            uiState = uiState,
            onRetry = onRetry,
            customLoading = { NetworkListLoadingSkeleton() }
        ) {
            NetworkListDemoContent(
                list = list,
                isRefreshing = isRefreshing,
                isLoadingMore = isLoadingMore,
                hasMoreData = hasMoreData,
                onRefresh = onRefresh,
                onLoadMore = onLoadMore
            )
        }
    }
}

成功态内容

BaseNetWorkListView 成功时才组合 RefreshLayout。列表内容使用 LazyListScopeitemsIndexed,与当前 Demo 一致。

kotlin
/**
 * Network List Demo 成功态内容
 *
 * @param list 商品列表数据
 * @param isRefreshing 是否正在刷新
 * @param isLoadingMore 是否正在加载更多
 * @param hasMoreData 是否还有更多数据
 * @param onRefresh 刷新回调
 * @param onLoadMore 加载更多回调
 */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun NetworkListDemoContent(
    list: List<Goods>,
    isRefreshing: Boolean,
    isLoadingMore: Boolean,
    hasMoreData: Boolean,
    onRefresh: () -> Unit,
    onLoadMore: () -> Unit
) {
    RefreshLayout(
        list = list,
        isRefreshing = isRefreshing,
        isLoadingMore = isLoadingMore,
        hasMoreData = hasMoreData,
        onRefresh = onRefresh,
        onLoadMore = onLoadMore
    ) {
        itemsIndexed(list) { _, goods ->
            // 列表项只负责展示单条商品
            GoodsListItem(goods = goods)
        }
    }
}

RefreshLayout 行为

参数或机制当前实现
手势容器UltraSwipeRefresh
列表模式默认 LazyColumn,通过 content 构建
网格模式isGrid = true,通过 gridContent 构建 LazyVerticalStaggeredGrid
加载更多开关list.isNotEmpty()true 时启用
头部指示器默认 ClassicRefreshHeader
底部指示器hasMoreData 或加载动画;无更多时调用 noMoreContent
震动反馈vibrationEnabled = true
顶部栏联动传入 scrollBehavior 后连接 nestedScroll

RefreshContent 为列表提供默认内边距和间距,并在列表与瀑布流切换时使用 300 ms 淡入缩放动画。应用启动时 Application.initRefresh() 将头部和底部的 NestedScrollMode 统一设为 Translate

刷新、加载更多与重试

调用前置条件结果
onRefresh()加载更多未进行currentPage = 1,设置 isRefreshing = true 并重新请求
onLoadMore()非加载中、有更多且列表非空页码加一,设置 isLoadingMore = true 并追加数据
retryRequest()任意错误态清空刷新标记和更多标记,从第 1 页重新加载
observeRefreshState()需要响应导航结果监听 RefreshResult.refresh == true 后调用 onRefresh()

首屏最短加载时间由 enableMinLoadingTime 控制,时长为 320 ms;刷新和加载更多成功态至少保持 500 ms,避免指示器闪现。加载更多失败会回退 currentPage,因此下一次触发仍请求失败页。

三种请求如何写入列表

请求类型请求前成功后失败后
首次加载第 1 页、列表为空、Loading替换列表,进入 SuccessEmpty列表为空时进入 Error
下拉刷新页码重置为 1,保留旧列表,isRefreshing = true用新列表替换旧列表保留旧列表并回到 Success,但 hasMoreData = false
加载更多页码加 1,isLoadingMore = true追加新列表并更新 hasMoreData页码减 1,保留已加载列表

分页基类不会按 ID 去重。服务端分页数据可能重复或排序变化时,应在 Repository 或重写 handleSuccess 的业务实现中明确合并策略;不要在 Composable 每次重组时临时去重。

注意事项

  • pagination 为空或总条数不满足公式时,底部会显示无更多内容,不会继续触发下一页。
  • onRefresh() 在加载更多进行中会直接返回,避免两个请求同时修改列表。
  • onRefresh() 没有额外阻止第二次刷新,基类也不保存和取消请求 Job;自定义手势或按钮触发时应避免重复调用。
  • 首屏错误但已有旧列表时显示旧内容,用户可再次下拉刷新;首屏没有数据时才显示错误占位。
  • isGrid 模式需要通过 gridContent 提供 LazyStaggeredGridScope 内容,不能把 itemsIndexed 的列表作用域直接传入。
  • pagination.sizepagetotal 决定是否还有下一页;接口缺少这些元数据时基类会停止加载更多。

官方文档