You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Jetpack Compose元素拖拽旋转缩放问题:左侧锚点缩放失效

Jetpack Compose 可拖拽缩放旋转元素:左侧锚点缩放问题修复

问题背景

需实现一个包含主体和左右两个锚点的可组合项,核心功能需求:

  • 拖拽主体可移动整个元素
  • 拖拽任意锚点可缩放元素
  • 拖拽单个锚点可绕另一个锚点旋转元素

当前已实现主体拖拽、右侧锚点的缩放与旋转功能,但左侧锚点(Anchor 1)的缩放逻辑失效,以下是完整实现代码及尝试的修复逻辑。

完整实现代码

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import java.lang.Math.toDegrees
import java.lang.Math.toRadians
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            Scaffold { paddingValues ->
                Box(modifier = Modifier
                    .fillMaxSize()
                    .padding(paddingValues)
                ) {
                    Element()
                }
            }
        }
    }
}

@Composable
fun Element() {

    var position by remember { mutableStateOf(Offset(100f, 100f)) }
    var size by remember { mutableStateOf(Size(300f, 60f)) }
    var rotation by remember { mutableFloatStateOf(0f) }
    var pivot by remember { mutableStateOf(Offset.Zero) }

    val anchor1 by rememberUpdatedState(
        Rect(
            offset = Offset(0f, 0f),
            size = Size(size.height, size.height)
        )
    )

    val anchor2 by rememberUpdatedState(
        Rect(
            offset = Offset(size.width - size.height, 0f),
            size = Size(size.height, size.height)
        )
    )

    val strokeColor = Color.Black

    Box(modifier = Modifier
        .size(size.toDp())
        .graphicsLayer(
            translationX = position.x,
            translationY = position.y,
            rotationZ = rotation,
            transformOrigin = pivot.toTransformOrigin(size)
        )
        .pointerInput(Unit) {
            detectDragGestures { change, dragAmount ->
                change.consume()
                position += dragAmount.rotateBy(rotation)
            }
        }
    ) {
        // Element body
        Box(
            modifier = Modifier
                .size(size.toDp())
                .border(width = 1.dp, color = strokeColor)
                .drawBehind {
                    drawLine(
                        color = strokeColor,
                        start = anchor1.center,
                        end = anchor2.center,
                        strokeWidth = 1.dp.toPx()
                    )
                }
        )

        // Anchor 1 (left side)
        Box(modifier = Modifier
            .size(anchor1.size.toDp())
            .align(Alignment.CenterStart)
            .border(width = 1.dp, color = strokeColor, shape = CircleShape)
            .pointerInput(Unit) {
                detectDragGestures(
                    onDragStart = {

                        val c1 = anchor1.center
                        val c2 = anchor2.center

                        val w = abs(c1.x - c2.x)

                        // Set pivot to anchor 2 (right side) and adjust position to prevent jump
                        if (pivot != c2) {
                            pivot = c2

                            val radian = toRadians(rotation.toDouble())
                            position += Offset(
                                w * (cos(radian).toFloat() - 1),
                                w * sin(radian).toFloat()
                            )
                        }
                    },
                    onDrag = { change, dragAmount ->
                        change.consume()

                        val c1 = anchor1.center

                        // Adjust the width (commented out due to failure)
                        //val newWidth = size.width - dragAmount.x
                        //size = size.copy(width = newWidth)

                        // Adjust the position to keep pin 2 in place (commented out)
                        //position += Offset(dragAmount.x, 0f).rotateBy(rotation)

                        rotation += pivot.angleBetween(c1, c1 + dragAmount)
                    }
                )
            }
        )

        // Anchor 2 (right side)
        Box(modifier = Modifier
            .size(anchor2.size.toDp())
            .align(Alignment.CenterEnd)
            .border(width = 1.dp, color = strokeColor, shape = CircleShape)
            .pointerInput(Unit) {
                detectDragGestures(
                    onDragStart = {
                        val c1 = anchor1.center
                        val c2 = anchor2.center
                        val w = abs(c1.x - c2.x)

                        if (pivot != c1) {
                            pivot = c1

                            val radian = toRadians(rotation.toDouble())
                            position -= Offset(
                                w * (cos(radian).toFloat() - 1),
                                w * sin(radian).toFloat()
                            )
                        }
                    },
                    onDrag = { change, dragAmount ->
                        change.consume()

                        val c2 = anchor2.center

                        // Adjust the width
                        size = size.copy(width = size.width + dragAmount.x)

                        rotation += pivot.angleBetween(c2, c2 + dragAmount)
                    }
                )
            }
        )
    }
}

// -------------------- Utilities methods ------------------------------

fun Offset.toTransformOrigin(size: Size) = TransformOrigin(
    pivotFractionX = x / size.width,
    pivotFractionY = y / size.height
)

// Calculate the angle between two points relative to this Offset
fun Offset.angleBetween(p1: Offset, p2: Offset) : Float {

    val initialAngle = atan2(p1.y - y, p1.x - x)
    val newAngle = atan2(p2.y - y, p2.x - x)
    val angleDifference = toDegrees((newAngle - initialAngle).toDouble()).toFloat()

    return angleDifference
}

// Rotate an Offset by the given angle
fun Offset.rotateBy(
    angle: Float
): Offset {
    val angleInRadians = toRadians(angle.toDouble())

    val cosAngle = cos(angleInRadians)
    val sinAngle = sin(angleInRadians)

    val newX = x * cosAngle - y * sinAngle
    val newY = x * sinAngle + y * cosAngle

    return Offset(newX.toFloat(), newY.toFloat())
}

@Composable
fun Size.toDp() = with(LocalDensity.current) {
    DpSize(
        width = width.toDp(),
        height = height.toDp()
    )
}

尝试的修复逻辑(未生效)

// Anchor 1 (left side)
Box(modifier = Modifier
    //...
    .pointerInput(Unit) {
        detectDragGestures(
            onDragStart = {
                //...
            },
            onDrag = { change, dragAmount ->
                change.consume()

                val c1 = anchor1.center

                // Adjust the width
                val newWidth = size.width - dragAmount.x
                size = size.copy(width = newWidth)

                // Adjust the position to keep pin 2 fix
                position += Offset(dragAmount.x, 0f).rotateBy(rotation)

                rotation += pivot.angleBetween(c1, c1 + dragAmount)
            }
        )
    }
)

解决方案

问题出在左侧锚点缩放时的坐标变换逻辑:当元素处于旋转状态时,直接使用原始的dragAmount.x调整宽度和位置会导致坐标不匹配,需要先将拖拽量逆旋转到元素的局部坐标系,再进行计算。

修改后的左侧锚点拖拽逻辑如下:

// Anchor 1 (left side)
Box(modifier = Modifier
    .size(anchor1.size.toDp())
    .align(Alignment.CenterStart)
    .border(width = 1.dp, color = strokeColor, shape = CircleShape)
    .pointerInput(Unit) {
        detectDragGestures(
            onDragStart = {
                val c1 = anchor1.center
                val c2 = anchor2.center
                val w = abs(c1.x - c2.x)

                if (pivot != c2) {
                    pivot = c2
                    val radian = toRadians(rotation.toDouble())
                    position += Offset(
                        w * (cos(radian).toFloat() - 1),
                        w * sin(radian).toFloat()
                    )
                }
            },
            onDrag = { change, dragAmount ->
                change.consume()

                // 将拖拽量逆旋转到元素局部坐标系
                val localDrag = dragAmount.rotateBy(-rotation)

                // 调整宽度:左侧锚点左移时宽度增加,右移时宽度减少
                val newWidth = size.width - localDrag.x
                if (newWidth >= size.height) { // 限制最小宽度为锚点直径,避免元素消失
                    size = size.copy(width = newWidth)
                    // 调整位置,保持右侧锚点固定
                    position += Offset(localDrag.x, 0f).rotateBy(rotation)
                }

                // 计算旋转角度
                val c1 = anchor1.center
                rotation += pivot.angleBetween(c1, c1 + dragAmount)
            }
        )
    }
)

关键修改说明

  1. 逆旋转拖拽量:使用dragAmount.rotateBy(-rotation)将屏幕坐标系的拖拽转换为元素自身局部坐标系的拖拽,确保缩放逻辑不受当前旋转角度影响。
  2. 宽度调整逻辑:左侧锚点的局部x轴拖拽量直接对应宽度变化,左移(localDrag.x为负)时宽度增加,右移(localDrag.x为正)时宽度减少。
  3. 位置补偿:使用局部坐标系的x拖拽量旋转后调整元素位置,确保右侧锚点始终保持在原屏幕位置,避免缩放时元素偏移。
  4. 最小宽度限制:添加newWidth >= size.height判断,防止宽度小于锚点直径导致元素异常。

内容的提问来源于stack exchange,提问作者Abdo21

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.18 13:47:06