Compose 实现圆弧滑动效果之FanLayout ( 二 )

之前那篇文章还有很多细节没有处理,最近有时间了想再完善下,效果如上,之前的实现文章点这

Android Compose 自定义ViewGroup实现圆弧滑动效果之FanLayout - 掘金

之前的还有惯性滚动fling , 以及选择之后的回调没有处理。

所以打算再开一篇文章详细说下实现过程。

自定义布局

像这种效果我们还是用自定义布局来实现比较好。

我们来看下measure 和 layout。

kotlin 复制代码
            require(measurables.size > 3)
            val placeables = measurables.mapIndexed { index, measurable ->
                measurable.measure(constraints.copy(minWidth = 0, minHeight = 0))
            }
            layout(constraints.maxWidth, constraints.maxHeight) {
                placeables.forEachIndexed { index, placeable ->
                    if (index == 0)
                        placeable.place(
                            0,
                            constraints.maxHeight / 2 - placeable.height / 4, zIndex = 1f
                        )
                    else
                        placeable.place(
                            0,
                            constraints.maxHeight / 2
                        )
                }
            }

首先必须保证有三个子待测量的,也就是轴承和其他两个item (1个item 还转什么)。

index =0 表示轴承,轴承也就是那个最大的滑稽,轴承zIndex 设置为1,其他的item zIndex为默认值0,表示他会覆盖在全部item之上。

item的坐标 x, y都设置成一样,因为待会还要绕着圆心旋转。和view相比简单吧,是的,Comopse 自定义布局 的measure 和 layout 就是如此简单。

手势的处理

首先定义几个变量

kotlin 复制代码
       var angleAnimator = remember {
            Animatable(0f)
        }
        var angle by remember {
            mutableFloatStateOf(0f)
        }
        val velocityTracker = remember {
            VelocityTracker()
        }
        val splineBasedDecay: DecayAnimationSpec<Float> = with(LocalDensity.current) {
            remember {
                splineBasedDecay(this)
            }
        }

angle表示旋转的角度,velocityTracker用来监测速率 ,splineBasedDecay 是衰退动画的曲线,有个指数的但是感觉没splineBasedDecay效果好。

compose的手势在pointerInput方法进行处理,我们使用awaitEachGesture来处理每一次事件流

kotlin 复制代码
                    awaitEachGesture {
                        while (true) {
                            awaitFirstDown(requireUnconsumed = true).let { change ->
                                velocityTracker.addPosition(
                                    change.uptimeMillis,
                                    change.position,
                                )
                                if (angleAnimator.isRunning) {
                                    scope.launch {
                                        angleAnimator.stop()
                                    }
                                }
                                drag(change.id) { change ->
                                    velocityTracker.addPosition(
                                        change.uptimeMillis,
                                        change.position,
                                    )
                                    scope.launch {
                                        angle += getAngle(change)
                                    }
                                }
                                scope.launch {
                                    val velocityY = velocityTracker.calculateVelocity().y / 10
                                    if (velocityY.absoluteValue > 0f) {
                                        var lastValue = 0f
                                        angleAnimator = Animatable(0f)
                                        angleAnimator
                                            .animateDecay(
                                                initialVelocity = velocityY,
                                                splineBasedDecay
                                            ) {
                                                angle += (value - lastValue)
                                                lastValue = value
                                            }
                                    }
                                    angleAnimator = Animatable(angle)
                                    if (angle.absoluteValue % 36 < 18) {
                                        //不足18度回滚
                                        angleAnimator.animateTo(angle - (angle % 36)) {
                                            angle = value
                                        }
                                    } else {
                                        //大于18度,补足至36度
                                        angleAnimator.animateTo(angle + (36f.withSign(angle.sign) - angle % 36)) {
                                            angle = value
                                        }
                                    }
                                }

                            }
                        }
                    }

firstdown 的时候如果动画在运行则停止,down 和move 的时候用velocityTracker.addPosition 监控速率,在up 的时候就能获取到fling的速率了,剩下的就是一些边界值的判断了。

getAngle 方法 ,根据上一个触摸点和当前触摸点和圆心三个点计算滚动角度,原理是勾股定理和反三角函数。

kotlin 复制代码
private fun getAngle(inputChange: PointerInputChange): Float {
    val l: Float
    val t: Float
    val r: Float
    val b: Float
    val preX = inputChange.previousPosition.x
    val preY = inputChange.previousPosition.y
    val curX = inputChange.position.x
    val curY = inputChange.position.y
    if (preX > curX) {
        r = preX; l = curX
    } else {
        r = curX; l = preX
    }
    if (preY > curY) {
        b = preY; t = curY
    } else {
        b = curY; t = preY
    }
    val pA1: Float = abs(preX - pivotX)
    val pA2: Float = abs(preY - pivotY)
    val pB1: Float = abs(curX - pivotX)
    val pB2: Float = abs(curY - pivotY)
    val hypotenuse =
        sqrt((r - l).toDouble().pow(2.0) + (b - t).toDouble().pow(2.0)).toFloat()
    val lineA = sqrt(pA1.toDouble().pow(2.0) + pA2.toDouble().pow(2.0)).toFloat()
    val lineB = sqrt(pB1.toDouble().pow(2.0) + pB2.toDouble().pow(2.0)).toFloat()
    if (hypotenuse > 0 && lineA > 0 && lineB > 0) {
        val angle = Math.toDegrees(
            acos(
                (lineA.toDouble().pow(2.0) + lineB.toDouble().pow(2.0) - hypotenuse.toDouble()
                    .pow(2.0)) / (2 * lineA * lineB)
            )
        ).toFloat()
        if (!java.lang.Float.isNaN(angle)) {
            return if (isClockwise(inputChange)) angle else -angle
        }
    }
    return 0f
}

还有一点需要优化,在轴承上触摸移动时需要把事件禁止掉,另外搞一个pointerInput来处理。

kotlin 复制代码
        .pointerInput(Unit) {
                    awaitEachGesture {
                        awaitFirstDown(pass = PointerEventPass.Initial).also {
                            val dis = sqrt(
                                (it.position.y - pivotY).absoluteValue.pow(2) + (it.position.x - pivotX).absoluteValue.pow(
                                    2
                                )
                            )
                            if (dis < 100.dp.toPx()) {
                                it.consume()
                            }
                        }
                    }
                }

轴承区域是一个直径为100dp的圆,只要触摸点在这个区域内要把事件消费掉,怎么判断触摸点在这个区域内?这时候就需要用到初中数学知识了,只要触摸点和圆心距离小于直径我们就可以认为触摸点在圆内了,在圆内需要把事件消费掉。 计算触摸点和圆心的距离可以使用距离公式

原理其实也是勾股定理,不清理勾股定理的请立即打电话给初中数学老师。

Item选择时候的回调

默认以在屏幕中间那个为选择的,选择之后会标红,可以看上图那个gif,我们可以使用onGloballyPositioned 来处理,每次在屏幕的坐标变了这个方法都会回调。

kotlin 复制代码
                        .onGloballyPositioned {
                            if (it.boundsInParent().topLeft == Offset(
                                    0f,
                                    (height / 2f)
                                        .roundToInt()
                                        .toFloat()
                                )
                            ) {
                                curSelectedIndex = index
                            }

                        }

完整代码

kotlin 复制代码
fun FanLayout() {
    Surface(modifier = Modifier.fillMaxSize()) {
        var angleAnimator = remember {
            Animatable(0f)
        }
        var angle by remember {
            mutableFloatStateOf(0f)
        }
        val velocityTracker = remember {
            VelocityTracker()
        }
        val splineBasedDecay: DecayAnimationSpec<Float> = with(LocalDensity.current) {
            remember {
                splineBasedDecay(this)
            }
        }
        var curSelectedIndex by remember {
            mutableIntStateOf(0)
        }
        val scope = rememberCoroutineScope()
        val height = with(LocalDensity.current) {
            pivotY =
                LocalConfiguration.current.screenHeightDp.dp.roundToPx() / 2 + 20.dp.toPx()
                    .roundToInt()
            LocalConfiguration.current.screenHeightDp.dp.roundToPx()
        }
        /*    val imageBrush =
                ShaderBrush(ImageShader(ImageBitmap.imageResource(id = R.drawable.img)))*/
        Layout(
            content = {
                Image(
                    modifier = Modifier
                        .size(100.dp)
                        .clip(CircleShape),
                    painter = painterResource(id = R.drawable.img),
                    contentDescription = ""
                )
                List(10) { index ->
                    Row(modifier = Modifier
                        .graphicsLayer {
                            transformOrigin = TransformOrigin(0f, 0f)
                            rotationZ = (360 / 10).toFloat() * index + (angle)
                        }
                        .onGloballyPositioned {
                            if (it.boundsInParent().topLeft == Offset(
                                    0f,
                                    (height / 2f)
                                        .roundToInt()
                                        .toFloat()
                                )
                            ) {
                                curSelectedIndex = index
                            }

                        }
                        .fillMaxWidth()
                        .requiredHeight(50.dp)
                        .clickable {
                            if (angleAnimator.isRunning) {
                                return@clickable
                            }
                            scope.launch {
                                angleAnimator = Animatable(angle)
                                angleAnimator.animateTo(angle - 36) {
                                    angle = value
                                }
                            }

                        }
                        .background(if (curSelectedIndex == index) Color.Red else Color.White)) {
                        repeat(10) {
                            Image(
                                painter = painterResource(id = R.drawable.img),
                                contentDescription = ""
                            )
                        }

                    }
                }
            },
            modifier = Modifier
                .fillMaxSize()
                .background(Color.White)
                .pointerInput(Unit) {
                    awaitEachGesture {
                        awaitFirstDown(pass = PointerEventPass.Initial).also {
                            val dis = sqrt(
                                (it.position.y - pivotY).absoluteValue.pow(2) + (it.position.x - pivotX).absoluteValue.pow(
                                    2
                                )
                            )
                            if (dis < 100.dp.toPx()) {
                                it.consume()
                            }
                        }
                    }
                }
                .pointerInput(Unit) {
                    awaitEachGesture {
                        while (true) {
                            awaitFirstDown(requireUnconsumed = true).let { change ->
                                velocityTracker.addPosition(
                                    change.uptimeMillis,
                                    change.position,
                                )
                                if (angleAnimator.isRunning) {
                                    scope.launch {
                                        angleAnimator.stop()
                                    }
                                }
                                drag(change.id) { change ->
                                    velocityTracker.addPosition(
                                        change.uptimeMillis,
                                        change.position,
                                    )
                                    scope.launch {
                                        angle += getAngle(change)
                                    }
                                }
                                scope.launch {
                                    val velocityY = velocityTracker.calculateVelocity().y / 10
                                    if (velocityY.absoluteValue > 0f) {
                                        var lastValue = 0f
                                        angleAnimator = Animatable(0f)
                                        angleAnimator
                                            .animateDecay(
                                                initialVelocity = velocityY,
                                                splineBasedDecay
                                            ) {
                                                angle += (value - lastValue)
                                                lastValue = value
                                            }
                                    }
                                    angleAnimator = Animatable(angle)
                                    if (angle.absoluteValue % 36 < 18) {
                                        angleAnimator.animateTo(angle - (angle % 36)) {
                                            angle = value
                                        }
                                    } else {
                                        angleAnimator.animateTo(angle + (36f.withSign(angle.sign) - angle % 36)) {
                                            angle = value
                                        }
                                    }
                                }

                            }
                        }
                    }
                }) { measurables, constraints ->
            require(measurables.size > 3)
            val placeables = measurables.mapIndexed { index, measurable ->
                measurable.measure(constraints.copy(minWidth = 0, minHeight = 0))
            }
            layout(constraints.maxWidth, constraints.maxHeight) {
                placeables.forEachIndexed { index, placeable ->
                    if (index == 0)
                        placeable.place(
                            0,
                            constraints.maxHeight / 2 - placeable.height / 4, zIndex = 1f
                        )
                    else
                        placeable.place(
                            0,
                            constraints.maxHeight / 2
                        )
                }
            }
        }
    }
}

private var pivotX = 0
private var pivotY = 0
private fun getAngle(inputChange: PointerInputChange): Float {
    val l: Float
    val t: Float
    val r: Float
    val b: Float
    val preX = inputChange.previousPosition.x
    val preY = inputChange.previousPosition.y
    val curX = inputChange.position.x
    val curY = inputChange.position.y
    if (preX > curX) {
        r = preX; l = curX
    } else {
        r = curX; l = preX
    }
    if (preY > curY) {
        b = preY; t = curY
    } else {
        b = curY; t = preY
    }
    val pA1: Float = abs(preX - pivotX)
    val pA2: Float = abs(preY - pivotY)
    val pB1: Float = abs(curX - pivotX)
    val pB2: Float = abs(curY - pivotY)
    val hypotenuse =
        sqrt((r - l).toDouble().pow(2.0) + (b - t).toDouble().pow(2.0)).toFloat()
    val lineA = sqrt(pA1.toDouble().pow(2.0) + pA2.toDouble().pow(2.0)).toFloat()
    val lineB = sqrt(pB1.toDouble().pow(2.0) + pB2.toDouble().pow(2.0)).toFloat()
    if (hypotenuse > 0 && lineA > 0 && lineB > 0) {
        val angle = Math.toDegrees(
            acos(
                (lineA.toDouble().pow(2.0) + lineB.toDouble().pow(2.0) - hypotenuse.toDouble()
                    .pow(2.0)) / (2 * lineA * lineB)
            )
        ).toFloat()
        if (!java.lang.Float.isNaN(angle)) {
            return if (isClockwise(inputChange)) angle else -angle
        }
    }
    return 0f
}


private fun isClockwise(inputChange: PointerInputChange): Boolean {
    val px = inputChange.previousPosition.x
    val py = inputChange.previousPosition.y
    val x = inputChange.position.x
    val y = inputChange.position.y
    return if (abs(y - py) > abs(x - px)) x < pivotX != y > py else y < pivotY == x > px
}
相关推荐
拭心4 小时前
Google 提供的 Android 端上大模型组件:MediaPipe LLM 介绍
android
带电的小王6 小时前
WhisperKit: Android 端测试 Whisper -- Android手机(Qualcomm GPU)部署音频大模型
android·智能手机·whisper·qualcomm
梦想平凡6 小时前
PHP 微信棋牌开发全解析:高级教程
android·数据库·oracle
元争栈道7 小时前
webview和H5来实现的android短视频(短剧)音视频播放依赖控件
android·音视频
阿甘知识库8 小时前
宝塔面板跨服务器数据同步教程:双机备份零停机
android·运维·服务器·备份·同步·宝塔面板·建站
元争栈道8 小时前
webview+H5来实现的android短视频(短剧)音视频播放依赖控件资源
android·音视频
MuYe8 小时前
Android Hook - 动态加载so库
android
居居飒9 小时前
Android学习(四)-Kotlin编程语言-for循环
android·学习·kotlin
Henry_He12 小时前
桌面列表小部件不能点击的问题分析
android
工程师老罗12 小时前
Android笔试面试题AI答之Android基础(1)
android