Skip to content

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 ​

参数类型默认值说明
titleString—标题。展开态是大标题,折叠后同一条文字落进工具栏
modifierModifierModifier修饰符
subtitleString?null标题下方小字,折叠过程中逐渐透明直至消失
onBack(() -> Unit)?null返回回调。传 null 表示根页面,不占返回键的位置
actions@Composable RowScope.() -> Unit{}工具栏右侧动作区
subHeader(@Composable () -> Unit)?null头部下方常驻的一条(如分类 chips)。不参与折叠
scrollToTopBooleanfalse是否提供「回到顶部」悬浮按钮
canScrollBackward() -> Boolean{ false }当前能否向上滚。通常传 { listState.canScrollBackward }
canScrollForward() -> Boolean{ true }当前能否向下滚
remainingScrollPx(() -> Float)?null还能向下滚多少 px,0 = 一屏装得下。通常传 { listState.remainingScrollPx() }
collapseEnabledBooleantrue是否启用折叠。内容不足一屏时传 false
forceExpandedBooleanfalse强制展开并锁定(下拉刷新进行中用)
onScrollToTopsuspend () -> Unit{}点悬浮按钮时执行
containerColorColorMaterialTheme.colorScheme.background页面底色,头部与内容共用
content@Composable () -> Unit—页面内容。滚动容器由调用方自己给

remainingScrollPx() ​

扩展适用于
LazyListState.remainingScrollPx(): FloatLazyColumn / 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、再往下取整。宁可把「刚好还能滚一点点」当成装得下而不折,也不要折了之后才发现装得下 —— 后者才是要修的那个观感。