Android Jetpack Compose Canvas:如何实现图片局部放大功能?
问题描述
在Android Jetpack Compose中实现了彩色+灰度图片的叠加效果,需求是拖拽图片时,在触摸位置显示对应区域的彩色图片放大版本,功能对标Android官方Magnifier组件。但自行编写的DrawMagnifier组件存在图片缩放比例错误、对齐错位的问题。
实现代码
CardImageXRay 组件
@Composable fun CardImageXRay( modifier: Modifier = Modifier, pairUrl: Pair<String, String>, saturationValue: Float, //0..1 visible = 0..1 else 0f isVisible: Boolean, imageWidthHeight: Pair<Int, Int> ) { val ctx = LocalContext.current val minValue = 0f val maxValue = 0.7f val mappedValue = minValue + (1f - saturationValue) * (maxValue - minValue) val boxWidth = ctx.resources.displayMetrics.widthPixels - 18 * 3f val boxHeight = 400.toPx(ctx) val imageAspectRatio = imageWidthHeight.first / imageWidthHeight.second.toFloat() val scaledWidth: Float val scaledHeight: Float if (imageAspectRatio > 1) { // Landscape image scaledWidth = boxWidth scaledHeight = boxWidth / imageAspectRatio } else { // Portrait image scaledHeight = boxHeight scaledWidth = boxHeight * imageAspectRatio } var isDragged by remember { mutableStateOf(false) } var pointerOffset by remember { mutableStateOf(Offset(0f, 0f)) } var convertedBitmap by remember { mutableStateOf<ImageBitmap?>(null) } LaunchedEffect(key1 = true) { withContext(Dispatchers.IO) { convertedBitmap = getBitmap(ctx, pairUrl.first)?.asImageBitmap() } } val mdf = Modifier.size(width = scaledWidth.pxToDp(ctx).dp, height = scaledHeight.pxToDp(ctx).dp) Spacer(modifier = Modifier.padding(top = 100.dp)) Box(modifier = modifier.size(400.dp), contentAlignment = Alignment.Center) { AsyncImage( //colored image model = pairUrl.first, contentDescription = null, contentScale = ContentScale.Fit, colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(if (!isVisible) 1f else 0f) }), modifier = mdf .pointerInput(Unit) { detectDragGestures( onDragStart = { isDragged = true Timber.d("Dragged Start $isDragged") }, onDrag = { change, dragAmount -> change.consume() change.position.apply { pointerOffset = Offset( x.coerceIn(0f, imageWidthHeight.first.toFloat()), y.coerceIn(0f, imageWidthHeight.second.toFloat()) ) } Timber.d("Dragged Pointer $pointerOffset") }, onDragEnd = { isDragged = false Timber.d("Dragged End $isDragged") }, ) } .onSizeChanged { pointerOffset = Offset(it.width / 2f, it.height / 2f) }, ) if(isVisible){ AsyncImage( // GreyScale Image model = pairUrl.first, contentDescription = null, colorFilter = ColorFilter.tint(black), alpha = mappedValue, modifier = mdf, contentScale = ContentScale.Fit, ) } } convertedBitmap?.let { if (isDragged) { DrawMagnifier( //Magnifier pointerOffset = { pointerOffset }, imageBitmap = it, imageWidth = scaledWidth, imageHeight = scaledHeight, zoom = 1f ) } } }
DrawMagnifier 组件
@Composable fun DrawMagnifier( pointerOffset: () -> Offset, imageBitmap: ImageBitmap, imageWidth: Float, imageHeight: Float, magnifierSize: DpSize = DpSize(150.dp, 75.dp), zoom: Float = 1f ) { val ctx = LocalContext.current val magnifierWidth = magnifierSize.width.toPx(ctx) val magnifierHeight = magnifierSize.height.toPx(ctx) val zoomedWidth = magnifierWidth / zoom val zoomedHeight = magnifierHeight / zoom val srcRect = Rect( //Portion of image to be magnify left = (pointerOffset().x - zoomedWidth / 2).coerceIn(0f, imageWidth - zoomedWidth), top = (pointerOffset().y - zoomedHeight / 2).coerceIn(0f, imageHeight - zoomedHeight), right = (pointerOffset().x + zoomedWidth / 2).coerceIn(zoomedWidth, imageWidth), bottom = (pointerOffset().y + zoomedHeight / 2).coerceIn(zoomedHeight, imageHeight), ) val dstRect = Rect( left = pointerOffset().x - (magnifierWidth / 2), top = pointerOffset().y - (magnifierHeight / 2), right = pointerOffset().x + (magnifierWidth / 2), bottom = pointerOffset().y + (magnifierWidth / 2), ) Canvas( modifier = Modifier .offset { IntOffset( (pointerOffset().x - magnifierWidth / 2).toInt(), (pointerOffset().y - magnifierHeight / 2).toInt() ) } .size(magnifierSize) .clip(RoundedCornerShape(50)) .background(Color(0xFF464646)) ) { drawImage( image = imageBitmap, srcOffset = IntOffset(srcRect.left.toInt(), srcRect.top.toInt()), srcSize = IntSize(srcRect.width.toInt(), srcRect.height.toInt()), dstOffset = IntOffset.Zero, dstSize = IntSize(dstRect.width.toInt(), dstRect.height.toInt()) ) } }
问题分析与修复
核心问题点
- 坐标体系不匹配:触摸事件的
change.position是组件内的显示坐标,但代码中直接用原始图片尺寸约束坐标,导致触摸点与原始图片区域映射错位。 - 目标矩形计算错误:
dstRect的bottom值错误使用magnifierWidth,应该用magnifierHeight,导致放大镜宽高比例异常。 - 缩放比例未对齐:
DrawMagnifier混用显示图片尺寸和原始图片尺寸,没有计算两者的缩放比例,导致截取区域和显示区域不匹配。
修复后的代码
1. 修正CardImageXRay的坐标处理
计算显示图片与原始图片的缩放比例,将触摸坐标映射到原始图片坐标系:
// 在CardImageXRay中添加缩放比例计算 val scaleX = scaledWidth / imageWidthHeight.first.toFloat() val scaleY = scaledHeight / imageWidthHeight.second.toFloat() // ContentScale.Fit取最小缩放比例 val imageScale = min(scaleX, scaleY) // 修改pointerInput中的坐标处理 pointerInput(Unit) { detectDragGestures( onDragStart = { isDragged = true }, onDrag = { change, _ -> change.consume() val (x, y) = change.position // 将触摸坐标转换为原始图片坐标 val originalX = x / imageScale val originalY = y / imageScale pointerOffset = Offset( originalX.coerceIn(0f, imageWidthHeight.first.toFloat()), originalY.coerceIn(0f, imageWidthHeight.second.toFloat()) ) }, onDragEnd = { isDragged = false } ) }
2. 重写DrawMagnifier组件
修复比例计算和坐标映射逻辑:
@Composable fun DrawMagnifier( pointerOffset: () -> Offset, imageBitmap: ImageBitmap, originalImageSize: IntSize, displayedImageSize: IntSize, magnifierSize: DpSize = DpSize(150.dp, 75.dp), zoom: Float = 2f ) { val ctx = LocalContext.current val magnifierWidthPx = magnifierSize.width.toPx(ctx) val magnifierHeightPx = magnifierSize.height.toPx(ctx) // 原始图片与显示图片的缩放比例 val scaleX = originalImageSize.width.toFloat() / displayedImageSize.width.toFloat() val scaleY = originalImageSize.height.toFloat() / displayedImageSize.height.toFloat() // 触摸点在原始图片上的坐标 val touchX = pointerOffset().x * scaleX val touchY = pointerOffset().y * scaleY // 计算要截取的原始图片区域 val cropWidth = magnifierWidthPx / zoom val cropHeight = magnifierHeightPx / zoom val srcLeft = (touchX - cropWidth / 2).coerceIn(0f, originalImageSize.width.toFloat() - cropWidth) val srcTop = (touchY - cropHeight / 2).coerceIn(0f, originalImageSize.height.toFloat() - cropHeight) Canvas( modifier = Modifier .offset { // 向上偏移避免遮挡手指 IntOffset( (pointerOffset().x - magnifierWidthPx / 2).toInt(), (pointerOffset().y - magnifierHeightPx - 20.dp.toPx(ctx)).toInt() ) } .size(magnifierSize) .clip(RoundedCornerShape(16.dp)) .background(Color(0xCC464646)) .padding(2.dp) ) { drawImage( image = imageBitmap, srcOffset = IntOffset(srcLeft.toInt(), srcTop.toInt()), srcSize = IntSize(cropWidth.toInt(), cropHeight.toInt()), dstOffset = IntOffset.Zero, dstSize = IntSize(magnifierWidthPx.toInt(), magnifierHeightPx.toInt()) ) } }
3. 修正DrawMagnifier调用
convertedBitmap?.let { bitmap -> if (isDragged) { DrawMagnifier( pointerOffset = { pointerOffset }, imageBitmap = bitmap, originalImageSize = IntSize(imageWidthHeight.first, imageWidthHeight.second), displayedImageSize = IntSize(scaledWidth.toInt(), scaledHeight.toInt()), zoom = 2f ) } }
额外优化建议
- 用
remember缓存缩放比例,避免重复计算 - 放大镜位置可根据屏幕边界做自适应偏移,防止超出屏幕
- 调整
zoom参数大于1,确保有明显放大效果
内容的提问来源于stack exchange,提问作者Deepak Tiwari
相关产品推荐
相关产品推荐

