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

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

其时该控件运用了轻量级自定义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的布局顶出去了:

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

@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扩大到甚至在不属于自己的布局中展现内容,盖住了另一个布局。

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

而本动画用不上丈量文本得到的大量信息,因而我挑选改动展现份额。

在官方的动画库中,并没有内置关于字体巨细Sp的动画函数,从中也能够猜测官方的态度了:不建议运用字体巨细做动画。

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

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在上边和下边,它的效果便是选项的起始方位和结尾方位占位,它们无法被选中。

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

上下占位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值。

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

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在起始方位往上滑的时分如下图所示。

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

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

这个环节能够分为三步:

  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,在放开拖拽的时分,巨细会有频频的闪烁。

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

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

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

有懂的能够谈论区或私信告诉我为什么吗?

总结

经过本次探究,我们对LazyColumn的运用应该更加了解了。也用一百多行做出了看起来还挺不错的滑动挑选控件。假如无法做出来,能够结合代码一同看github.com/MReP1/Littl…

由本事例能够看到,Compose在自定义组件上十分简单、代码十分简练,这是View不可比较的。若对我们有帮助,我们无妨点个赞。

参考

轻量级自定义NumberPicker