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

Android Compose Canvas缩放图片手势偏移问题:图片移出圆形裁剪区

Android Compose Canvas圆形图片裁剪的平移边界问题

我基于Android Compose Canvas实现了圆形图片裁剪功能,支持图片旋转、缩放和平移操作,基础功能正常,但存在以下问题:

  • 缩放比例为1.4时,平移图片会出现偏移过大,导致图片移出圆形裁剪区
  • 缩放比例增大至3时,偏移程度更加严重

需求是限制平移的偏移范围,确保图片不会完全移出圆形裁剪区,但允许用户将图片移动到边缘位置。

现有代码

ZoomableImage Composable

@Composable
fun ZoomableImage(
    modifier: Modifier = Modifier,
    imageUrl: Uri,
    maxWidthVal: Int = 0,
    maxHeightVal: Int = 0,
) {
    var scale by remember { mutableStateOf(1f) }
    var offset by remember { mutableStateOf(Offset(0f, 0f)) }
    var rotation by remember { mutableStateOf(0f) }
    var croppedImage: Bitmap? by remember { mutableStateOf(null) }
    val minScale = 1f
    val maxScale = 3f
    var canvasSize by remember { mutableStateOf(Size(0f, 0f)) }

    var originalImageBitmap by remember { mutableStateOf<ImageBitmap?>(null) }

    val context = LocalContext.current
    val imageLoader = ImageLoader(context)

    val request = ImageRequest.Builder(context)
        .allowHardware(false)
        .data(imageUrl)
        .target { result ->
            originalImageBitmap = result.current.toBitmap().asImageBitmap()
        }
        .build()

    LaunchedEffect(request) {
        withContext(Dispatchers.IO) {
            imageLoader.execute(request)
        }
    }

    Box(
        modifier = modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                detectTransformGestures { _, pan, zoom, rotationChange ->
                    // Zoom
                    scale *= zoom
                    scale = scale.coerceIn(minScale, maxScale)
                    // Pan (move)
                    offset = if (scale > minScale) {
                        val offsetX = (offset.x + pan.x * scale).coerceIn(
                            -maxWidthVal * (scale - 1),
                            maxWidthVal * (scale - 1)
                        )
                        val offsetY = (offset.y + pan.y * scale).coerceIn(
                            -maxHeightVal * (scale - 1),
                            maxHeightVal * (scale - 1)
                        )
                        Log.d("CropImage", "ZoomableImage: move $offsetX, $offsetY scale $scale")
                        Offset(offsetX, offsetY)
                    } else {
                        Offset(0f, 0f)
                    }
                    // Rotation cap to 360 degrees, cause if we spin more its big than 360
                    // make canvas fail to build up so we mod by 360
                    rotation = (rotation + rotationChange) % 360
                    Log.d("CropImage", "ZoomableImage: rotation $rotation")
                    croppedImage = null
                }
            }
            .background(MaterialTheme.colorScheme.onSurface),
        contentAlignment = Alignment.Center
    ) {
        if (croppedImage == null) {
            if (originalImageBitmap == null) return
            Canvas(
                modifier = Modifier
                    .fillMaxSize()
                    .onGloballyPositioned {
                        canvasSize = Size(it.size.width.toFloat(), it.size.height.toFloat())
                    }
                    .clickable {
                        croppedImage = getCroppedBitmap(
                            originalImageBitmap!!.asAndroidBitmap(),
                            scale,
                            scale,
                            offset.x,
                            offset.y,
                            rotation,
                            canvasSize
                        )
                    },
                onDraw = {
                    val circlePath = Path().apply {
                        addOval(Rect(center, radius = size.width / 2))
                    }

                    val centerX = center.x - ((originalImageBitmap!!.width) / 2) + offset.x // calculate to make photo center
                    val centerY = center.y - ((originalImageBitmap!!.height) /  2) + offset.y // calculate to make photo center

                    rotate(rotation, Offset(center.x, center.y)) {
                        scale(scale, Offset(center.x, center.y)) {
                            drawImage(originalImageBitmap!!, topLeft = Offset(centerX, centerY))
                        }
                    }

                    // Draw the white border
                    val borderPaint = Paint().asFrameworkPaint()
                    borderPaint.color = Color.White.toArgb()
                    // borderPaint.style = Paint().style.Stroke
                    borderPaint.strokeWidth = 4f // Adjust the width as needed

                    drawPath(
                        circlePath,
                        color = Color.White,
                        colorFilter = ColorFilter.tint(Color.White),
                        style = Stroke(4f)
                    )

                    clipPath(circlePath, clipOp = ClipOp.Difference) {
                        drawRect(SolidColor(Color.Black.copy(alpha = 0.8f)))
                    }
                })
        }
    }

    croppedImage?.let {
        Image(
            painter = rememberAsyncImagePainter(it),
            contentDescription = null,
            modifier = Modifier
                .fillMaxSize()
                .background(color = Color.Blue)
                .clickable { croppedImage = null },
        )
    }
}

圆形裁剪函数

fun getCroppedBitmap(
    bitmap: Bitmap,
    scaleX: Float,
    scaleY: Float,
    translationX: Float,
    translationY: Float,
    rotationZ: Float,
    canvasSize: Size,
): Bitmap {
    Log.d("CropImage", "ZoomableImage: scale: $scaleX, translationX: $translationX, translationY: $translationY, rotationZ: $rotationZ, canvasSize: $canvasSize")
    val center = Offset(canvasSize.width.toFloat() / 2, canvasSize.height.toFloat() / 2)
    val circle = Rect(center = Offset(0f, 0f), radius = 500f)
    val output = Bitmap.createBitmap(
        canvasSize.width.toInt(),
        canvasSize.height.toInt(),
        Bitmap.Config.ARGB_8888
    )
    val canvas = Canvas(output.asImageBitmap())
    val color = -0xbdbdbe
    val paint = Paint()
    paint.isAntiAlias = true
    paint.color = Color(color = color)
    val radius = canvasSize.width / 2
    val scaleValue = canvasSize.height / bitmap.height
    Log.d("CropImage", "getCroppedBitmap: original radius ${canvasSize.width / 2} with size $canvasSize, new radius: $radius")
    Log.d("CropImage", "getCroppedBitmap: scaleValue: $scaleValue")
    Log.d("CropImage", "getCroppedBitmap: translationX: ${translationX / scaleValue}, translationY: ${translationY / scaleValue}")
    canvas.drawCircle(
        center = center, radius = radius,
        paint = paint
    )


    canvas.save()
    canvas.rotate(rotationZ, center.x, center.y)
    canvas.scale(scaleX, scaleY, center.x, center.y)
    val imageBitmap = bitmap.copy(Bitmap.Config.ARGB_8888, true).asImageBitmap()
    var offsetX = center.x - ((bitmap.width) /  2) // calculate to make photo center
    var offsetY = center.y - ((bitmap.height) /  2 ) // calculate to make photo center
    offsetX += (translationX)
    offsetY += (translationY)

    paint.blendMode = BlendMode.SrcIn
    Log.d("CropImage", "getCroppedBitmap: offsetX: $offsetX offsetY: $offsetY")
    canvas.save()
    canvas.drawImage(imageBitmap, Offset(offsetX, offsetY), paint)


    canvas.restore()
    return output
}

调用示例

ZoomableImage(
     imageUrl = "https://picsum.photos/seed/picsum/2000/1500".toUri(),
     maxWidthVal = 2000,
     maxHeightVal = 1500
)

问题分析

现有代码存在两个核心问题导致平移偏移异常:

  1. 平移边界计算错误:使用固定的maxWidthVal*(scale-1)限制偏移,没有考虑图片缩放后的实际尺寸与圆形裁剪区的差值,当图片宽高比与画布不一致时,限制范围完全不符合实际需求。
  2. 绘制位置计算错误:Canvas绘制图片时,使用原始图片宽高计算居中位置,未乘以缩放比例,导致缩放后图片的实际位置与预期偏移。

修复方案

1. 修正平移边界计算逻辑

在detectTransformGestures中,基于缩放后图片尺寸与圆形裁剪区的差值计算最大允许偏移量:

// 替换原来的平移计算逻辑
offset = if (scale > minScale) {
    val scaledImgWidth = originalImageBitmap!!.width * scale
    val scaledImgHeight = originalImageBitmap!!.height * scale
    // 圆形裁剪区的直径取画布宽高中的较小值(确保圆形完全显示)
    val circleDiameter = min(canvasSize.width, canvasSize.height)
    
    // 计算X方向最大偏移:只有当缩放后图片宽大于圆形直径时,才允许平移
    val maxOffsetX = max(0f, (scaledImgWidth - circleDiameter) / 2)
    // Y方向同理
    val maxOffsetY = max(0f, (scaledImgHeight - circleDiameter) / 2)

    val newOffsetX = (offset.x + pan.x * scale).coerceIn(-maxOffsetX, maxOffsetX)
    val newOffsetY = (offset.y + pan.y * scale).coerceIn(-maxOffsetY, maxOffsetY)
    
    Log.d("CropImage", "ZoomableImage: move $newOffsetX, $newOffsetY scale $scale")
    Offset(newOffsetX, newOffsetY)
} else {
    Offset(0f, 0f)
}

2. 修正Canvas绘制位置计算

在Canvas的onDraw方法中,使用缩放后的图片尺寸计算居中位置:

// 替换原来的centerX、centerY计算
val scaledImageWidth = originalImageBitmap!!.width * scale
val scaledImageHeight = originalImageBitmap!!.height * scale
val drawX = center.x - scaledImageWidth / 2 + offset.x
val drawY = center.y - scaledImageHeight / 2 + offset.y

rotate(rotation, Offset(center.x, center.y)) {
    scale(scale, Offset(center.x, center.y)) {
        drawImage(originalImageBitmap!!, topLeft = Offset(drawX, drawY))
    }
}

3. 裁剪函数同步修正位置计算

在getCroppedBitmap中,同样需要使用缩放后的尺寸计算绘制位置,避免裁剪结果与预览不一致:

// 替换原来的offsetX、offsetY计算
val scaledBitmapWidth = bitmap.width * scaleX
val scaledBitmapHeight = bitmap.height * scaleY
var offsetX = center.x - scaledBitmapWidth / 2 
var offsetY = center.y - scaledBitmapHeight / 2 
offsetX += translationX
offsetY += translationY

说明

  • 修复后的逻辑会根据图片缩放后的实际尺寸,动态计算允许的最大平移距离,确保图片始终至少有部分区域留在圆形裁剪区内。
  • 若需要支持旋转后的边界限制,还需进一步计算旋转后图片的外接矩形与圆形的交集,计算最大偏移量(当前修复未包含旋转后的边界处理,可根据需求扩展)。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.06 10:33:10