H列表进阶
业务里最长的一页往往是列表。先选 Lazy,再补 key、下拉刷新(含自定义动画)、空态壳。Paging 3 按「从没写过」的路径拆成能跑通的最小闭环:PagingSource → Pager → 列表 → loadState,最后才提 Room 缓存。
🔗 引用:列表 · 下拉刷新 · Paging 3 · 加载分页数据 · 变换 PagingData
先按场景选做法
| 列表长什么样 | 用这个 | 别用 | 频率 |
|---|---|---|---|
| 设置页 8 行、订单详情块 | Column + verticalScroll | Lazy / Paging | 常用 |
| 通讯录、本地已有完整 List | LazyColumn + items(key) | Paging | 常用 |
| 资讯 / 商品 / 消息,接口按页返回 | Paging 3 + Lazy | 自己 page++ 拼 List | 常用 |
| 下拉重新拉第一页 | PullToRefreshBox | 顶栏按钮当唯一刷新 | 常用 |
| 自定义下拉动画(品牌指示器) | 换 indicator,读 PullToRefreshState | 改 Lazy 的 contentPadding 冒充 | 偶尔 |
| 网络 + Room 离线先看缓存 | RemoteMediator | 新手第一页就上 | 偶尔 |
为何用 LazyColumn 常用
概念
Lazy 只组合可见附近条目。上千项用 Column + forEach 会一次全建,卡顿甚至 OOM。XML 里这就是 RecyclerView 对 ScrollView + LinearLayout。
和 XML 对照
| XML | Compose |
|---|---|
RecyclerView + Adapter | LazyColumn + items { } |
LinearLayoutManager | 默认纵向 LazyColumn |
GridLayoutManager | LazyVerticalGrid |
adapter.notify… / DiffUtil | 数据是 State / PagingData,列表自己重组 |
viewType | contentType |
LayoutManager.findFirstVisible… | LazyListState |
怎么用
@Composable
fun ContactList(contacts: List<Contact>) {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(contacts, key = { it.id }, contentType = { "contact" }) { c ->
ContactRow(c)
}
}
}
contentType 告诉 Compose「这类行可复用同一套测量」,多模板列表(广告卡 + 文本卡)值得写,纯一种行可省略。
易错点
- Lazy 里再套可滚的
Column(verticalScroll)/ 另一个 LazyColumn → 高度测不准、滑动抢手。横向条用LazyRow嵌在纵向 Lazy 的item { }里可以。 - 父级已经
verticalScroll,子级又 Lazy → 先去掉外层滚。
▶ 联系人列表
items + key 常用
概念
key 给条目稳定身份,删改排序时减少错乱重组与状态串位。≈ Adapter 稳定 getItemId + DiffUtil 的 areItemsTheSame。
怎么用
items(users, key = { it.id }) { user ->
UserRow(user)
}
stickyHeader(key = "letter-$letter") { LetterBar(letter) } // 通讯录字母头,偶尔
易错点
用 index 当 key → 删除中间项后,下面行「顶上来」却还带着旧行的开关/输入框状态。条目进出动画也依赖 key,见动画 · animateItem。
▶ 删一项不错乱
下拉刷新 PullToRefreshBox 常用
概念
包在可滚动内容外面:用户下拉 → 你设 isRefreshing = true 并拉数 → 结束改回 false。指示器是否转圈完全由这个布尔驱动,不是组件自己猜。
和 XML 对照
≈ SwipeRefreshLayout:isRefreshing / setOnRefreshListener。Accompanist 的 SwipeRefresh 已退役,新代码用 Material3 的 Box。
怎么用
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FeedWithRefresh(vm: FeedViewModel = viewModel()) {
val refreshing by vm.refreshing.collectAsStateWithLifecycle()
PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = { vm.refresh() }
) {
LazyColumn(Modifier.fillMaxSize()) { /* items… */ }
}
}
VM 里:refresh() 开头 _refreshing.value = true,finally 里改回 false。和 Paging 对接时用 items.refresh(),见下面 Paging UI。
易错点
- Box 塞进 Lazy 的第一条
item→ 嵌套滚动乱、指示器跟着条目走。必须包住整个列表。 - 只改 UI 布尔、没真正发请求 → 转圈永不停,或闪一下就停但数据没变。
- 列表没
fillMaxSize()、内容不足一屏时,有的版本下拉手势抢不到,给列表撑满。
▶ 下拉重新加载第一页
自定义下拉刷新动画 偶尔
概念
默认指示器是 PullToRefreshDefaults.Indicator(圆圈 + 容器)。品牌动画不要重写手势,只换 indicator 槽。手势进度在 rememberPullToRefreshState():distanceFraction 从 0 拉到 1(越过阈值可 > 1)。
XML 对照:SwipeRefreshLayout.setProgressViewOffset / setColorSchemeColors,或干脆藏系统圈、自己在 Header 里跟 getProgress() 画。Compose 把进度收成 State,用 animate* / InfiniteTransition 去画。
怎么用 · 先改颜色和阈值
val state = rememberPullToRefreshState()
PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = onRefresh,
state = state,
indicator = {
PullToRefreshDefaults.Indicator(
modifier = Modifier.align(Alignment.TopCenter),
isRefreshing = refreshing,
state = state,
color = MaterialTheme.colorScheme.primary,
containerColor = MaterialTheme.colorScheme.surfaceContainer
)
}
) { LazyColumn(Modifier.fillMaxSize()) { /* … */ } }
怎么用 · 自己画(随下拉缩放旋转,刷新时连转)
@Composable
fun BoxScope.BrandRefreshIndicator(
isRefreshing: Boolean,
state: PullToRefreshState
) {
val inf = rememberInfiniteTransition(label = "ptr")
val spin by inf.animateFloat(
0f, 360f,
infiniteRepeatable(tween(900, easing = LinearEasing)),
label = "spin"
)
val fraction = state.distanceFraction.coerceIn(0f, 1f)
val rot = if (isRefreshing) spin else fraction * 180f
val scale = 0.55f + 0.45f * fraction
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = "刷新",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 8.dp)
.size(28.dp)
.graphicsLayer {
scaleX = scale
scaleY = scale
rotationZ = rot
alpha = 0.35f + 0.65f * fraction
}
)
}
PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = onRefresh,
state = state,
indicator = { BrandRefreshIndicator(refreshing, state) }
) { /* LazyColumn */ }
还想画弧线、点阵、插画:用 Canvas 读同一个 fraction。Lottie 也能喂 progress,但那是第三方,本页不展开。
更底层
布局不能用 Box 时,用 Modifier.pullToRefresh(isRefreshing, state, onRefresh) 挂在自己的容器上,指示器仍按 state.distanceFraction 绝对定位。手势阈值、嵌套滚动由 Modifier 处理,不要自己 pointerInput 再写一套。
易错点
- 自定义 indicator 里忽略
isRefreshing:松手后 fraction 回 0,动画直接消失,用户以为没刷。 - 刷新中仍用 Infinite 转圈,结束却不读
isRefreshing→ 松手后还转。用if (isRefreshing) spin else fraction * …。 - 在 indicator 里发请求或改 VM —— 槽位只负责画。
▶ 下拉放大、刷新连转的刷新图标
进度只来自 state.distanceFraction 和 isRefreshing,不要用列表的 firstVisibleItemScrollOffset 冒充下拉距离。
Paging 3 是什么(给没用过的人) 常用
概念
接口按页返回(?page=3&size=20)。你若自己在 VM 里 list = list + newPage,就要手写:滑近底部再请求、失败重试、下拉作废旧数据、搜索词变了丢掉旧页。Paging 把这些收成一条 Flow<PagingData<T>>。
和 XML 对照
XML:PagingDataAdapter 接 RecyclerView,loadStateFlow 画页脚。Compose:collectAsLazyPagingItems() 接 Lazy。中间的 PagingSource / Pager / cachedIn 完全一样,换 UI 层即可。
什么时候先别用
- 后台一次返回全部、总共几十条 → 普通 List + Lazy。
- 还没接口、先写死 3 条假数据 → 普通 List。等接口分页了再换 Source。
新手第一页不要上 RemoteMediator(那是 Room 当唯一数据源)。先走「只打网络」的 PagingSource,能刷能翻就毕业。
五步最小闭环(照抄能跑)
- Gradle:
androidx.paging:paging-runtime+androidx.paging:paging-compose(跟 BOM,别手写乱版本)。 - 写一个
PagingSource<页码类型, 行类型>:load里打接口,成功LoadResult.Page,失败LoadResult.Error。 - VM:
Pager(PagingConfig(pageSize = 20)) { YourSource(...) }.flow.cachedIn(viewModelScope)。 - UI:
val items = vm.pages.collectAsLazyPagingItems(),LazyColumn用items.itemCount。 - 用
items.loadState画首次加载 / 底部材加载 / 失败重试;下拉调items.refresh()。
下面三节把 2~5 展开。页码用 Int 最常见(page=1,2,3);有的接口用 nextCursor: String? 当 Key,把 PagingSource<Int, T> 改成 <String, T> 即可。
PagingSource:你真正要写的类
概念
库问你:「从 key=K 开始,给我最多 loadSize 条」。你只负责这一问。不要在 Source 里存列表、不要自己记住「当前页」——当前页就是这次的 params.key。
怎么用
class NewsPagingSource(
private val api: NewsApi,
private val query: String
) : PagingSource<Int, News>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, News> {
val page = params.key ?: 1
return try {
val resp = api.list(query = query, page = page, size = params.loadSize)
LoadResult.Page(
data = resp.items,
prevKey = if (page == 1) null else page - 1,
nextKey = if (resp.items.isEmpty()) null else page + 1
)
} catch (e: IOException) {
LoadResult.Error(e)
} catch (e: HttpException) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, News>): Int? {
val anchor = state.anchorPosition ?: return null
val page = state.closestPageToPosition(anchor) ?: return null
return page.prevKey?.plus(1) ?: page.nextKey?.minus(1)
}
}
三句话记住字段
params.key:这一页从哪开始。第一次刷新是null,你改成起始页(常见 1 或 0)。prevKey / nextKey:没有上一页/下一页就null。空数组也要nextKey = null,否则会无限打空页。getRefreshKey:下拉刷新后,尽量让用户还停在附近。照抄上面「锚点邻页 ±1」即可,不要返回null以外的魔法数字除非你很清楚。
易错点
- 把 Retrofit 的失败当成空列表
Page(emptyList())→ 用户以为没有数据,应LoadResult.Error。 - Source 里持有可变页码字段
var page = 1→ 刷新/重试会乱。页码只来自params.key。 - 搜索词变了仍用旧 Source:要在 VM 里新建 Pager(见下一节
flatMapLatest)。
ViewModel:Pager + cachedIn
概念
Pager 根据配置创建 Source。cachedIn(viewModelScope) 让分页数据活在 VM 里:旋转屏幕不会从头拉第一页。忘记 cachedIn,每次重组都新订阅,列表会闪、重复请求。
class NewsViewModel(private val api: NewsApi) : ViewModel() {
private val query = MutableStateFlow("")
val pages: Flow<PagingData<News>> = query
.flatMapLatest { q ->
Pager(
config = PagingConfig(
pageSize = 20,
prefetchDistance = 5,
enablePlaceholders = false,
initialLoadSize = 20
),
pagingSourceFactory = { NewsPagingSource(api, q) }
).flow
}
.cachedIn(viewModelScope)
fun onQuery(q: String) { query.value = q }
}
PagingConfig:pageSize 对齐接口;prefetchDistance 离底部还有几条就预加载(默认够用);enablePlaceholders 为 true 时没加载到的行是 null,可画骨架,新手先 false。
和 XML 对照
同一段 Pager.flow.cachedIn 可以给 PagingDataAdapter.submitData(lifecycle, pagingData)。迁 Compose 只换收集端。
UI:collectAsLazyPagingItems + loadState
概念
LazyPagingItems 既是列表数据,也是加载状态机。三路 loadState:
| 哪一路 | 何时 | UI 怎么做 |
|---|---|---|
refresh | 第一次进页、或 refresh() | 全屏转圈 / 下拉圈;失败全屏重试 |
append | 滑到底加载下一页 | 列表脚转圈;失败脚上「点击重试」 |
prepend | 向上加载(聊天史) | 列表头;资讯流通常不管 |
LoadState.Loading / Error / NotLoading。refresh() = 作废当前 Source 重来;retry() = 只重试失败的那一次 load。
怎么用(和下拉刷新接好)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewsFeed(vm: NewsViewModel = viewModel()) {
val items = vm.pages.collectAsLazyPagingItems()
val refreshing = items.loadState.refresh is LoadState.Loading
&& items.itemCount > 0
PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = { items.refresh() }
) {
when {
items.loadState.refresh is LoadState.Loading && items.itemCount == 0 -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
items.loadState.refresh is LoadState.Error && items.itemCount == 0 -> {
val err = items.loadState.refresh as LoadState.Error
Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
Text(err.error.localizedMessage ?: "加载失败")
TextButton(onClick = { items.retry() }) { Text("重试") }
}
}
items.loadState.refresh is LoadState.NotLoading
&& items.itemCount == 0 -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("暂无资讯")
}
}
else -> {
LazyColumn(Modifier.fillMaxSize()) {
items(
count = items.itemCount,
key = items.itemKey { it.id }
) { index ->
val news = items[index] ?: return@items
NewsRow(news)
}
when (val append = items.loadState.append) {
is LoadState.Loading -> item {
Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
is LoadState.Error -> item {
TextButton(
onClick = { items.retry() },
modifier = Modifier.fillMaxWidth()
) { Text("加载失败,点我重试") }
}
else -> {}
}
}
}
}
}
}
首次进入:itemCount == 0 且 refresh Loading → 全屏圈(不要同时再转下拉圈)。已经有数据再下拉:itemCount > 0 才把 isRefreshing 置 true。
易错点
- 在 VM 再抄一份
MutableStateList去 addAll —— 和 Paging 抢数据源,翻页会重复或丢。 items[i]可能为 null(占位开启时);先false占位也要习惯判空。- 用
items.refresh()当「加载下一页」——下一页是滑动触发的,refresh 是整表重来。
▶ 资讯流:下拉刷新 + 滑到底加载
把本节代码和 NewsPagingSource 接上假接口(或 delay + 假数据)就能掌握 80% 业务列表。深层链接进详情仍只传 id,见导航 · 传参。
Paging 常用加料
map / filter(仍在 VM)
val pages = Pager(...) { NewsPagingSource(api, q) }
.flow
.map { paging -> paging.map { it.toUi() } }
.cachedIn(viewModelScope)
先 map 再 cachedIn。在 UI 里对 LazyPagingItems 再 filter 会把分页计数搞乱。
Header / 混排
Lazy 里先 item { Banner() } 再 items(count = items.itemCount)。不要把 Banner 塞进 PagingSource 当第 0 条,除非服务端就这么下发。
RemoteMediator + Room 偶尔
要离线先看上次缓存、网络只负责写库,再用 Pager(remoteMediator = …) { db.newsDao().pagingSource() }。UI 仍然 collectAsLazyPagingItems,Source 变成 DAO 的 PagingSource。这是第二阶段,官网「Paging 与 Room」专章再看。第一页闭环不要并行学它。
空态 / 加载 / 失败 常用
概念
非 Paging 列表用密封 UiState(见状态)三壳。Paging 列表用上一节 loadState,不要两套状态各画一遍圈。
▶ 三种壳怎么叠在普通 List 上
when {
loading -> CircularProgressIndicator()
error != null -> TextButton(onClick = onRetry) { Text("重试") }
items.isEmpty() -> Text("暂无数据")
else -> LazyColumn { /* … */ }
}
LazyRow · Grid · Pager · 滚动位置 常用
概念
纵向长列表用 LazyColumn;横向滑动条用 LazyRow;商品墙用 LazyVerticalGrid;Banner / 引导页用 HorizontalPager。规则相同:key、别和可滚父布局硬嵌套。
怎么用
LazyRow { items(banners, key = { it.id }) { BannerCard(it) } }
LazyVerticalGrid(columns = GridCells.Adaptive(minSize = 128.dp)) {
items(products, key = { it.id }) { ProductCell(it) }
}
val pager = rememberPagerState(pageCount = { pages.size })
HorizontalPager(state = pager) { page -> Page(pages[page]) }
val listState = rememberLazyListState()
LazyColumn(state = listState) { /* … */ }
LaunchedEffect(query) { listState.scrollToItem(0) } // 换搜索词滚回顶
条目动画
增删重排时给行加上 Modifier.animateItem()(需稳定 key),详见扩展·动画。Paging 列表不适合拿 animateItem 做「新页飞入」,翻页是追加不是局部 Diff。
偶尔
LazyVerticalStaggeredGrid(瀑布流)、reverseLayout = true(聊天从底部长)场景更少,API 与 Column/Grid 相近。
易错合集
- 长列表用 Column 硬滚;短设置页却上 Paging。
key用 index;Paging 忘了itemKey { it.id }。- PullToRefresh 包在 Lazy 内部;自定义指示器不看
isRefreshing。 - Paging 不
cachedIn;失败当空页;搜索不变 Source。 - 自己
page++同时又用 Paging。