用途
这是一个用 Jetpack Compose 构建折叠视差工具栏(collapsing parallax toolbar)的实现方案。核心结构是:五个同级组件放在同一个 Box 中,其中四个读取同一个 ScrollState 且都不拥有它,第五个完全不读取滚动状态,而是由工具栏向上回报的布尔值驱动。
val scroll: ScrollState = rememberScrollState(0)
Box(modifier) {
Header(scroll, headerHeightPx, imageUrl, backgroundColor, /* … */) // artwork, parallax + fade
Body(scroll, headerHeight) { content(color) } // the only scrollable
Toolbar(scroll, headerHeightPx, toolbarHeightPx, onShow = { … }) // pinned bar, appears late
Title(scroll, title, headerHeight, toolbarHeight) // travels between the two
AnimatedVisibility(showBackButton) { /* floating back button */ }
}
主要能力
- 只有 Body 可滚动,它用 Spacer 预留头部空间,而不是把头部包含在滚动容器内:
Column(modifier.verticalScroll(scroll)) {
Spacer(Modifier.height(headerHeight))
Box(Modifier.background(Color.Black)) { content() }
}
- 头部完全位于可滚动区域之外,由 graphics layer 移动,因此一次滚动只花费一次绘制:
.graphicsLayer {
translationY = -scroll.value.toFloat() / 2f // half the content's rate — the parallax
alpha = (-1f / headerHeightPx) * scroll.value + 1
}
- 头部自身高度来自窗口形状而非平台:高大于宽时使用正方形框(窗口宽度),否则使用视口高度的一半,两者下限均为 250.dp。只有正方形情形才请求正方形源图,因为只有那里源图恰好填满框。
使用方式与陷阱
资料给出了若干实现要点:
-
翻转点用无 key 的 remember 记忆,会在首次组合时冻结。headerHeightPx 由窗口尺寸推导并在 resize 时变化,但无 key 的 remember 会在重组中保留旧值,导致固定栏出现偏早或偏晚。应加 key:
remember(headerHeightPx, toolbarHeightPx)。旁边的 derivedStateOf 正确地保持无 key。 -
两个返回入口一次只出现一个。浮动返回按钮与固定栏的导航图标是不同组件,翻转时在两者之间交接:工具栏向上回报自身可见性(
onShow = { show -> showBackButton = !show }),浮动按钮在栏动画进入时动画退出。两者必须由同一个派生布尔值驱动。 -
两段式标题路径是二次贝塞尔曲线,其中点被共享。每个轴都是对同一 fraction 的三次 lerp:
val collapseFraction = (scroll.value / (headerHeight.toPx() - toolbarHeight.toPx())).coerceIn(0f, 1f)
val titleYFirst = lerp(headerHeight - titleHeightPx.toDp() - paddingMedium, headerHeight / 2, collapseFraction)
val titleYSecond = lerp(headerHeight / 2, toolbarHeight / 2 - titleHeightPx.toDp() / 2, collapseFraction)
val titleY = lerp(titleYFirst, titleYSecond, collapseFraction)
lerp(lerp(A,B,t), lerp(B,C,t), t) 是 de Casteljau 构造法:从 A 到 C 的二次贝塞尔曲线,B 为控制点。若两个中间项不一致,控制点会移到它们的平均值,路径仍然平滑但不再经过预期的路径点,症状是标题沿着一条没人画出的线移动。应让中间项保持为被两段共同读取的单一表达式。
- 缩放标题需要补偿起始内边距。scaleX/scaleY 围绕元素中心缩放,标题向栏收缩时左边缘会向内收进其损失宽度的一半:
val titleExtraStartPadding = titleWidthPx.toDp() * (1 - scaleXY.value) / 2f
-
标题自测量,首帧从零计算。titleHeightPx 和 titleWidthPx 从 0f 开始,由 onGloballyPositioned 填充,它在首次布局之后运行,因此首帧以零高度和未补偿的内边距放置标题。这对只喂给变换的值没问题,但对必须立即正确的值不行——绝不要用自测量尺寸计算头部高度、翻转点或滚动目标。
-
过渡期间的可读性靠遮罩和绘制顺序,而非裁剪。头部带垂直渐变,中部透明、底部不透明,使标题在图像上有对比度;Body 的不透明背景在向上滚动时覆盖头部。标题在 Box 中声明于工具栏之后,因此绘制在上层并落入折叠栏内;重排同级组件会让它在折叠结束时消失。maxLines = 1 完成其余工作。
-
alpha 斜坡未做钳制。它在恰好一个头部高度的滚动后到达零,并继续变为负值——只是因为 Body 的不透明背景已覆盖头部才不可见。在半透明 Body 上应做 coerce。
-
使用 graphics layer,而非 offset 或 padding。graphicsLayer 块中的 translationY 在绘制阶段读取 scroll.value,因此滚动帧完全跳过组合与布局。Modifier.offset { } 属于布局,滚动驱动的 Modifier.padding 每帧重新测量。
验证方式
资料提供了三条 grep 检查命令(需先将 TOOLBAR 设为你的工具栏文件名):
TOOLBAR=CollapsingToolbar.kt
# 1. Geometry in a keyless remember — anything derived from a window or element size must be keyed.
find . -name "$TOOLBAR" -not -path "*/build/*" -exec grep -Hn "remember {" {} +
# 2. Everything the scroll value drives. Each hit should be inside a graphicsLayer or a
# derivedStateOf — a scroll read in plain composition means a recomposition per frame.
find . -name "$TOOLBAR" -not -path "*/build/*" -exec grep -Hn "scroll.value\|collapseFraction\|graphicsLayer" {} +
# 3. Exactly one scrollable, and the spacer that reserves the header's space right after it.
find . -name "$TOOLBAR" -not -path "*/build/*" -exec grep -Hn "verticalScroll\|rememberScrollState\|Spacer(Modifier.height" {} +
然后用长标题和短标题缓慢滚动,观察路径走向而非是否平滑——无论控制点多错它都是平滑的。折叠过半时标题必须位于头部中部附近,不得向内切入,不得在收缩时横向漂移,必须居中落入栏内。恰好停在翻转点:只有一个返回入口。最后调整窗口大小并再次滚动,检查固定栏是否出现在错误偏移。
适用场景与限制
适用于在 Jetpack Compose 中实现折叠头部、视差图像、标题在头部与固定栏之间移动的界面。资料未提供该方案的平台版本要求、依赖库、安装命令或收费信息。