外观
CollapsingTitleScaffold — 折叠大标题
首页那种「大标题随滚动收成工具栏」的页面骨架。展开态字号取 headlineLarge,折叠态取 titleLarge,字阶全部来自 Type.kt。
基本用法
kotlin
val listState = rememberLazyListState()
CollapsingTitleScaffold(
title = "发现",
subtitle = "今天有什么新东西",
onBack = null, // 根页面:不画返回键
actions = {
ActionImageButton(icon = Phosphor.MagnifyingGlass, onAction = { search() })
},
scrollToTop = true,
canScrollBackward = { listState.canScrollBackward },
canScrollForward = { listState.canScrollForward },
remainingScrollPx = { listState.remainingScrollPx() },
onScrollToTop = { listState.animateScrollToItem(0) },
) {
// 滚动容器由你自己给 —— LazyColumn 或瀑布流 grid 都行
LazyColumn(state = listState) { items(items) { … } }
}常驻子头部(不参与折叠,效果等于吸顶):
kotlin
CollapsingTitleScaffold(
title = "发现",
subHeader = {
PillTabsRow(labels = tabs, selectedIndex = sel, onSelect = { sel = it })
},
canScrollBackward = { listState.canScrollBackward },
canScrollForward = { listState.canScrollForward },
remainingScrollPx = { listState.remainingScrollPx() },
) {
LazyColumn(state = listState) { … }
}配下拉刷新(刷新期间强制展开头部):
kotlin
val listState = rememberLazyListState()
val overScrollState = remember { OverScrollState() }
var isRefreshing by remember { mutableStateOf(false) }
CompositionLocalProvider(LocalOverScrollState provides overScrollState) {
PullToRefresh(
isRefreshing = isRefreshing,
onRefresh = { isRefreshing = true; refresh() },
) {
CollapsingTitleScaffold(
title = "首页",
forceExpanded = isRefreshing, // 否则折叠态会遮住刷新指示器
canScrollBackward = { listState.canScrollBackward },
canScrollForward = { listState.canScrollForward },
remainingScrollPx = { listState.remainingScrollPx() },
) {
LazyColumn(state = listState) { … }
}
}
}这里 LocalOverScrollState 必须包住下拉刷新和折叠标题栏两者 —— 折叠标题栏要读同一个 isOverScrollActive 才能在 fling 到列表边界时正确响应,见过度滚动。
参数
CollapsingTitleScaffold
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
title | String | — | 标题。展开态是大标题,折叠后同一条文字落进工具栏 |
modifier | Modifier | Modifier | 修饰符 |
subtitle | String? | null | 标题下方小字,折叠过程中逐渐透明直至消失 |
onBack | (() -> Unit)? | null | 返回回调。传 null 表示根页面,不占返回键的位置 |
actions | @Composable RowScope.() -> Unit | {} | 工具栏右侧动作区 |
subHeader | (@Composable () -> Unit)? | null | 头部下方常驻的一条(如分类 chips)。不参与折叠 |
scrollToTop | Boolean | false | 是否提供「回到顶部」悬浮按钮 |
canScrollBackward | () -> Boolean | { false } | 当前能否向上滚。通常传 { listState.canScrollBackward } |
canScrollForward | () -> Boolean | { true } | 当前能否向下滚 |
remainingScrollPx | (() -> Float)? | null | 还能向下滚多少 px,0 = 一屏装得下。通常传 { listState.remainingScrollPx() } |
collapseEnabled | Boolean | true | 是否启用折叠。内容不足一屏时传 false |
forceExpanded | Boolean | false | 强制展开并锁定(下拉刷新进行中用) |
onScrollToTop | suspend () -> Unit | {} | 点悬浮按钮时执行 |
containerColor | Color | MaterialTheme.colorScheme.background | 页面底色,头部与内容共用 |
content | @Composable () -> Unit | — | 页面内容。滚动容器由调用方自己给 |
remainingScrollPx()
| 扩展 | 适用于 |
|---|---|
LazyListState.remainingScrollPx(): Float | LazyColumn / LazyRow |
LazyStaggeredGridState.remainingScrollPx(): Float | 瀑布流 / LazyVerticalStaggeredGrid |
都是「还能向前滚多少 px」,0 表示一屏装得下;最后一项还没露面时给 Float.MAX_VALUE。
要点
- 列表一屏装得下时会拒绝折叠。 折叠换不到空间,却留下一个收不回去的标题栏 —— 因为列表滚不动,「到顶」永远不会再触发。判断条件是
canScrollBackward与canScrollForward同时为 false(不能只看canScrollForward:列表滚到底时它同样是 false,那时必须保持折叠,让头部跟随列表位置)。 remainingScrollPx是为了掐掉抖动。 溢出量还不到折叠能腾出的高度时,折完照样一屏装得下,会被「装得下」监听弹回去。不传它则退回旧行为(先折一下再弹回)。- 三个回调要给真实值。
canScrollBackward/canScrollForward/remainingScrollPx的默认值是保守的,不接列表状态的话折叠行为会不对。它们是 lambda,值变化时会正常重组。 - 同一个手势内被「装得下」打回过一次就不再折,由
onPreFling/onPostFling(手势结束)解除 —— 否则会逐帧追打:折一点 → 内容区变大 → 装得下 → 弹回 → 又装不下 → 再折…… subHeader不参与折叠,滚动时一直贴在工具栏下面。效果与「吸顶」相同,但不需要吸顶那套机制。onBack = null时不占返回键的位置,标题的横向起点也不会往右移(避免标题凭空横向漂)。containerColor别传surface。 本配色里surface是卡片色,页面底是刻意压暗一档的;默认background是故意的(见「说明」)。- 下拉刷新时要传
forceExpanded = ptrState.isRefreshing,否则折叠态会把刷新指示器遮住一半。 content里不给滚动容器就不会折。nestedScroll挂在内容区的祖先上,里面必须是可滚动的东西(LazyColumn/ grid)才拦得到滚动。
说明
- 折叠是「一个标题平移」,不是两个标题交接。 工具栏与大标题区是同一个头部容器,里面只有一个
Text。它的 X、Y 和字号全部由折叠进度插值 —— 标题真的从大标题位置平移进工具栏那一行,同时字号从headlineLarge收到titleLarge、副标题透明度从 1 收到 0。 - 字重不参与插值:字重是枚举没法插值,中途硬切一次会比字号的连续变化更扎眼,所以它固定取展开态的 token。
- 副标题只淡出、不缩小 —— 它是「展开态才有的信息」,跟着缩放会在半透明时糊成一团小字。
- 折叠由滚动驱动、展开由状态驱动。
onPreScroll只负责折叠(拖拽和惯性都跟手);展开统一交给「到顶监听」—— 因为 fling 到顶后剩余速度被 overscroll 弹簧接管,那段位移不经过 nested scroll 的dispatchPreScroll,onPreScroll收不到可靠位移(这是早期「惯性下滑到顶不展开」的根因)。只要canScrollBackward变false,无论 fling、拖拽还是弹簧回弹,都会动画展开。 - 折叠跟手是同步写值的,不套协程。上一版为做吸附把跟手也塞进协程(
stop+snapTo),每帧一次launch会让标题滞后、显得生硬。现在跟手直接改状态,松手后的吸附单独用一个animate协程(250ms /FastOutSlowInEasing),滚动时靠Job打断它恢复跟手。 - 「装得下」的弹回是
snap不是动画:折叠与弹回会在同一帧里互相追打,播动画就是肉眼可见的抖动,硬切反而看不出发生过什么 —— 也正是「装得下就不许折」想要的效果。 - 字体放大时也正确:sp → dp 会乘以系统的
fontScale,头部高度与折叠位置都按换算后的值算,否则放大字体后大标题会被工具栏压住、折叠态标题也会偏下。 - 尺寸规格:工具栏行高 56dp;标题左起 16dp、有返回键时 56dp(跳过一个 44dp 的返回键圆);标题与副标题间距 4dp;展开态大标题块下方留白 14dp —— 没有副标题时会把这 14dp 之外的那行高度算掉,否则会在标题下方空出约 34dp 的死白。
- 为什么内容槽是普通
@Composable而不是LazyListScope:早期这个组件自己持有LazyColumn,调用方没法换滚动容器 —— 想用懒加载瀑布流(LazyVerticalStaggeredGrid)就不行,因为把 grid 嵌进LazyColumn会因高度无界直接崩。现在由页面自己决定用哪种容器,组件只负责头部与悬浮按钮,代价是多写一行rememberLazyListState()。 remainingScrollPx有意偏保守:减掉afterContentPadding、再往下取整。宁可把「刚好还能滚一点点」当成装得下而不折,也不要折了之后才发现装得下 —— 后者才是要修的那个观感。