【Compose】LazyColumn自定义一个滚动选择控件

2,832 阅读7分钟

小鹅事务所在从View转换成Compose的时候,碰到了一个滑动选择控件如下:

滑动动画.gif

当时该控件使用了轻量级自定义NumberPicker,非常好用。但是在Compose中如何从0实现该控件呢?

建议在观看本文章的时候结合代码一起观看github.com/MReP1/Littl…

FontSize / Scale

在选择项滚动到中间时,中间的选择项会放大,上面的选择项就会变小。需要改变展示文本的大小,这个时候有两个可选项,分别有不同的特点:

  1. 改变FontSize字体大小,动画生硬、测量文本耗时较长。
  2. 改变显示比例大小,动画丝滑,无法准确获取改变比例后字的布局大小。

FontSize字体尺寸

由于在改变字体尺寸的时候改变FontSize,会造成一次文本布局测量。文本测量是用于测量在某个TextStyle时文本的布局、段落信息等等。

val textMeasure = rememberTextMeasurer()
// 测量字布局的耗时
measureTimeMillis {
    val result = textMeasure.measure(items[index], textStyle)
}

在滑动动画过程中会改变很多次字体大小,因此也会引入大量字体测量的耗时。举个例子:如果测量“2023”字符串平均需要0.6ms(仅供参考),在动画过程中有两个选项大小变更,因此需要测量两次,可能会引入1-2ms的测量耗时。

而60帧手机只要在主线程超过16.6ms还未完成渲染就会掉帧、120帧手机为8.3ms。

主线程的计算是十分昂贵的,因此个人建议:最好不要在主线程做字体大小变更的动画。

Scale比例

改变显示比例无需引入动画过程中产生的测量耗时。

但是这个方式有一个缺点,就是无法获得当前文本的布局尺寸,如果设置的数值大于1有可能会显示出界,在需要获取当前文本的布局情况下无法使用这种方式。

怎么理解这句话,可以看看以下两个案例,在一个Row布局中放两个Text,前一个Text做一个放大的动画:

@Composable
fun TestTextAnimation() {
    var isExpended by remember { mutableStateOf(false) }
    val fontScale by animateFloatAsState(targetValue = if (isExpended) 2F else 1F)
    Row(
        modifier = Modifier
            .fillMaxHeight()
            .wrapContentWidth()
            .clickable { isExpended = !isExpended },
        verticalAlignment = Alignment.CenterVertically
    ) {
        // 修改fontSize
        Text(
            text = "Hello World!",
            fontSize = 14.sp * fontScale
        )
        Text(text = "我被顶出去啦")
    }
}

本案例的效果如图所示,后一个Text被前一个Text的布局顶出去了:

顶出去动画.gif

@Composable
fun TestTextScaleAnimation() {
    var isExpended by remember { mutableStateOf(false) }
    val fontScale by animateFloatAsState(targetValue = if (isExpended) 2F else 1F)
    Row(
        modifier = Modifier
            .fillMaxHeight()
            .wrapContentWidth()
            .clickable { isExpended = !isExpended },
        verticalAlignment = Alignment.CenterVertically
     ) {
        // 修改Scale
        Text(
            text = "Hello World!",
            fontSize = 14.sp,
            modifier = Modifier.scale(fontScale)
        )
        Text(text = "我被覆盖啦")
    }
}

效果如图所示,前一个Text放大到甚至在不属于自己的布局中展示内容,盖住了另一个布局。

覆盖动画.gif

而本动画用不上测量文本得到的大量信息,因此我选择改变展示比例。

在官方的动画库中,并没有内置关于字体大小Sp的动画函数,从中也可以猜测官方的态度了:不建议使用字体大小做动画。

b8ed6d00848be596df55713582d47b7.png

LazyColumn

在选择控件上,我毫不犹豫选择了LazyColumn,它实在太符合这个控件了,它的特性有很多介绍,类似于RecyclerView,我就不多说了。

然后很自然而然地写出这个控件的UI代码,如下所示:

LazyColumn(
    modifier = Modifier...
) {
    item {
        Spacer(modifier = Modifier.size(...))
    }
    items(
        count = items.size,
        key = { items[it] }
    ) { index ->
        Box(
            modifier = Modifier....,
            contentAlignment = Alignment.Center
        ) {
            Text(
                text = items[index],
                style = textStyle,
                modifier = Modifier
                    .scale(
                        when (index) {
                            firstVisibleItemIndex -> scrollingOutScale.value
                            firstVisibleItemIndex + 1 -> scrollingInScale.value
                            else -> currentUnselectedScale
                        }
                    )
            )
        }
    }
    item {
        Spacer(modifier = Modifier.size(...))
    }
}

这里分别需要两个Spacer在上边和下边,它的作用就是选项的起始位置和终点位置占位,它们无法被选中。

image_-HxQRkyqrs.png

上下占位Spacer

这个Spacer的高度如何获取呢?还记得前面的测量文本尺寸的代码吗?只需要测量一次,并将该尺寸给到两个Spacer就好。

val textMeasurer = rememberTextMeasurer()
val contentHeight = remember(textStyle, padding) {
    // 测量一个空字符串的尺寸,并获取高度
    val textContentHeight = textMeasurer.measure("", textStyle).size.height
    val topPadding = padding.calculateTopPadding()
    val bottomPadding = padding.calculateBottomPadding()
    with(density) { (topPadding + bottomPadding).toPx() } + textContentHeight
}

然后这里就定下了LazyColumnSpacer的尺寸了,前者为3个Item的高度,后者为一个Item的高度

LazyColumn(
    modifier = Modifier.height(
        with(density) { (contentHeight * 3).toDp() }
    ),
    state = state
) {
    item {
        Spacer(
            modifier = Modifier.size(
                width = 42.dp,
                height = with(density) {
                    contentHeight.toDp()
                }
            )
        )
    }
    ...
}

动画

在滑动过程中,下方的选项会放大、上方的选项会缩小,所以需要两个会变化的scale值。

上下滑动.gif

val scrollingOutScale = remember { mutableStateOf(selectedScale) }
val scrollingInScale = remember { mutableStateOf(unselectedScale) }

selectedScaleunselectedScale为外部传入配置值,前者一般比后者大。

然后在滑动的时候需要监听首个可见选项的偏移值,对这两个scale进行变更。


snapshotFlow { state.firstVisibleItemScrollOffset }
    .onEach { firstVisibleItemScrollOffset ->
        // 滑动时,根据滑动距离计算缩放比例
        
        // 1. 当前滑动进度百分比
        val progress = firstVisibleItemScrollOffset.toFloat() / contentHeight
        // 2. 需要调整的比例大小
        val disparity = (currentSelectedScale - currentUnselectedScale) * progress
        // 3. 由于往上滑是缩小的,因此需要减去调整的比例
        scrollingOutScale.value = currentSelectedScale - disparity
        // 4. 反之增大
        scrollingInScale.value = currentUnselectedScale + disparity
    }.launchIn(this)

由于上下Spacer不需要放大缩小,因此只需要对有内容的选项做scale操作就好了。

items(
    count = items.size,
    key = { items[it] }
) { index ->
    Box(...) {
        Text(
            text = items[index],
            style = textStyle,
            modifier = Modifier
                .scale(
                    when (index) {
                        firstVisibleItemIndex -> scrollingOutScale.value
                        firstVisibleItemIndex + 1 -> scrollingInScale.value
                        else -> currentUnselectedScale
                    }
                )
                .padding(padding)
        )
    }
}

注意此处可能会有些误解,看到这里可能会觉得,Spacer不是占了第一个格子吗?滑动用scrollingOutScale缩小的选项应该是第二个Index而不是firstVisibleItemIndex。因此还需要加一个1。

但是仔细想想,ItemsindexLazyColumn是从位置1开始,但是实际上回调出来的index是从0开始,也就是说和前者抵消掉了。

举个例子,当index在起始位置往上滑的时候如下图所示。

Frame 10.png

滑动放大缩小的动画模块就做完了,剩下的是滑动完之后复位了。

这个环节可以分为三步:

  1. 获取滑动事件,并在滑动事件为Stop或者Cancel时判断该滑动事件的Start是否属于刚刚存下的滑动事件。
  2. 若是,则监听是否滑动中,由于惯性原因会继续滑动一会,因此需要等到非滑动的时机再进行下一步
  3. 计算当前滑动距离处于哪个位置,若小于单个选项的一半,则回滚到上一个选项,若大于单个选项的一半,则滚动到下一个选项。

理清思路之后,可以轻易用Flow写下如下逻辑。

launch {
    var lastInteraction: Interaction? = null
    state.interactionSource.interactions.mapNotNull {
        it as? DragInteraction
    }.map { interaction ->
        // 滑动结束或取消时,判断是否需要复位
        val currentStart = (interaction as? DragInteraction.Stop)?.start
            ?: (interaction as? DragInteraction.Cancel)?.start
        val needReset = currentStart == lastInteraction
        lastInteraction = interaction
        needReset
    }.combine(snapshotFlow { state.isScrollInProgress }) { needReset, isScrollInProgress ->
        needReset && !isScrollInProgress
    }.filter {
        it
    }.collectLatest {
        val halfHeight = contentHeight / 2
        val selectedIndex = if (state.firstVisibleItemScrollOffset < halfHeight) {
            // 若滑动距离小于一半,则回滚到上一个item
            firstVisibleItemIndex
        } else {
            // 若滑动距离大于一半,则滚动到下一个item
            firstVisibleItemIndex + 1
        }
        if (selectedIndex < items.size) {
            onItemSelected(selectedIndex, items[selectedIndex])
        }
        state.animateScrollToItem(selectedIndex)
    }
}

题外话

对了,在选项中我使用了一个firstVisibleItemIndex变量,它从state.firstVisibleItemIndex来,但是又没直接用,直接用的话会提示频繁改变的state不要直接在Composable函数中使用。

实际用下来会有不符合预期的BUG,在放开拖拽的时候,大小会有频繁的闪烁。

image_NBdeSGTDl1.png

因此我将它存在本地,监听并赋值,BUG神奇地消失了。

image_t-eRwUtWxD.png

有懂的可以评论区或私信告诉我为什么吗?

总结

经过本次探索,大家对LazyColumn的使用应该更加了解了。也用一百多行做出了看起来还挺不错的滑动选择控件。如果无法做出来,可以结合代码一起看github.com/MReP1/Littl…

由本案例可以看到,Compose在自定义组件上非常简单、代码非常简洁,这是View不可比拟的。若对大家有帮助,大家不妨点个赞。

参考

轻量级自定义NumberPicker