Search code examples
androidandroid-animationandroid-jetpack-compose

When to initialize resources to be used inside Compose Canvas?


I am using Jetpack Compose and I want to create a circle with custom shadow/gradient effects. As far as I know there is no way to create that with composable objects inside DrawScope and I have to use NativeCanvas instead. That works fine for my case, but as I remember when we use View and we write something in the onDraw() method, we SHOULD NOT INITIALIZE NEW OBJECTS there. Since the method is called on each 30/60fps when using animation and creating new objects for each call will lead to poor performance.

Where is the proper place to define those object BlurMaskFilter, RadialGradient, Paint so they could be re-initialized only when the size of the composable is changes?
I was wondering if I should define them as lateinit var outside the function and then use SideEffect, to initialize them? I forgot to mention that I am using InfiniteTransition, and then using the state to change shapes that are drawn inside the NativeCanvas!

Box(
    modifier = Modifier
        .size(widthDp, widthDp)
        .drawBehind {

            drawIntoCanvas { canvas ->
                canvas.nativeCanvas.apply {
                    
                    val blurMask = BlurMaskFilter(
                        15f,
                        BlurMaskFilter.Blur.NORMAL
                    )
                    val radialGradient = android.graphics.RadialGradient(
                        100f, 100f, 50f,
                        intArrayOf(android.graphics.Color.WHITE, android.graphics.Color.BLACK),
                        floatArrayOf(0f, 0.9f), android.graphics.Shader.TileMode.CLAMP
                    )
                    val paint = Paint().asFrameworkPaint().apply {
                        shader = radialGradient
                        maskFilter = blurMask
                        color = android.graphics.Color.WHITE
                    } 
                    drawCircle(100f, 100f, 50f, paint)
                }
            }
        }
) {

}

Solution

  • There are two ways to keep some objects between recompositions in Compose - using remember or representation models. For this particular case remember is a better fit.

    If you have a static size given by Modifier.size(widthDp, widthDp), it is easy to calculate everything in advance:

    val density = LocalDensity.current
    val paint = remember(widthDp) {
        // in case you need to use width in your calculations
        val widthPx = with(density) {
            widthDp.toPx()
        }
        val blurMask = BlurMaskFilter(
            15f,
            BlurMaskFilter.Blur.NORMAL
        )
        val radialGradient = android.graphics.RadialGradient(
            100f, 100f, 50f,
            intArrayOf(android.graphics.Color.WHITE, android.graphics.Color.BLACK),
            floatArrayOf(0f, 0.9f), android.graphics.Shader.TileMode.CLAMP
        )
        Paint().asFrameworkPaint().apply {
            shader = radialGradient
            maskFilter = blurMask
            color = android.graphics.Color.WHITE
        }
    }
    

    If you don't have a static size, for example you want to use Modifier.fillMaxSize, you can use Modifier.onSizeChanged to get the real size and update your Paint - that's why I pass size as key in the remember call - it will recalculate the value when the key changes.

    val (size, updateSize) = remember { mutableStateOf<IntSize?>(null) }
    val paint = remember(size) {
        if (size == null) {
            Paint()
        } else {
            Paint().apply { 
                // your code
            }
        }
    }
    Box(
        modifier = Modifier
            .fillMaxSize()
            .onSizeChanged(updateSize)
            .drawBehind {
               // ...
            }
    )