小鹅事务所在从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不可比较的。若对我们有帮助,我们无妨点个赞。
参考
轻量级自定义NumberPicker