小鹅事务所在从View转换成Compose的时候,碰到了一个滑动选择控件如下:
当时该控件使用了轻量级自定义NumberPicker,非常好用。但是在Compose中如何从0实现该控件呢?
建议在观看本文章的时候结合代码一起观看github.com/MReP1/Littl…
FontSize / Scale
在选择项滚动到中间时,中间的选择项会放大,上面的选择项就会变小。需要改变展示文本的大小,这个时候有两个可选项,分别有不同的特点:
- 改变FontSize字体大小,动画生硬、测量文本耗时较长。
- 改变显示比例大小,动画丝滑,无法准确获取改变比例后字的布局大小。
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的布局顶出去了:
@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放大到甚至在不属于自己的布局中展示内容,盖住了另一个布局。
而本动画用不上测量文本得到的大量信息,因此我选择改变展示比例。
在官方的动画库中,并没有内置关于字体大小Sp的动画函数,从中也可以猜测官方的态度了:不建议使用字体大小做动画。
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在上边和下边,它的作用就是选项的起始位置和终点位置占位,它们无法被选中。
上下占位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
}
然后这里就定下了LazyColumn和Spacer的尺寸了,前者为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值。
val scrollingOutScale = remember { mutableStateOf(selectedScale) }
val scrollingInScale = remember { mutableStateOf(unselectedScale) }
selectedScale和unselectedScale为外部传入配置值,前者一般比后者大。
然后在滑动的时候需要监听首个可见选项的偏移值,对这两个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。
但是仔细想想,Items的index在LazyColumn是从位置1开始,但是实际上回调出来的index是从0开始,也就是说和前者抵消掉了。
举个例子,当index在起始位置往上滑的时候如下图所示。
滑动放大缩小的动画模块就做完了,剩下的是滑动完之后复位了。
这个环节可以分为三步:
- 获取滑动事件,并在滑动事件为
Stop或者Cancel时判断该滑动事件的Start是否属于刚刚存下的滑动事件。 - 若是,则监听是否滑动中,由于惯性原因会继续滑动一会,因此需要等到非滑动的时机再进行下一步
- 计算当前滑动距离处于哪个位置,若小于单个选项的一半,则回滚到上一个选项,若大于单个选项的一半,则滚动到下一个选项。
理清思路之后,可以轻易用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,在放开拖拽的时候,大小会有频繁的闪烁。
因此我将它存在本地,监听并赋值,BUG神奇地消失了。
有懂的可以评论区或私信告诉我为什么吗?
总结
经过本次探索,大家对LazyColumn的使用应该更加了解了。也用一百多行做出了看起来还挺不错的滑动选择控件。如果无法做出来,可以结合代码一起看github.com/MReP1/Littl…
由本案例可以看到,Compose在自定义组件上非常简单、代码非常简洁,这是View不可比拟的。若对大家有帮助,大家不妨点个赞。