Search code examples
androidandroid-jetpack-composeandroid-jetpack-compose-canvas

Jetpack Compose watermark or write on Bitmap with androidx.compose.ui.graphics.Canvas?


With androidx.compose.foundation.Canvas, default Canvas for Jetpack Compose, or Spacer with Modifier.drawBehind{} under the hood

@Composable
fun Canvas(modifier: Modifier, onDraw: DrawScope.() -> Unit) =
    Spacer(modifier.drawBehind(onDraw

correctly refreshes drawing on Canvas when mutableState Offset changes

var offset by remember {
    mutableStateOf(Offset(bitmapWidth / 2f, bitmapHeight / 2f))
}  

Canvas(modifier = canvasModifier.fillMaxSize()) {
        val canvasWidth = size.width.roundToInt()
        val canvasHeight = size.height.roundToInt()
    
        drawImage(
            image = dstBitmap,
            srcSize = IntSize(dstBitmap.width, dstBitmap.height),
            dstSize = IntSize(canvasWidth, canvasHeight)
        )
    
        drawCircle(
            center = offset,
            color = Color.Red,
            radius = canvasHeight.coerceAtMost(canvasWidth) / 8f,
        )
    }

With androidx.compose.ui.graphics.Canvas, Canvas that takes an ImageBitmap as argument and draws to as in description of it

Create a new Canvas instance that targets its drawing commands to the provided ImageBitmap

I add full implementation to test this out easily and much appreciated if you come up with a solution.

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

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

        val bitmapWidth = imageBitmap.width
        val bitmapHeight = imageBitmap.height

        var offset by remember {
            mutableStateOf(Offset(bitmapWidth / 2f, bitmapHeight / 2f))
        }


        val canvasModifier = Modifier.pointerMotionEvents(
            Unit,
            onDown = {
                val position = it.position
                val offsetX = position.x * bitmapWidth / imageWidth
                val offsetY = position.y * bitmapHeight / imageHeight
                offset = Offset(offsetX, offsetY)
                it.consumeDownChange()
            },
            onMove = {
                val position = it.position
                val offsetX = position.x * bitmapWidth / imageWidth
                val offsetY = position.y * bitmapHeight / imageHeight
                offset = Offset(offsetX, offsetY)
                it.consumePositionChange()
            },
            delayAfterDownInMillis = 20
        )

        val canvas: androidx.compose.ui.graphics.Canvas = Canvas(imageBitmap)
        

        val paint1 = remember {
            Paint().apply {
                color = Color.Red
            }
        }
        canvas.apply {
            val nativeCanvas = this.nativeCanvas
            val canvasWidth = nativeCanvas.width.toFloat()
            val canvasHeight = nativeCanvas.height.toFloat()

            drawCircle(
                center = offset,
                radius = canvasHeight.coerceAtMost(canvasWidth) / 8,
                paint = paint1
            )
        }


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

        Text(
            "Offset: $offset",
            modifier = Modifier.align(Alignment.BottomEnd),
            color = Color.White,
            fontSize = 16.sp
        )
    }
}

First issue it never refreshes Canvas without Text or something else reading Offset.

Second issue is as in the image below. It doesn't clear previous drawing on Image, i tried every possible solution in this question thread but none of them worked.

enter image description here

I tried drawing image with BlendMode, drawColor(Color.TRANSPARENT,Mode.Multiply) with native canvas and many combinations still not able to have the same result with Jetpack Compose Canvas.

    val erasePaint = remember {
        Paint().apply {
            color = Color.Transparent
            blendMode = BlendMode.Clear
        }
    }

with(canvas.nativeCanvas) {
    val checkPoint = saveLayer(null, null)

    drawImage(imageBitmap, topLeftOffset = Offset.Zero, erasePaint)
    drawCircle(
        center = offset,
        radius = canvasHeight.coerceAtMost(canvasWidth) / 8,
        paint = paint1
    )
    
    restoreToCount(checkPoint)
}

I need to use androidx.compose.ui.graphics.Canvas as you can see operations on Canvas are reflected to Bitmap and using this i'm planning to create foundation for cropping Bitmap

enter image description here


Solution

  • I finally, after 6 months, figured out how it can be done and how you can modify Bitmap instance using androidx.compose.ui.graphics.Canvas

    First create an empty mutable bitmap with same dimensions of original bitmap. This is what we will draw on. The trick here is not sending a real bitmap but an empty bitmap

    val bitmapWidth = imageBitmap.width
    val bitmapHeight = imageBitmap.height
    
    val bmp: Bitmap = remember {
        Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888)
    }
    

    Then since we draw nothing at the base we can use drawColor(android.graphics.Color.TRANSPARENT, PorterDuff.Mode.CLEAR)

    to clear on each draw then draw image and apply any blend mode using Paint

    val paint = remember {
        Paint()
    }
    
    val erasePaint = remember {
        Paint().apply {
            color = Color.Red
            blendMode = BlendMode.SrcIn
        }
    }
    
    canvas.apply {
        val nativeCanvas = this.nativeCanvas
        val canvasWidth = nativeCanvas.width.toFloat()
        val canvasHeight = nativeCanvas.height.toFloat()
    
        with(canvas.nativeCanvas) {
           drawColor(android.graphics.Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
    
            drawCircle(
                center = offset,
                radius = 400f,
                paint = paint
            )
    
            drawImageRect(
                image = imageBitmap,
                dstSize = IntSize(canvasWidth.toInt(), canvasHeight.toInt()),
                paint = erasePaint
            )
        }
    }
    

    Finally draw bitmap we used in Canvas to Image Composable using

    Image(
        modifier = canvasModifier.border(2.dp, Color.Green),
        bitmap = bmp.asImageBitmap(),
        contentDescription = null,
        contentScale = ContentScale.FillBounds
    )
    

    or you can save this modified ImageBitmap with watermark or any overlay you draw into canvas

    Full implementation

    @Composable
    fun NativeCanvasSample2(imageBitmap: ImageBitmap, modifier: Modifier) {
    
        BoxWithConstraints(modifier) {
    
            val imageWidth = constraints.maxWidth
            val imageHeight = constraints.maxHeight
    
            val bitmapWidth = imageBitmap.width
            val bitmapHeight = imageBitmap.height
    
            var offset by remember {
                mutableStateOf(Offset(bitmapWidth / 2f, bitmapHeight / 2f))
            }
    
            val bmp: Bitmap = remember {
                Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888)
            }
    
            val canvas: Canvas = remember {
                Canvas(bmp.asImageBitmap())
            }
    
            val paint = remember {
                Paint()
            }
    
            val erasePaint = remember {
                Paint().apply {
                    color = Color.Red
                    blendMode = BlendMode.SrcIn
                }
            }
    
            canvas.apply {
                val nativeCanvas = this.nativeCanvas
                val canvasWidth = nativeCanvas.width.toFloat()
                val canvasHeight = nativeCanvas.height.toFloat()
    
                with(canvas.nativeCanvas) {
                    drawColor(android.graphics.Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
    
                    drawCircle(
                        center = offset,
                        radius = 400f,
                        paint = paint
                    )
    
                    drawImageRect(
                        image = imageBitmap,
                        dstSize = IntSize(canvasWidth.toInt(), canvasHeight.toInt()),
                        paint = erasePaint
                    )
                }
            }
            
            val canvasModifier = Modifier.pointerMotionEvents(
                Unit,
                onDown = {
                    val position = it.position
                    val offsetX = position.x * bitmapWidth / imageWidth
                    val offsetY = position.y * bitmapHeight / imageHeight
                    offset = Offset(offsetX, offsetY)
                    it.consume()
                },
                onMove = {
                    val position = it.position
                    val offsetX = position.x * bitmapWidth / imageWidth
                    val offsetY = position.y * bitmapHeight / imageHeight
                    offset = Offset(offsetX, offsetY)
                    it.consume()
                },
                delayAfterDownInMillis = 20
            )
    
            Image(
                modifier = canvasModifier.border(2.dp, Color.Green),
                bitmap = bmp.asImageBitmap(),
                contentDescription = null,
                contentScale = ContentScale.FillBounds
            )
        }
    }
    

    Result

    enter image description here