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

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())
            )
      }
}

问题分析与修复

核心问题点

  1. 坐标体系不匹配:触摸事件的change.position是组件内的显示坐标,但代码中直接用原始图片尺寸约束坐标,导致触摸点与原始图片区域映射错位。
  2. 目标矩形计算错误:dstRect的bottom值错误使用magnifierWidth,应该用magnifierHeight,导致放大镜宽高比例异常。
  3. 缩放比例未对齐: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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.20 15:55:55