JetpackCompose简单的自定义NumberPicker之字体缩放字体染色惯性移动fling

89 阅读5分钟

如题,JetpackCompose 已经出现这么久了,网上居然连个自定义NumberPicker的文章都没有。没得抄,那就做一个吧

1、先看效果[框框是调试用,可以去除]

image.png

image.png

2、不说废话,这里是源码

import androidx.compose.animation.core.*
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.material.MaterialTheme
import androidx.compose.material3.LocalTextStyle
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import cn.com.expame.R
import kotlinx.coroutines.launch
import kotlin.math.abs

private fun <T> getItemIndexForOffset(
    range: List<T>,
    offset: Float,
    interYPx: Float
): Int {
    val indexOf = (offset / interYPx).toInt()
    return maxOf(0, minOf(indexOf, range.count() - 1))
}

@Composable
fun <T> StyleListItemPicker(
    label: (T) -> String = { it.toString() },
    initIndex: Int = 0,
    onValueChange: ((Int) -> Unit)? = null,
    list: List<T>,
    textStyle: TextStyle = LocalTextStyle.current.copy(fontSize = 48.sp),
    showCount: Int = 9,
    scaleFactor: Float = 0.17f,
    totalHeight: Dp = 500.dp,
    totalWidth: Dp = 400.dp,
    limitScrollCount: Int = -1,
    centerColor: Color = colorResource(R.color.red_9D2227),
    normalColor: Color = colorResource(R.color.gray_666666)
) {
    // 协程体
    val scope = rememberCoroutineScope()
    // 文本测量
    val textMeasurer = rememberTextMeasurer()
    // 记住index
    val maxTextHeight = remember(list) { mutableIntStateOf(0) }
    if (maxTextHeight.intValue == 0) {
        val maxTH = textMeasurer.measure(
            text = label(list[initIndex]),
            // density 与 toSp()里抵消
            style = textStyle,
            maxLines = 1
        ).size.height
        maxTextHeight.intValue = maxTH
    }
    // 计算出一part的高度
    val intervalY = remember {
        totalHeight / showCount
    }
    // to px
    val totalHeightPx = with(LocalDensity.current) {
        totalHeight.toPx()
    }
    val totalWidthPx = with(LocalDensity.current) { totalWidth.toPx() }
    val intervalYPx = with(LocalDensity.current) { intervalY.toPx() }
    val maxFontSizePx = with(LocalDensity.current) { textStyle.fontSize.toPx() }
    // 中点
    val centerY = remember { totalHeightPx / 2 }
    val centerX = remember { totalWidthPx / 2 }
    val density = LocalDensity.current.density
    // 限定边界
    val minOffsetY =
        remember { if (limitScrollCount > 0) -(initIndex + limitScrollCount) * intervalYPx else -(list.size - 1) * intervalYPx } // 最底部内容对齐顶部
    val maxOffsetY = remember { if (limitScrollCount > 0) -(initIndex) * intervalYPx else 0f }
    val scrollX = remember { (initIndex) * intervalYPx }
    // 滚动距离
    val offsetY = remember { Animatable(-scrollX) }
        .apply {
            updateBounds(minOffsetY, maxOffsetY)
        }
    val clampShowCount = remember(list) { if (showCount % 2 == 0) showCount + 1 else showCount }
    // 开始绘制的Y坐标
    val startY = remember(list) { (clampShowCount / 2) * intervalYPx }
    // indicator Box Rect
    val boxRect = remember(list) { Rect(0f, startY, totalWidthPx, startY + intervalYPx) }
    val singleTextStyle = remember(list) { textStyle }
    Box(
        modifier = Modifier
            .width(totalWidth)
            .height(totalHeight)
            .clip(RectangleShape)
            .draggable(
                orientation = Orientation.Vertical,
                state = rememberDraggableState { deltaY ->
                    scope.launch {
                        offsetY.snapTo(offsetY.value + deltaY)
                    }
                },
                onDragStopped = { velocity ->
                    scope.launch {
                        // fling ~
                        val endValue = offsetY.fling(
                            initialVelocity = velocity,
                            // 摩擦系数
                            animationSpec = exponentialDecay(frictionMultiplier = 2.5f),
                            // 调整最终值
                            adjustTarget = { target ->
                                val absTarget = abs(target)
                                val coercedTarget =
                                    if (absTarget % intervalYPx > intervalYPx / 2) {
                                        absTarget + (intervalYPx - (absTarget % intervalYPx))
                                    } else {
                                        absTarget - (absTarget % intervalYPx)
                                    }
                                return@fling -coercedTarget
                            }
                        ).endState.value
                        val result =
                            getItemIndexForOffset(list, abs(endValue), intervalYPx)
                        onValueChange?.invoke(result)
                    }
                }
            )
            .drawBehind {
                val startIndex = getItemIndexForOffset(list, abs(offsetY.value), intervalYPx) - showCount / 2
                val clampStartIndex = startIndex.coerceAtLeast(0).coerceAtMost(list.size)
                for (i in clampStartIndex..list.lastIndex) {
                    val itemStartY = startY + (i * intervalYPx) + offsetY.value
                    if (itemStartY > totalHeightPx) break
                    clampInHeight(totalHeightPx - intervalYPx, itemStartY) {
                        val text = list[i]
                        // 用于测量
                        val itemCenterY = itemStartY + ((intervalYPx / 2f))
                        // 字体大小缩放曲线
                        val scaleLine = abs((centerY - itemCenterY)) / centerY
                        val scaleFontSize =
                            maxFontSizePx - ((scaleLine - scaleFactor) * maxFontSizePx)
                        val textLayoutResult = textMeasurer.measure(
                            text = label(text),
                            // density 与 toSp()里抵消
                            style = singleTextStyle.copy(fontSize = (scaleFontSize / (density * density)).sp),
                            maxLines = 1
                        )
                        val textHeight = textLayoutResult.size.height.toFloat()
                        val textWidth = textLayoutResult.size.width.toFloat()
                        val textCenterX = centerX - textWidth / 2f
                        val realY = itemCenterY - textHeight / 2f
                        //fix fontSize interval
                        val fixScaleOffsetY =
                            ((centerY - itemCenterY) / centerY) * (maxTextHeight.intValue - textHeight)
                        val fixRealY = realY + fixScaleOffsetY
                        // 文本染色渲染
                        drawColoredTextPart(
                            textLayoutResult = textLayoutResult,
                            topLeft = Offset(textCenterX, fixRealY),
                            boxRect = boxRect,
                            centerColor = centerColor,
                            normalColor = normalColor.copy(alpha = 1 - scaleLine)
                        )
                    }
                }
            }
    )

}


private fun DrawScope.drawColoredTextPart(
    textLayoutResult: TextLayoutResult,
    topLeft: Offset,
    boxRect: Rect,
    normalColor: Color,
    centerColor: Color
) {
    // 先画普通文本
    drawText(
        textLayoutResult = textLayoutResult,
        topLeft = topLeft,
        color = normalColor
    )
    clipRect(
        left = boxRect.left,
        right = boxRect.right,
        top = boxRect.top,
        bottom = boxRect.bottom
    ) {
        drawText(
            textLayoutResult = textLayoutResult,
            topLeft = topLeft,
            color = centerColor
        )
    }
}


fun clampInHeight(height: Float, itemCenterY: Float, action: () -> Unit) {
    if (itemCenterY < 0 || itemCenterY > height + 5) return
    action.invoke()
}


private suspend fun Animatable<Float, AnimationVector1D>.fling(
    initialVelocity: Float,
    animationSpec: DecayAnimationSpec<Float>,
    adjustTarget: ((Float) -> Float)?,
    block: (Animatable<Float, AnimationVector1D>.() -> Unit)? = null,
): AnimationResult<Float, AnimationVector1D> {
    val targetValue = animationSpec.calculateTargetValue(value, initialVelocity)
    val adjustedTarget = adjustTarget?.invoke(targetValue)
    return if (adjustedTarget != null) {
        animateTo(
            targetValue = adjustedTarget,
            initialVelocity = initialVelocity,
            block = block
        )
    } else {
        animateDecay(
            initialVelocity = initialVelocity,
            animationSpec = animationSpec,
            block = block,
        )
    }
}

@Preview
@Composable
fun StyleNumberPickerPreview() {
    MaterialTheme {

        Row{
            StyleListItemPicker(
                list = listOf("我", "是", "小", "小", "黄", "啊", "~", "啊"),
                initIndex = 0,
                onValueChange = {}
            )
            StyleListItemPicker(
                list = listOf("我", "是", "小", "小", "黄", "啊", "~", "啊"),
                initIndex = 0,
                onValueChange = {}
            )
        }

    }
}

3、这里是实现原理

a)、文本染色 计算出染色区域,先画普通文本,再画染色文本,用clipRect限定有色文本区域

private fun DrawScope.drawColoredTextPart(
    textLayoutResult: TextLayoutResult,
    topLeft: Offset,
    boxRect: Rect,
    normalColor: Color,
    centerColor: Color
) {
    // 先画普通文本
    drawText(
        textLayoutResult = textLayoutResult,
        topLeft = topLeft,
        color = normalColor
    )
    clipRect(
        left = boxRect.left,
        right = boxRect.right,
        top = boxRect.top,
        bottom = boxRect.bottom
    ) {
        drawText(
            textLayoutResult = textLayoutResult,
            topLeft = topLeft,
            color = centerColor
        )
    }
}

b)、字体缩放(主要是一些线性计算) 不得不说jetpackcompose的语法很简洁也很舒服

// 字体大小缩放曲线 
//从上到中到下的变化 scaleLine 转化后为  1----0----1
val scaleLine = abs((centerY.toPx() - itemCenterY)) / centerY.toPx()
// scaleFactor 为缩放系数 计算出最终的字体大小 
val scaleFontSize = maxFontSize - ((scaleLine - scaleFactor) * maxFontSize)
// 得到最终的文本布局进行绘制
val textLayoutResult = textMeasurer.measure(
   text = text,
   // density 与 toSp()里抵消
   style = textStyle.copy(fontSize = (scaleFontSize * density).toSp()),
   maxLines = 1
)
val textHeight = textLayoutResult.size.height.toFloat()
val textWidth = textLayoutResult.size.width.toFloat()
val textCenterX = centerX.toPx() - textWidth / 2f
val realY = itemCenterY - textHeight/2f

//fix fontSize interval
val fixScaleOffsetY =
((centerY - itemCenterY) / centerY) * (maxTextHeight.intValue - textHeight)
val fixRealY = realY + fixScaleOffsetY
// 文本染色渲染
drawColoredTextPart(
   textLayoutResult = textLayoutResult,
   topLeft = Offset(textCenterX, fixRealY),
   boxRect = boxRect,
   centerColor = centerColor,
   normalColor = normalColor
)

c)、手势处理 & fling加速度

Box(
   modifier = Modifier
       .width(totalWidth)
       .height(totalHeight)
       .draggable(
           orientation = Orientation.Vertical,
           state = rememberDraggableState { deltaY ->
               // 滚动过程
               scope.launch {
                   offsetY.snapTo(offsetY.value + deltaY)
               }
           },
           onDragStopped = { velocity ->
               // 滚动结束
               scope.launch {
                   // fling ~
                   val endValue = offsetY.fling(
                       initialVelocity = velocity,
                       // 摩擦力系数,官方API影响惯性距离
                       animationSpec = exponentialDecay(frictionMultiplier = 20f),
                       // 调整最终值(让其落到某个index)一些计算
                       adjustTarget = { target ->
                           val absTarget = abs(target)
                           val coercedTarget =
                               if (absTarget % intervalYPx > intervalYPx / 2) {
                                   absTarget + (intervalYPx - (absTarget % intervalYPx))
                               } else {
                                   absTarget - (absTarget % intervalYPx)
                               }
                           return@fling -coercedTarget
                       }
                   ).endState.value
                   val result =
                       getItemIndexForOffset(list, abs(endValue), intervalYPx)
                   currentIndex.intValue = result
                   onValueChange(result)
               }
           }
       )
)

/**
* 为Animatable添加自定义惯性滑动(fling)功能,支持目标位置修正
* 用于处理拖拽结束后的惯性运动,可根据需求修正最终停止位置
*
* @receiver Animatable<Float, AnimationVector1D> 1维浮点型动画值管理者(如垂直偏移量)
* @param initialVelocity Float 惯性滑动的初始速度(单位:像素/秒)
* @param animationSpec DecayAnimationSpec<Float> 速度衰减规律(如指数衰减)
* @param adjustTarget ((Float) -> Float)? 最终目标位置修整
* @param block (Animatable<Float, AnimationVector1D>.() -> Unit)? 动画过程中的回调
* @return AnimationResult<Float, AnimationVector1D> 动画结束后的结果
*/
private suspend fun Animatable<Float, AnimationVector1D>.fling(
   initialVelocity: Float,
   animationSpec: DecayAnimationSpec<Float>,
   adjustTarget: ((Float) -> Float)?,
   block: (Animatable<Float, AnimationVector1D>.() -> Unit)? = null,
): AnimationResult<Float, AnimationVector1D> {
   // 1. 先计算衰减情况下的目标位置
   // 根据当前动画值(value)和初始速度,通过衰减规格计算出不修正时的最终停止位置
   val targetValue = animationSpec.calculateTargetValue(value, initialVelocity)

   // 2. 执行目标位置修正
   // 调用adjustTarget回调对原始目标位置进行修正(如对齐到列表项间隔的整数倍)
   val adjustedTarget = adjustTarget?.invoke(targetValue)

   // 3. 根据是否有修正执行对应的动画
   return if (adjustedTarget != null) {
       // 3.1 有修正目标:直接动画到修正后的位置
       // 使用animateTo实现从当前值到修正目标的动画,保留初始速度使过渡更自然
       animateTo(
           targetValue = adjustedTarget,
           initialVelocity = initialVelocity,
           block = block
       )
   } else {
       // 3.2 无修正目标:执行自然衰减动画
       // 使用animateDecay按照衰减规格执行惯性滑动,速度逐渐减慢直至停止
       animateDecay(
           initialVelocity = initialVelocity,
           animationSpec = animationSpec,
           block = block,
       )
   }
}

d)、根据偏移计算index(调整最终值之后更方便计算)

private fun <T> getItemIndexForOffset(
    range: List<T>,
    offset: Float,
    interYPx: Float
): Int {
    val indexOf = (offset / interYPx).toInt()
    return maxOf(0, minOf(indexOf, range.count() - 1))
}

结束~