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 )
问题分析
现有代码存在两个核心问题导致平移偏移异常:
- 平移边界计算错误:使用固定的
maxWidthVal*(scale-1)限制偏移,没有考虑图片缩放后的实际尺寸与圆形裁剪区的差值,当图片宽高比与画布不一致时,限制范围完全不符合实际需求。 - 绘制位置计算错误: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
相关产品推荐
相关产品推荐

