Jetpack Compose 实现下拉动态渐变切换布局-灵析社区

江江说技术

标题写的好像有点抽象了,上个效果图看看

总的来说,就是 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 ->
}

然后就是根据 SwipeableStatecurrentOffset 动态计算前景的高度了,这部分的代码如下:

    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->0Foreground0.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