StuartDTO
StuartDTO

Reputation: 1051

Get visibility canvas draw in compose

I have a canvas where I draw two images of the same size, and I've implemented a touch listener where I "erase" one of them and I'd like to know if there's any possibility to know the % of visibility of the one that I'm "erasing".

val overlayImageLoaded = rememberAsyncImagePainter(
        model = overlayImage,
    )
    val baseImageLoaded = rememberAsyncImagePainter(
        model = baseImage
    )
    Canvas(modifier = modifier
        .size(220.dp)
        .clip(RoundedCornerShape(size = 16.dp))
        .pointerInteropFilter {
            when (it.action) {
                MotionEvent.ACTION_DOWN -> {
                    currentPath.moveTo(it.x, it.y)
                }

                MotionEvent.ACTION_MOVE -> {
                    onMovedOffset(it.x, it.y)
                }
            }
            true
        }) {

        with(overlayImageLoaded) {
            draw(size = Size(size.width, size.height))

        }

        movedOffset?.let {
            currentPath.addOval(oval = Rect(it, currentPathThickness))
        }

        clipPath(path = currentPath, clipOp = ClipOp.Intersect) {
            with(baseImageLoaded) {
                draw(size = Size(size.width, size.height))
            }
        }
    }

I have some ideas :

Since what I want is to know if the image have been erased at least 70% let's say I've thought about store the onMovedOffset into a list and since I know the size of my canvas and the path thickness I can do a calculus of what user have seen, but perhaps it's a bit overkill.

Also I've thought about getting the canvas draw as a bitmap every-time user moves and then have a method that compares bitmap with bitmap and check the % of equality.

The goal is to know wether the user have erased at least 70% of the image and it's not visible anymore.

Upvotes: 3

Views: 1078

Answers (1)

Thracian
Thracian

Reputation: 67209

Answer of this question is quite complex and involves many layers. There can be some optimization for threading to comparing pixels in background thread.

Step one create copy of original ImageBitmap to fit in Composable on screen. Since we erase pixels on screen we should use sc

// Pixels of scaled bitmap, we scale it to composable size because we will erase
// from Composable on screen
val originalPixels: IntArray = remember {
    val buffer = IntArray(imageWidth * imageHeight)
    Bitmap.createScaledBitmap(imageBitmap.asAndroidBitmap(), imageWidth, imageHeight, false)
        .asImageBitmap()
        .readPixels(
            buffer = buffer,
            startX = 0,
            startY = 0,
            width = imageWidth,
            height = imageHeight
        )

    buffer
}

val erasedBitmap: ImageBitmap = remember {
    Bitmap.createBitmap(imageWidth, imageHeight, Bitmap.Config.ARGB_8888).asImageBitmap()
}

Second step is to create a androidx.compose.ui.graphics.Canvas(imageBitmap) to apply changes on imageBitmap as in this answer. Check this out to be familiar with how to manipulate Bitmap that is drawn to empty Bitmap

val canvas: Canvas = remember {
    Canvas(erasedBitmap)
}

Third step create paints to erase from Bitmap

val paint = remember {
    Paint()
}

val erasePaint = remember {
    Paint().apply {
        blendMode = BlendMode.Clear
        this.style = PaintingStyle.Stroke
        strokeWidth = 30f
    }
}

Forth step is to erase from Bitmap with gesture and calculate difference of original pixels with currently erased bitmap

canvas.apply {
    val nativeCanvas = this.nativeCanvas
    val canvasWidth = nativeCanvas.width.toFloat()
    val canvasHeight = nativeCanvas.height.toFloat()


    when (motionEvent) {

        MotionEvent.Down -> {
            erasePath.moveTo(currentPosition.x, currentPosition.y)
            previousPosition = currentPosition

        }
        MotionEvent.Move -> {

            erasePath.quadraticBezierTo(
                previousPosition.x,
                previousPosition.y,
                (previousPosition.x + currentPosition.x) / 2,
                (previousPosition.y + currentPosition.y) / 2

            )
            previousPosition = currentPosition
        }

        MotionEvent.Up -> {
            erasePath.lineTo(currentPosition.x, currentPosition.y)
            currentPosition = Offset.Unspecified
            previousPosition = currentPosition
            motionEvent = MotionEvent.Idle

            matchPercent = compareBitmaps(
                originalPixels,
                erasedBitmap,
                imageWidth,
                imageHeight
            )
        }
        else -> Unit
    }

    with(canvas.nativeCanvas) {
        drawColor(android.graphics.Color.TRANSPARENT, PorterDuff.Mode.CLEAR)

        drawImageRect(
            image = imageBitmap,
            dstSize = IntSize(canvasWidth.toInt(), canvasHeight.toInt()),
            paint = paint
        )

        drawPath(
            path = erasePath,
            paint = erasePaint
        )
    }
}

Fifth step is to compare original pixels with erased bitmap to determine in which percent they match

private fun compareBitmaps(
    originalPixels: IntArray,
    erasedBitmap: ImageBitmap,
    imageWidth: Int,
    imageHeight: Int
): Float {

    var match = 0f

    val size = imageWidth * imageHeight
    val erasedBitmapPixels = IntArray(size)

    erasedBitmap.readPixels(
        buffer = erasedBitmapPixels,
        startX = 0,
        startY = 0,
        width = imageWidth,
        height = imageHeight
    )

    erasedBitmapPixels.forEachIndexed { index, pixel: Int ->
        if (originalPixels[index] == pixel) {
            match++
        }
    }

    return 100f * match / size
}

Full Implementation

@Composable
fun EraseBitmapSample(imageBitmap: ImageBitmap, modifier: Modifier) {


    var matchPercent by remember {
        mutableStateOf(100f)
    }

    BoxWithConstraints(modifier) {

        // Path used for erasing. In this example erasing is faked by drawing with canvas color
        // above draw path.
        val erasePath = remember { Path() }

        var motionEvent by remember { mutableStateOf(MotionEvent.Idle) }
        // This is our motion event we get from touch motion
        var currentPosition by remember { mutableStateOf(Offset.Unspecified) }
        // This is previous motion event before next touch is saved into this current position
        var previousPosition by remember { mutableStateOf(Offset.Unspecified) }

        val imageWidth = constraints.maxWidth
        val imageHeight = constraints.maxHeight


        val drawImageBitmap = remember {
            Bitmap.createScaledBitmap(imageBitmap.asAndroidBitmap(), imageWidth, imageHeight, false)
                .asImageBitmap()
        }

        // Pixels of scaled bitmap, we scale it to composable size because we will erase
        // from Composable on screen
        val originalPixels: IntArray = remember {
            val buffer = IntArray(imageWidth * imageHeight)
            drawImageBitmap
                .readPixels(
                    buffer = buffer,
                    startX = 0,
                    startY = 0,
                    width = imageWidth,
                    height = imageHeight
                )

            buffer
        }

        val erasedBitmap: ImageBitmap = remember {
            Bitmap.createBitmap(imageWidth, imageHeight, Bitmap.Config.ARGB_8888).asImageBitmap()
        }

        val canvas: Canvas = remember {
            Canvas(erasedBitmap)
        }

        val paint = remember {
            Paint()
        }

        val erasePaint = remember {
            Paint().apply {
                blendMode = BlendMode.Clear
                this.style = PaintingStyle.Stroke
                strokeWidth = 30f
            }
        }


        canvas.apply {
            val nativeCanvas = this.nativeCanvas
            val canvasWidth = nativeCanvas.width.toFloat()
            val canvasHeight = nativeCanvas.height.toFloat()


            when (motionEvent) {

                MotionEvent.Down -> {
                    erasePath.moveTo(currentPosition.x, currentPosition.y)
                    previousPosition = currentPosition

                }
                MotionEvent.Move -> {

                    erasePath.quadraticBezierTo(
                        previousPosition.x,
                        previousPosition.y,
                        (previousPosition.x + currentPosition.x) / 2,
                        (previousPosition.y + currentPosition.y) / 2

                    )
                    previousPosition = currentPosition
                }

                MotionEvent.Up -> {
                    erasePath.lineTo(currentPosition.x, currentPosition.y)
                    currentPosition = Offset.Unspecified
                    previousPosition = currentPosition
                    motionEvent = MotionEvent.Idle

                    matchPercent = compareBitmaps(
                        originalPixels,
                        erasedBitmap,
                        imageWidth,
                        imageHeight
                    )
                }
                else -> Unit
            }

            with(canvas.nativeCanvas) {
                drawColor(android.graphics.Color.TRANSPARENT, PorterDuff.Mode.CLEAR)



                drawImageRect(
                    image = drawImageBitmap,
                    dstSize = IntSize(canvasWidth.toInt(), canvasHeight.toInt()),
                    paint = paint
                )

                drawPath(
                    path = erasePath,
                    paint = erasePaint
                )
            }
        }

        val canvasModifier = Modifier.pointerMotionEvents(
            Unit,
            onDown = { pointerInputChange ->
                motionEvent = MotionEvent.Down
                currentPosition = pointerInputChange.position
                pointerInputChange.consume()
            },
            onMove = { pointerInputChange ->
                motionEvent = MotionEvent.Move
                currentPosition = pointerInputChange.position
                pointerInputChange.consume()
            },
            onUp = { pointerInputChange ->
                motionEvent = MotionEvent.Up
                pointerInputChange.consume()
            },
            delayAfterDownInMillis = 20
        )

        Image(
            modifier = canvasModifier
                .clipToBounds()
                .drawBehind {
                    val width = this.size.width
                    val height = this.size.height

                    val checkerWidth = 10.dp.toPx()
                    val checkerHeight = 10.dp.toPx()

                    val horizontalSteps = (width / checkerWidth).toInt()
                    val verticalSteps = (height / checkerHeight).toInt()

                    for (y in 0..verticalSteps) {
                        for (x in 0..horizontalSteps) {
                            val isGrayTile = ((x + y) % 2 == 1)
                            drawRect(
                                color = if (isGrayTile) Color.LightGray else Color.White,
                                topLeft = Offset(x * checkerWidth, y * checkerHeight),
                                size = Size(checkerWidth, checkerHeight)
                            )
                        }
                    }
                }
                .matchParentSize()
                .border(2.dp, Color.Green),
            bitmap = erasedBitmap,
            contentDescription = null,
            contentScale = ContentScale.FillBounds
        )
    }

    Text("Original Bitmap")

    Image(
        modifier = modifier,
        bitmap = imageBitmap,
        contentDescription = null,
        contentScale = ContentScale.FillBounds
    )

    Text("Bitmap match $matchPercent", color = Color.Red, fontSize = 22.sp)

}

Result

enter image description here

Upvotes: 4

Related Questions