目录
- 前言
- MediumTopAppBar
- 阅读源码
- 核心
- 解决方法
- 重写TopAppBarLayout
- 完整代码
前言
想用composes实现类似掘金的文章详细页面的标题栏
上滑隐藏标题后标题栏显示标题
compose.material3下的TopAppBar不能嵌套滚动
MediumTopAppBar
便使用了MediumTopAppBar一开始用着没什么问题,但是标题字数多了,MediumTopAppBar就不支持了,最多就两行,进入源码一看就明白了
@ExperimentalMaterialApi | |
@Composable | |
fun MediumTopAppBar( | |
... | |
) { | |
TwoRowsTopAppBar( | |
... | |
) | |
} |
TwoRowsTopAppBar 官方就是告诉你我就两行,要是不服你就自己写,自己写就自己写,当然我才不自己写呢,直接抄,把TwoRowsTopAppBarcopy过来改改就行,开始想着改Text的maxLines就行,后来才发现TwoRowsTopAppBar是用最大heignt限制的
阅读源码
理解源码可以知道MediumTopAppBar布局可以分为两块
上标题栏(TopAppBa) 和下标题(bottomTitle)分别设置了固定高度
布局 | 高度 |
上标题栏 | 122.dp |
下标题 | 64.dp |
这个就是TwoRowsTopAppBar命名的TwoRows的原因
高度是固定在我们改不了
核心
首先限制嵌套滑动的Y轴最大的偏移量也就是高度,目的就是仅隐藏底部标题区域并保留顶部标题
手指上滑后计算上滑偏移量
//官方源码 | |
SideEffect { | |
if (scrollBehavior?.state?.heightOffsetLimit != pinnedHeightPx - maxHeightPx) { | |
scrollBehavior?.state?.heightOffsetLimit = pinnedHeightPx - maxHeightPx | |
} | |
} |
接着scrollBehavior.state.collapsedFraction获取折叠高度百分比(0.0表示完全展开,1.0表示完全折叠)
在利用三阶贝塞尔曲线+百分比设置titleText的Alpha值实现滑动渐显效果
最后实现自定义布局,下标题的高度-上滑偏移量实现折叠标题 并且利用Alpha显示上标题
Column { | |
//上标题 | |
TopAppBarLayout( | |
... | |
) | |
//下标题 | |
TopAppBarLayout( | |
... | |
heightPx = maxHeightPx - pinnedHeightPx + (scrollBehavior?.state?.heightOffset | |
?:f) | |
... | |
) | |
} | |
...... | |
val layoutHeight = heightPx.roundToInt() | |
layout(constraints.maxWidth, layoutHeight) { | |
// Title | |
titlePlaceable.placeRelative(...) | |
} |
解决方法
先计算下布局高度
var bottomLayoutViewSize: IntSize by remember { mutableStateOf(IntSize(,0)) } | |
val bottomLayoutBox = { | |
Box( | |
modifier= Modifier.onSizeChanged { bottomLayoutViewSize = it }, | |
content = bottomLayout | |
) | |
} |
保留上标题的固定高度,动态计算最大高度
LocalDensity.current.run { | |
maxHeightPx = 上布局的高度 + 下布局的高度 | |
} | |
重写TopAppBarLayout
为下布局重写TopAppBarLayout,去除里面的无用代码
使用方法和MediumTopAppBar一样,只不过
title变成了topLayout和bottomLayout两个Composable
为了方便实现不同的字体风格和其他布局,可以像掘金一样显示头像和关注。
KnowledgeTopAppBar( | |
topLayout = { | |
Text( | |
modifier = Modifier.padding(.dp), | |
text = "九狼JIULANG", | |
color = CustomTheme.colors.textPrimary, | |
fontSize =.sp, | |
maxLines =, | |
overflow = TextOverflow.Ellipsis, | |
fontWeight = FontWeight.Bold | |
) | |
}, | |
bottomLayout = { | |
Text( | |
modifier = Modifier.padding(vertical =.dp, horizontal = 12.dp), | |
text = "关注 点赞 ", | |
color = CustomTheme.colors.textPrimary, | |
fontSize =.sp, | |
fontWeight = FontWeight.Bold | |
) | |
}, | |
navigationIcon = { | |
}, | |
actions = { | |
}, | |
scrollBehavior = scrollBehavior | |
) |
完整代码
import androidx.compose.animation.core.* | |
import androidx.compose.foundation.gestures.Orientation | |
import androidx.compose.foundation.gestures.draggable | |
import androidx.compose.foundation.gestures.rememberDraggableState | |
import androidx.compose.foundation.layout.* | |
import androidx.compose.material.* | |
import androidx.compose.runtime.* | |
import androidx.compose.ui.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.draw.clipToBounds | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.graphics.graphicsLayer | |
import androidx.compose.ui.platform.LocalDensity | |
import androidx.compose.ui.semantics.clearAndSetSemantics | |
import androidx.compose.ui.text.TextStyle | |
import com.jiulang.wordsfairy.ui.theme.CustomTheme | |
import kotlin.math.abs | |
import kotlin.math.max | |
import kotlin.math.roundToInt | |
import androidx.compose.ui.layout.* | |
import androidx.compose.ui.unit.* | |
import com.google.accompanist.insets.statusBarsPadding | |
fun KnowledgeTopAppBar( | |
modifier: Modifier = Modifier, | |
titleBottomPadding: Dp =.dp, | |
navigationIcon: @Composable () -> Unit, | |
actions: @Composable RowScope.() -> Unit, | |
topLayout: @Composable () -> Unit, | |
bottomLayout: @Composable BoxScope.() -> Unit, | |
pinnedHeight: Dp =.0.dp, | |
scrollBehavior: TopAppBarScrollBehavior | |
){ | |
val pinnedHeightPx: Float | |
val maxHeightPx: Float | |
val titleBottomPaddingPx: Int | |
var bottomLayoutViewSize: IntSize by remember { mutableStateOf(IntSize(,0)) } | |
//计算布局高度 | |
val bottomLayoutBox = { | |
Box( | |
modifier= Modifier.onSizeChanged { bottomLayoutViewSize = it }, | |
content = bottomLayout | |
) | |
} | |
LocalDensity.current.run { | |
pinnedHeightPx = pinnedHeight.toPx() | |
maxHeightPx = bottomLayoutViewSize.height.toFloat() +pinnedHeightPx | |
titleBottomPaddingPx = titleBottomPadding.roundToPx() | |
} | |
// 设置应用程序栏的高度偏移限制以仅隐藏底部标题区域并保留顶部标题 | |
// 折叠时可见。 | |
SideEffect { | |
if (scrollBehavior.state.heightOffsetLimit != pinnedHeightPx - maxHeightPx) { | |
scrollBehavior.state.heightOffsetLimit = pinnedHeightPx - maxHeightPx | |
} | |
} | |
val colorTransitionFraction = scrollBehavior.state.collapsedFraction | |
val appBarContainerColor by rememberUpdatedState(CustomTheme.colors.statusBarColor) | |
val actionsRow = { | |
Row( | |
horizontalArrangement = Arrangement.End, | |
verticalAlignment = Alignment.CenterVertically, | |
content = actions | |
) | |
} | |
val topLayoutAlpha = CubicBezierEasing(.f, 0f, .8f, .15f).transform(colorTransitionFraction) | |
val bottomLayoutAlpha =f - colorTransitionFraction | |
// Hide the top row title semantics when its alpha value goes below.5 threshold. | |
// Hide the bottom row title semantics when the top title semantics are active. | |
val hideTopRowSemantics = colorTransitionFraction <.5f | |
val hideBottomRowSemantics = !hideTopRowSemantics | |
// Set up support for resizing the top app bar when vertically dragging the bar itself. | |
val appBarDragModifier = if (!scrollBehavior.isPinned) { | |
Modifier.draggable( | |
orientation = Orientation.Vertical, | |
state = rememberDraggableState { delta -> | |
scrollBehavior.state.heightOffset = scrollBehavior.state.heightOffset + delta | |
}, | |
onDragStopped = { velocity -> | |
settleAppBar( | |
scrollBehavior.state, | |
velocity, | |
scrollBehavior.flingAnimationSpec, | |
scrollBehavior.snapAnimationSpec | |
) | |
} | |
) | |
} else { | |
Modifier | |
} | |
Surface(modifier = modifier.then(appBarDragModifier), color = appBarContainerColor) { | |
Column { | |
TopAppBarLayout( | |
modifier = Modifier | |
.statusBarsPadding() | |
// 在填充后剪辑,这样不会在插入区域上显示标题 | |
.clipToBounds(), | |
heightPx = pinnedHeightPx, | |
navigationIconContentColor = | |
CustomTheme.colors.mainColor, | |
actionIconContentColor = | |
CustomTheme.colors.mainColor, | |
title = topLayout, | |
titleTextStyle = TextStyle.Default, | |
titleAlpha = topLayoutAlpha, | |
titleVerticalArrangement = Arrangement.Center, | |
titleHorizontalArrangement = Arrangement.Start, | |
titleBottomPadding =, | |
hideTitleSemantics = hideTopRowSemantics, | |
navigationIcon = navigationIcon, | |
actions = actionsRow, | |
) | |
KnowledgeTitleLayout( | |
modifier = Modifier.clipToBounds(), | |
heightPx = maxHeightPx - pinnedHeightPx + scrollBehavior.state.heightOffset, | |
title = bottomLayoutBox, | |
titleTextStyle = TextStyle.Default, | |
titleAlpha = bottomLayoutAlpha, | |
titleVerticalArrangement = Arrangement.Bottom, | |
titleHorizontalArrangement = Arrangement.Start, | |
titleBottomPadding = titleBottomPaddingPx, | |
hideTitleSemantics = hideBottomRowSemantics, | |
) | |
} | |
} | |
} | |
private suspend fun settleAppBar( | |
state: TopAppBarState, | |
velocity: Float, | |
flingAnimationSpec: DecayAnimationSpec<Float>?, | |
snapAnimationSpec: AnimationSpec<Float>? | |
): Velocity { | |
//检查应用程序栏是否完全折叠/展开。如果是,则无需结算应用程序栏, | |
//然后返回零速度。 | |
//请注意,由于collapsedFraction的浮点精度,不用检查f | |
if (state.collapsedFraction <.01f || state.collapsedFraction == 1f) { | |
return Velocity.Zero | |
} | |
var remainingVelocity = velocity | |
//如果有一个初始速度是在前一次用户投掷后留下的,则设置动画以 | |
// 继续运动以展开或折叠应用程序栏。 | |
if (flingAnimationSpec != null && abs(velocity) >f) { | |
var lastValue =f | |
AnimationState( | |
initialValue =f, | |
initialVelocity = velocity, | |
) | |
.animateDecay(flingAnimationSpec) { | |
val delta = value - lastValue | |
val initialHeightOffset = state.heightOffset | |
state.heightOffset = initialHeightOffset + delta | |
val consumed = abs(initialHeightOffset - state.heightOffset) | |
lastValue = value | |
remainingVelocity = this.velocity | |
// 避免舍入错误,如果有任何内容未被使用,则停止 | |
if (abs(delta - consumed) >.5f) this.cancelAnimation() | |
} | |
} | |
// 如果提供了动画规格,则捕捉。 | |
if (snapAnimationSpec != null) { | |
if (state.heightOffset < && | |
state.heightOffset > state.heightOffsetLimit | |
) { | |
AnimationState(initialValue = state.heightOffset).animateTo( | |
if (state.collapsedFraction <.5f) { | |
f | |
} else { | |
state.heightOffsetLimit | |
}, | |
animationSpec = snapAnimationSpec | |
) { state.heightOffset = value } | |
} | |
} | |
return Velocity(f, remainingVelocity) | |
} | |
private fun TopAppBarLayout( | |
modifier: Modifier, | |
heightPx: Float, | |
navigationIconContentColor: Color, | |
actionIconContentColor: Color, | |
title: @Composable () -> Unit, | |
titleTextStyle: TextStyle, | |
titleAlpha: Float, | |
titleVerticalArrangement: Arrangement.Vertical, | |
titleHorizontalArrangement: Arrangement.Horizontal, | |
titleBottomPadding: Int, | |
hideTitleSemantics: Boolean, | |
navigationIcon: @Composable () -> Unit, | |
actions: @Composable () -> Unit, | |
) { | |
Layout( | |
{ | |
Box( | |
Modifier | |
.layoutId("navigationIcon") | |
.padding(start = TopAppBarHorizontalPadding) | |
) { | |
CompositionLocalProvider( | |
LocalContentColor provides navigationIconContentColor, | |
content = navigationIcon | |
) | |
} | |
Box( | |
Modifier | |
.layoutId("title") | |
.padding(horizontal = TopAppBarHorizontalPadding) | |
.then(if (hideTitleSemantics) Modifier.clearAndSetSemantics { } else Modifier) | |
.graphicsLayer(alpha = titleAlpha) | |
) { | |
ProvideTextStyle(value = titleTextStyle) { | |
CompositionLocalProvider( | |
content = title | |
) | |
} | |
} | |
Box( | |
Modifier | |
.layoutId("actionIcons") | |
.padding(end = TopAppBarHorizontalPadding) | |
) { | |
CompositionLocalProvider( | |
LocalContentColor provides actionIconContentColor, | |
content = actions | |
) | |
} | |
}, | |
modifier = modifier | |
) { measurables, constraints -> | |
val navigationIconPlaceable = | |
measurables.first { it.layoutId == "navigationIcon" } | |
.measure(constraints.copy(minWidth =)) | |
val actionIconsPlaceable = | |
measurables.first { it.layoutId == "actionIcons" } | |
.measure(constraints.copy(minWidth =)) | |
val maxTitleWidth = if (constraints.maxWidth == Constraints.Infinity) { | |
constraints.maxWidth | |
} else { | |
(constraints.maxWidth - navigationIconPlaceable.width - actionIconsPlaceable.width) | |
.coerceAtLeast() | |
} | |
val titlePlaceable = | |
measurables.first { it.layoutId == "title" } | |
.measure(constraints.copy(minWidth =, maxWidth = maxTitleWidth)) | |
// Locate the title's baseline. | |
val titleBaseline = | |
if (titlePlaceable[LastBaseline] != AlignmentLine.Unspecified) { | |
titlePlaceable[LastBaseline] | |
} else { | |
} | |
val layoutHeight = heightPx.roundToInt() | |
layout(constraints.maxWidth, layoutHeight) { | |
// Navigation icon | |
navigationIconPlaceable.placeRelative( | |
x =, | |
y = (layoutHeight - navigationIconPlaceable.height) / | |
) | |
// Title | |
titlePlaceable.placeRelative( | |
x = when (titleHorizontalArrangement) { | |
Arrangement.Center -> (constraints.maxWidth - titlePlaceable.width) / | |
Arrangement.End -> | |
constraints.maxWidth - titlePlaceable.width - actionIconsPlaceable.width | |
// Arrangement.Start. | |
// An TopAppBarTitleInset will make sure the title is offset in case the | |
// navigation icon is missing. | |
else -> max(.dp.roundToPx(), navigationIconPlaceable.width) | |
}, | |
y = when (titleVerticalArrangement) { | |
Arrangement.Center -> (layoutHeight - titlePlaceable.height) / | |
// Apply bottom padding from the title's baseline only when the Arrangement is | |
// "Bottom". | |
Arrangement.Bottom -> | |
if (titleBottomPadding ==) layoutHeight - titlePlaceable.height | |
else layoutHeight - titlePlaceable.height - max(, | |
titleBottomPadding - titlePlaceable.height + titleBaseline | |
) | |
// Arrangement.Top | |
else -> | |
} | |
) | |
// Action icons | |
actionIconsPlaceable.placeRelative( | |
x = constraints.maxWidth - actionIconsPlaceable.width, | |
y = (layoutHeight - actionIconsPlaceable.height) / | |
) | |
} | |
} | |
} | |
private fun KnowledgeTitleLayout( | |
modifier: Modifier, | |
heightPx: Float, | |
title: @Composable () -> Unit, | |
titleTextStyle: TextStyle, | |
titleAlpha: Float, | |
titleVerticalArrangement: Arrangement.Vertical, | |
titleHorizontalArrangement: Arrangement.Horizontal, | |
titleBottomPadding: Int, | |
hideTitleSemantics: Boolean, | |
) { | |
Layout( | |
{ | |
Box( | |
Modifier | |
.layoutId("title") | |
.then(if (hideTitleSemantics) Modifier.clearAndSetSemantics { } else Modifier) | |
.graphicsLayer(alpha = titleAlpha) | |
) { | |
ProvideTextStyle(value = titleTextStyle) { | |
CompositionLocalProvider( | |
content = title | |
) | |
} | |
} | |
}, | |
modifier = modifier | |
) { measurables, constraints -> | |
val maxTitleWidth = constraints.maxWidth | |
val titlePlaceable = | |
measurables.first { it.layoutId == "title" } | |
.measure(constraints.copy(minWidth =, maxWidth = maxTitleWidth)) | |
val layoutHeight =heightPx.roundToInt() | |
layout(maxTitleWidth, layoutHeight) { | |
// Title | |
titlePlaceable.placeRelative( | |
x = when (titleHorizontalArrangement) { | |
Arrangement.Center -> (constraints.maxWidth - titlePlaceable.width) / | |
Arrangement.End -> | |
constraints.maxWidth - titlePlaceable.width | |
else -> max(.dp.roundToPx(), 0.dp.roundToPx()) | |
}, | |
y = when (titleVerticalArrangement) { | |
Arrangement.Center -> (layoutHeight - titlePlaceable.height) / | |
// Apply bottom padding from the title's baseline only when the Arrangement is | |
// "Bottom". | |
Arrangement.Bottom -> | |
if (titleBottomPadding ==) layoutHeight - titlePlaceable.height | |
else layoutHeight - titlePlaceable.height - max(, | |
titleBottomPadding - titlePlaceable.height | |
) | |
// Arrangement.Top | |
else -> | |
} | |
) | |
} | |
} | |
} | |
private val TopAppBarHorizontalPadding =.dp |