标题写的好像有点抽象了,上个效果图看看
总的来说,就是 A 页面下拉拖动时,B 页面开始从某一位置淡出,直至覆盖全屏。最终实现的布局,格式化后的代码不超过 70 行,一定程度上也体现出 Jetpack Compose 中自定义布局的简洁性。
您可以到 这里 下载 APK 体验
如图所见,我的小应用《译站》 最近做了次 UI 升级,希望参考谷歌翻译实现 “下拉打开历史记录” 的操作。具体来说,页面的变化如下:
开始时:
页面有一个 Main
,其中又分为了 Upper
(上面的背景)和 Lower
(下面的功能栏) 两个部分,当开始拖动时,前景的初始大小为 MainUpper
的大小(与它重叠),随着用户的拖动逐渐展开,渐渐淡出;同时背景渐渐隐去,直到最后完成切换。
通过思考要求,我们发现,要知道前景的大小,需要先知道 MainUpper
的大小。这种“子布局大小需要依赖其他子布局来确定”的情景,意味着我们需要使用 SubcomposeLayout 。如果你对此不了解,可以参考 SubcomposeLayout | 你好 Compose
那么首先,写个大致的框架出来:
@Composable
fun SwipeCrossFadeLayout(
modifier: Modifier = Modifier,
mainUpper: @Composable () -> Unit,
mainLower: @Composable () -> Unit,
foreground: @Composable () -> Unit,
) {
SubcomposeLayout(modifier = modifier) { constraints ->
layout(..., ...) {
}
}
}
接下来就是测量。因为我的特殊需求,我希望 MainLower
先被测量,之后 MainUpper
再填满剩下的空间。最后才是根据 MainUpper
的大小测量 Foreground
。写几个变量用于保存几个大小,编写代码如下:
@Composable
fun SwipeCrossFadeLayout(
modifier: Modifier = Modifier,
mainUpper: @Composable () -> Unit,
mainLower: @Composable () -> Unit,
foreground: @Composable () -> Unit,
) {
var containerHeight by remember { mutableStateOf(100) } // 容器的高度,最初设为 100
var mainUpperHeight by remember { mutableStateOf(0) } // 背景的上半部分的高度,最初设为 0
var lowerPartHeight by remember { mutableStateOf(100) } // 背景的下半部分的高度,最初设为 100
SubcomposeLayout(modifier = modifier) { constraints ->
containerHeight = constraints.maxHeight // 获取容器的最大高度作为 containerHeight
// 先通过 subcompose 和 measure 方法对背景的下半部分进行测量
val mainLowerPlaceable = subcompose(MainLowerKey, mainLower).first().measure(constraints.copy(
minWidth = 0,
minHeight = 0
))
lowerPartHeight = mainLowerPlaceable.height // 记录背景的下半部分的高度
// 再通过 subcompose 和 measure 方法对背景的上半部分进行测量
val mainUpperPlaceable = subcompose(MainUpperKey, mainUpper).first().measure(constraints.copy(
minWidth = 0,
minHeight = 0,
maxHeight = constraints.maxHeight - lowerPartHeight
// 高度设为容器最大高度减去下半部分的高度
))
mainUpperHeight = mainUpperPlaceable.height // 记录背景的上半部分的高度
layout(..., ...) {
}
}
}
到这里,Main
的大小就算完了,接下来就是计算 Foreground
部分的大小了,而这,就需要结合当前拖动的位置动态计算。
幸运的是,优秀的 Jetpack Compose 已经为我们提供了 swipeable
修饰符,利用它就可以轻松实现“带有回弹和动画的拖动”效果。如果你不了解,可以参考:滑动(Swipeable) | 你好 Compose。
考虑到外部应该可以控制前景的显示与关闭,我们把对应的状态放到参数上:
@Composable
fun SwipeCrossFadeLayout(
modifier: Modifier = Modifier,
state: SwipeableState<SwipeShowType> = rememberSwipeableState(SwipeShowType.Main),
mainUpper: @Composable () -> Unit,
mainLower: @Composable () -> Unit,
foreground: @Composable () -> Unit,
)
然后添加 swipeable
修饰符:
enum class SwipeShowType {
Main,
Foreground
}
SubcomposeLayout(
modifier = modifier.swipeable(
state = state, // 使用 SwipeableState 管理可滑动状态
// anchors 参数定义了滑动到各个位置时触发哪些状态
anchors = mapOf(
0f to SwipeShowType.Main, // 滑动到 0 时显示背景的上半部分
lowerPartHeight.toFloat() to SwipeShowType.Foreground // 滑动到 lowerPartHeight 时显示前景
),
orientation = Orientation.Vertical,
thresholds = { _, _ -> FractionalThreshold(0.5f) } // 设置触发阈值为 0.5f
)
) { constraints ->
}
然后就是根据 SwipeableState
的 currentOffset
动态计算前景的高度了,这部分的代码如下:
SubcomposeLayout(modifier = modifier) { constraints ->
// ...
val progress = (state.offset.value / lowerPartHeight).coerceIn(0f, 1f) // 计算当前滑动进度,progress 的值在 0 到 1 之间
// 根据滑动进度计算前景的高度
val foregroundHeight = mainUpperHeight + progress * lowerPartHeight
// 测量时固定高度
val foregroundPlaceable = subcompose(ForegroundKey, foreground).first().measure(
constraints.copy(
minWidth = constraints.minWidth,
minHeight = foregroundHeight.toInt(),
maxWidth = constraints.maxWidth,
maxHeight = foregroundHeight.toInt()
)
)
layout(..., ...) {
}
}
最后就是 layout
代码的实现,由于要改变 alpha
,因此选用 placeWithLayer
实现。有了上面的 progress
,摆放时只需要将 Main
的透明度从 1->0
,Foreground
从 0.5->1
(选择 0.5 而不是 0 开始,是因为 0 开始的话,最初的阶段实在看不见)。
layout(constraints.maxWidth, constraints.maxHeight) {
if (progress != 1f) {
// 如果滑动进度不为 1,则渐变消失背景的上半部分和下半部分
mainUpperPlaceable.placeRelativeWithLayer(0, 0) {
alpha = 1f - progress
}
mainLowerPlaceable.placeRelativeWithLayer(0, containerHeight - lowerPartHeight) {
alpha = 1f - progress
}
}
if (progress > 0.01f) {
// 如果滑动进度大于 0.01,则渐变显示前景
foregroundPlaceable.placeRelativeWithLayer(0, 0) {
alpha = lerp(0.5f, 1f, progress)
// shadowElevation = if (progress == 1f) 0f else 8f
}
}
}
你可能注意到,上面的代码加了 if
作为判断,这是因为我希望当切换完成后,整个 Composable 只显示 前景 或者 背景 之一。
其实到这里,这个布局已经基本可用了。但是,由于我的前景为列表,因此当前景展开后,由于滑动事件被列表消费了,因此没法上滑关闭。因此这里我们要手动处理下嵌套滑动的问题。
Jetpack Compose 为嵌套滑动提供了特别的修饰符 .nestedScroll(nestedScrollConnection)
,具体介绍可以参考 嵌套滑动(NestedScroll) | 你好 Compose。在这里,由于我们要将“列表滑动后多余的偏移量给父布局消费”,因此重写 postScroll
方法,代码如下:
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
// 因为 HistoryScreen 是列表,如果滑到底部仍然有多余的滑动距离,就关闭
// Log.d("NestedScrollConnection", "onPostScroll: $available")
if (!swipeableState.isAnimationRunning
&& source == NestedScrollSource.Drag
&& available.y < -30.0f
) {
scope.launch {
swipeableState.animateTo(SwipeShowType.Main)
}
return Offset(0f, available.y)
}
return super.onPostScroll(consumed, available, source)
}
}
}
然后就完成了~
本文的代码可以在 FunnyTranslation/SwipeCrossFadeLayout.kt 找到。作为我个人持续维护的 Jetpack Compose 开源项目,译站最近完成了一次 UI 大更新,除了本文提到的,还有其他有趣的效果。比如下面这个文字动画:
码里还有一些花里胡哨的效果,比如 使用 Jetpack Compose 做一个年度报告页面 里展示的。
阅读量:1551
点赞量:0
收藏量:0