compose使用入门:做一个丝滑的可展开列表

前言

效果预览

在开始之前,先看看最终的实现效果:

preview1

需求确定

不久之前,我使用 compose 做了一个 TODO 应用,其中有一个设置页面。

不过在 compose 中没有类似 PreferenceFragment 的东西,所以我们需要自己实现一个。

后来一想,既然都要自己实现了,为什么还要照着 PreferenceFragment 写呢?

所以我决定做一个可以展开的菜单列表效果。

最终实现如上图所示。

开始实现

实现思路

根据需求,我们想要的是一个列表,点击列表后展开隐藏的子列表,再次点击继续隐藏。

显而易见的,我们首先想到的当然是使用 Column 嵌套标题和子列表:

@Preview(showSystemUi = true)
@Composable
fun PreviewExpandableItem2() {
    Column {
        Text(text = "我是标题")
        
        Text(
            text = "我是子内容",
            // 为了美观,给子内容加个 padding
            modifier = Modifier.padding(start = 8.dp)
        )
    }
}

preview2

大致样式是这么没错,那么下一步就是添加隐藏和显示子内容的状态,并给标题控件添加点击回调:

@Preview(showSystemUi = true)
@Composable
fun PreviewExpandableItem2() {
    // 是否显示子内容的状态
    var isShowSubItem by remember { mutableStateOf(false) }


    Column {
        Text(text = "我是标题", modifier = Modifier.clickable {
            // 点击标题则将状态取反
            isShowSubItem = !isShowSubItem
        })

        if (isShowSubItem) { // 只有 isShowSubItem 为 true 才显示子内容
            Text(
                text = "我是子内容",
                // 为了美观,给子内容加个 padding
                modifier = Modifier.padding(start = 8.dp)
            )
        }
    }
}

preview3

似乎更加有感觉了。

但是总觉得标题的右边空空的,或许可以加上一个箭头来指示列表展开状态。

这里我们把原本的标题 Text 包裹到一个 Row 中,并设置水平对齐方式为 Arrangement.SpaceBetween 即让子项在水平上平均分布,并且前后不留空格,形如: 1##2##3

然后再把点击回调从标题 Text 改到 Row 上。

最后在 Row 中,Text 后添加一个 Icon , 并且给 Icon 按照当前是否展开添加一个旋转 90° 的修饰符

Modifier.rotate(if (isShowSubItem) 90f else 0f)

这里的 rotate 表示把修饰的组件按照中心旋转指定的度数,正数表示顺时针旋转,负数表示逆时针旋转。

修改后代码如下:

Row(
    horizontalArrangement = Arrangement.SpaceBetween,
    modifier = Modifier
        .fillMaxWidth()
        .clickable {
            isShowSubItem = !isShowSubItem
        }
) {
    Text(text = "我是标题")
    Icon(
        Icons.Outlined.ArrowRight,
        contentDescription = "我是箭头",
        modifier = Modifier.rotate(if (isShowSubItem) 90f else 0f)
    )
}

preview4

好,我想要就是这种效果,但是感觉有点生硬啊,对了,可以加上动画效果和亿点细节。

加一点动画

简单观察这个组件,可以加动画的地方有两个:

  1. 箭头的旋转动画
  2. 子列表的显示和隐藏动画

至于怎么选择动画类型,可以参考谷歌官方的这篇选择指南:

animation-flowchart.svg

箭头旋转动画

根据指南,我们想要实现的箭头旋转动画属于基于状态的(isShowSubItem),并且不是无限的动画(只需要基于状态进行有限的动画)、不需要同时为多个值设置动画(我们只需要设置旋转角度)。

所以我们应该选择 animate*AsState 动画,其中的 * 可以是多种数据类型,因为 Modifier.rotate() 的参数是 float 类型,所以我们使用 animateFloatAsState 动画:

val arrowRotateDegrees: Float by animateFloatAsState(if (isShowSubItem) 90f else 0f)

animateFloatAsState 的参数 targetValue 顾名思义,就是目标值,当这个目标值改变时,compose 会自动开始动画,把 arrowRotateDegrees 的当前值按照动画效果逐渐变为 targetValue

将这个 arrowRotateDegrees 作为参数传递给 rotate() 即可, 当动画开始运行,conpose 会自动重组使用到 arrowRotateDegrees 的组件。

最终代码如下:

@Preview(showSystemUi = true)
@Composable
fun PreviewExpandableItem2() {
    // 是否显示子内容的状态
    var isShowSubItem by remember { mutableStateOf(false) }

    // 定义旋转动画
    val arrowRotateDegrees: Float by animateFloatAsState(if (isShowSubItem) 90f else 0f)

    Column {
        Row(
            horizontalArrangement = Arrangement.SpaceBetween,
            modifier = Modifier
                .fillMaxWidth()
                .clickable {
                    isShowSubItem = !isShowSubItem
                }
        ) {
            Text(text = "我是标题")
            Icon(
                Icons.Outlined.ArrowRight,
                contentDescription = "我是箭头",
                modifier = Modifier.rotate(arrowRotateDegrees)
            )
        }


        if (isShowSubItem) { // 只有 isShowSubItem 为 true 才显示子内容
            Text(
                text = "我是子内容",
                // 为了美观,给子内容加个 padding
                modifier = Modifier.padding(start = 8.dp)
            )
        }
    }
}

效果如下:

preview5

可以明显的看到,箭头的旋转不再是干巴巴的硬切,而是带有旋转过程的动画效果了。

子列表显隐动画

依然是按照官方指南,显然,子列表显隐动画属于显示和隐藏动画,所以应该使用 AnimatedVisibility

AnimatedVisibility 的参数很简单:

@Composable
fun ColumnScope.AnimatedVisibility(
    visible: Boolean,
    modifier: Modifier = Modifier,
    enter: EnterTransition = fadeIn() + expandVertically(),
    exit: ExitTransition = fadeOut() + shrinkVertically(),
    label: String = "AnimatedVisibility",
    content: @Composable AnimatedVisibilityScope.() -> Unit
)

虽然有多个参数,但是我们这里只需要关心

visible : 用于控制是否显示

enter : 由隐藏转为显示时的动画效果,默认为 淡入(fadeIn)+垂直展开(expandVertically)

exit : 由显示转为隐藏时的动画效果,默认为 淡出(fadeOut)+垂直收缩(shrinkVertically)

由于默认的动画效果恰好就是我们想要的效果,所以我们只需要设置 visibleisShowSubItem 即可。

然后把所有子项包裹进 AnimatedVisibility ,为了看起来更明显,我多复制了几个子项:

AnimatedVisibility(visible = isShowSubItem) {
    Column {
        Text(
            text = "我是子内容1",
            modifier = Modifier.padding(start = 8.dp)
        )
        Text(
            text = "我是子内容2",
            modifier = Modifier.padding(start = 8.dp)
        )
        Text(
            text = "我是子内容3",
            modifier = Modifier.padding(start = 8.dp)
        )
    }
}

效果如下:

preview6

这下是不是看起来顺眼多了?

抽出参数

虽然基本效果已经到达我们的需求了,但是肯定不能直接这样写啊,我们应该把它抽出成一个函数,方便复用。

并且再给它加亿点点小细节,便得到了这个函数:

/**
 * 可展开的列表
 * 
 * @param title 列表标题
 * @param modifier Modifier
 * @param endText 列表标题的尾部文字,默认为空
 * @param subItemStartPadding 子项距离 start 的 padding 值
 * @param subItem 子项
 * */
@Composable
fun ExpandableItem(
    title: String,
    modifier: Modifier = Modifier,
    endText: String = "",
    subItemStartPadding: Int = 8,
    subItem: @Composable () -> Unit
) {
    var isShowSubItem by remember { mutableStateOf(false) }

    val arrowRotateDegrees: Float by animateFloatAsState(if (isShowSubItem) 90f else 0f)

    Column(modifier = modifier) {
        Row(
            horizontalArrangement = Arrangement.SpaceBetween,
            modifier = Modifier
                .fillMaxWidth()
                .clickable {
                    isShowSubItem = !isShowSubItem
                }
        ) {
            Text(text = title)
            Row {
                if (endText.isNotBlank()) {
                    Text(text = endText,
                        modifier = modifier.padding(end = 4.dp).widthIn(0.dp, 100.dp),
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis)
                }
                Icon(
                    Icons.Outlined.ArrowRight,
                    contentDescription = title,
                    modifier = Modifier.rotate(arrowRotateDegrees)
                )
            }
        }

        AnimatedVisibility(visible = isShowSubItem) {
            Column(modifier = Modifier.padding(start = subItemStartPadding.dp)) {
                subItem()
            }
        }
    }
}

最后,再看一下我在项目中实际使用的效果:

preview1

总结

compose 的基础组件基本涵盖了所有的基本需求,即使是没有的组件我们也可以很快速的使用已有基础组件组合出我们需要的组件效果。

另外,compose 的动画创建相比于传统 view 方便了许多,例如参数值改变的动画,现在只需要使用 animate*AsState 创建一个带动画的参数,再放到需要动画的地方即可,完全不需要其他多余的操作。