最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

android - How to use RenderNode in Jetpack Compose to create frosted glass blur? - Stack Overflow

programmeradmin3浏览0评论

I'm trying to build build glass morph blur effect similar to in image below

based on this article using RenderNode with Jetpack Compose Canvas or draw scope. In article it's explained for ImageView inside onDraw

val contentNode = RenderNode("image")
val blurNode = RenderNode("blur")

override fun onDraw(canvas: Canvas?) {

    contentNode.setPosition(0, 0, width, height)
    val rnCanvas = contentNode.beginRecording()
    super.onDraw(rnCanvas)
    contentNode.endRecording()

    canvas?.drawRenderNode(contentNode)
    
    // ... rest of code below
}

How can i achieve this with Jetpack Compose?

I want to pass content of a Composable to a RenderNode, then draw it with blur above to create frozen glass effect below TopAppbar while my content is scrollable below it.

I made this sample for testing

@RequiresApi(Build.VERSION_CODES.S)
@Preview
@Composable
fun RenderNodeSample() {

    val renderNode = remember {
        RenderNode("myRenderNode").apply {
//            setPosition(0, 0, 100, 100)
        }
    }

    val blurNode = remember {
        RenderNode("blurNode")
    }

    val paint = remember {
        Paint().apply {
            color = Color.Red
        }
    }

    val imageBitmap = ImageBitmap.imageResource(R.drawable.landscape1)

    Canvas(
        modifier = Modifier
            .fillMaxWidth()
            .aspectRatio(3 / 4f)
            .padding(16.dp)
            .border(2.dp, Color.Green)
    ) {


        drawIntoCanvas { canvas: Canvas ->
            renderNode.setPosition(0, 0, size.width.toInt(), size.height.toInt())
            val renderCanvas: RecordingCanvas = renderNode.beginRecording()
            renderCanvas.drawRect(0f, 0f, 400f, 400f, paint.asFrameworkPaint())
            renderNode.endRecording()


            val top = size.height - 300

            blurNode.setPosition(0, top.toInt(), size.width.toInt(), size.height.toInt())
            blurNode.setRenderEffect(
                RenderEffect.createBlurEffect(
                    30f, 30f,
                    Shader.TileMode.CLAMP
                )
            )

            val blurCanvas = blurNode.beginRecording()
            blurCanvas.drawBitmap(imageBitmap.asAndroidBitmap(), 0f, 0f, paint.asFrameworkPaint())
            blurNode.endRecording()

            canvas.nativeCanvas.drawRenderNode(renderNode)
            canvas.nativeCanvas.drawRenderNode(blurNode)
        }
    }
}

and i can pass Bitmap of content, using GraphicsLayer, of scrollable Composable to contentNode but taking image on every frame looks overkill. How can i get contents of Composable to a RenderNode instead of that?

Edit

I think i found how to draw content into RenderNode by inspecting GraphicsLayer source code

override fun record(
    density: Density,
    layoutDirection: LayoutDirection,
    layer: GraphicsLayer,
    block: DrawScope.() -> Unit
) {
    val recordingCanvas =
        renderNode.start(
            maxOf(size.width, outlineSize.width),
            maxOf(size.height, outlineSize.height)
        )
    try {
        canvasHolder.drawInto(recordingCanvas) {
            canvasDrawScope.draw(density, layoutDirection, this, size.toSize(), layer, block)
        }
    } finally {
        renderNode.end(recordingCanvas)
    }
    isInvalidated = false
}

but for some reason content is not drawn into RenderNode while it does inside GraphicsLayer.

@RequiresApi(Build.VERSION_CODES.S)
@Preview
@Composable
fun RenderNodeSample() {

    val contentNode = remember {
        RenderNode("contentNode")
    }

    val blurNode = remember {
        RenderNode("blurNode")
    }

    val canvasHolder: CanvasHolder = remember {
        CanvasHolder()
    }

    Column {

        Canvas(
            modifier = Modifier
                .fillMaxWidth()
                .aspectRatio(4 / 3f)
                .border(2.dp, Color.Green)
        ) {

            drawIntoCanvas { canvas: Canvas ->

//                val top = size.height - 300
//
//                blurNode.setPosition(0, top.toInt(), size.width.toInt(), size.height.toInt())
//                blurNode.setRenderEffect(
//                    RenderEffect.createBlurEffect(
//                        30f, 30f,
//                        Shader.TileMode.CLAMP
//                    )
//                )
//
//                val blurCanvas = blurNode.beginRecording()
//                blurCanvas.drawRenderNode(contentNode)
//                blurNode.endRecording()

                canvas.nativeCanvas.drawRenderNode(contentNode)
            }
        }

        LazyColumn(
            modifier = Modifier
                .background(backgroundColor)
                .fillMaxSize()
                .drawWithContent {
                    drawContent()

                    contentNode.setPosition(0, 0, size.width.toInt(), size.height.toInt())
                    val recordingCanvas = contentNode.beginRecording()

                    try {
                        canvasHolder.drawInto(recordingCanvas) {
                            [email protected]()
                        }
                    } finally {
                        contentNode.endRecording()
                    }
                },
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            items(100) {

                Box(
                    modifier = Modifier.fillMaxWidth()
                        .background(Color.White, RoundedCornerShape(16.dp)).padding(16.dp)
                ) {
                    Text("Row $it", fontSize = 22.sp)
                }
            }
        }
    }
}

Snippet above should draw LazyColumn into canvas but it doesn't do it for some reason. When drawing is possible i think it would also be possible to apply blur by using blur node.

I'm trying to build build glass morph blur effect similar to in image below

based on this article using RenderNode with Jetpack Compose Canvas or draw scope. In article it's explained for ImageView inside onDraw

val contentNode = RenderNode("image")
val blurNode = RenderNode("blur")

override fun onDraw(canvas: Canvas?) {

    contentNode.setPosition(0, 0, width, height)
    val rnCanvas = contentNode.beginRecording()
    super.onDraw(rnCanvas)
    contentNode.endRecording()

    canvas?.drawRenderNode(contentNode)
    
    // ... rest of code below
}

How can i achieve this with Jetpack Compose?

I want to pass content of a Composable to a RenderNode, then draw it with blur above to create frozen glass effect below TopAppbar while my content is scrollable below it.

I made this sample for testing

@RequiresApi(Build.VERSION_CODES.S)
@Preview
@Composable
fun RenderNodeSample() {

    val renderNode = remember {
        RenderNode("myRenderNode").apply {
//            setPosition(0, 0, 100, 100)
        }
    }

    val blurNode = remember {
        RenderNode("blurNode")
    }

    val paint = remember {
        Paint().apply {
            color = Color.Red
        }
    }

    val imageBitmap = ImageBitmap.imageResource(R.drawable.landscape1)

    Canvas(
        modifier = Modifier
            .fillMaxWidth()
            .aspectRatio(3 / 4f)
            .padding(16.dp)
            .border(2.dp, Color.Green)
    ) {


        drawIntoCanvas { canvas: Canvas ->
            renderNode.setPosition(0, 0, size.width.toInt(), size.height.toInt())
            val renderCanvas: RecordingCanvas = renderNode.beginRecording()
            renderCanvas.drawRect(0f, 0f, 400f, 400f, paint.asFrameworkPaint())
            renderNode.endRecording()


            val top = size.height - 300

            blurNode.setPosition(0, top.toInt(), size.width.toInt(), size.height.toInt())
            blurNode.setRenderEffect(
                RenderEffect.createBlurEffect(
                    30f, 30f,
                    Shader.TileMode.CLAMP
                )
            )

            val blurCanvas = blurNode.beginRecording()
            blurCanvas.drawBitmap(imageBitmap.asAndroidBitmap(), 0f, 0f, paint.asFrameworkPaint())
            blurNode.endRecording()

            canvas.nativeCanvas.drawRenderNode(renderNode)
            canvas.nativeCanvas.drawRenderNode(blurNode)
        }
    }
}

and i can pass Bitmap of content, using GraphicsLayer, of scrollable Composable to contentNode but taking image on every frame looks overkill. How can i get contents of Composable to a RenderNode instead of that?

Edit

I think i found how to draw content into RenderNode by inspecting GraphicsLayer source code

override fun record(
    density: Density,
    layoutDirection: LayoutDirection,
    layer: GraphicsLayer,
    block: DrawScope.() -> Unit
) {
    val recordingCanvas =
        renderNode.start(
            maxOf(size.width, outlineSize.width),
            maxOf(size.height, outlineSize.height)
        )
    try {
        canvasHolder.drawInto(recordingCanvas) {
            canvasDrawScope.draw(density, layoutDirection, this, size.toSize(), layer, block)
        }
    } finally {
        renderNode.end(recordingCanvas)
    }
    isInvalidated = false
}

but for some reason content is not drawn into RenderNode while it does inside GraphicsLayer.

@RequiresApi(Build.VERSION_CODES.S)
@Preview
@Composable
fun RenderNodeSample() {

    val contentNode = remember {
        RenderNode("contentNode")
    }

    val blurNode = remember {
        RenderNode("blurNode")
    }

    val canvasHolder: CanvasHolder = remember {
        CanvasHolder()
    }

    Column {

        Canvas(
            modifier = Modifier
                .fillMaxWidth()
                .aspectRatio(4 / 3f)
                .border(2.dp, Color.Green)
        ) {

            drawIntoCanvas { canvas: Canvas ->

//                val top = size.height - 300
//
//                blurNode.setPosition(0, top.toInt(), size.width.toInt(), size.height.toInt())
//                blurNode.setRenderEffect(
//                    RenderEffect.createBlurEffect(
//                        30f, 30f,
//                        Shader.TileMode.CLAMP
//                    )
//                )
//
//                val blurCanvas = blurNode.beginRecording()
//                blurCanvas.drawRenderNode(contentNode)
//                blurNode.endRecording()

                canvas.nativeCanvas.drawRenderNode(contentNode)
            }
        }

        LazyColumn(
            modifier = Modifier
                .background(backgroundColor)
                .fillMaxSize()
                .drawWithContent {
                    drawContent()

                    contentNode.setPosition(0, 0, size.width.toInt(), size.height.toInt())
                    val recordingCanvas = contentNode.beginRecording()

                    try {
                        canvasHolder.drawInto(recordingCanvas) {
                            [email protected]()
                        }
                    } finally {
                        contentNode.endRecording()
                    }
                },
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            items(100) {

                Box(
                    modifier = Modifier.fillMaxWidth()
                        .background(Color.White, RoundedCornerShape(16.dp)).padding(16.dp)
                ) {
                    Text("Row $it", fontSize = 22.sp)
                }
            }
        }
    }
}

Snippet above should draw LazyColumn into canvas but it doesn't do it for some reason. When drawing is possible i think it would also be possible to apply blur by using blur node.

Share Improve this question edited Mar 17 at 15:15 Thracian asked Mar 17 at 7:52 ThracianThracian 68k21 gold badges213 silver badges395 bronze badges Recognized by Mobile Development Collective 16
  • chrisbanes.github.io/haze/latest does this help? – Raghunandan Commented Mar 17 at 7:59
  • 1 I fot the frost effect part in that example (and then accidentally deleted the comment). Here's the code again: drive.google/…. And it looks like: i.sstatic/vT1XZKfo.png. I haven't gotten around to messing with the shaders yet, but it looks like that second article is using Jetbrains' skiko library. – Mike M. Commented Mar 17 at 21:02
  • 1 AFAICT, we can't access or create the built-in blur RenderEffect as a Shader, which is what we would need for that last approach. They use Desktop, and it has a lot of features that Android doesn't. However, if you supply your own blur, it's pretty simple to put together: drive.google/…. That's a really cheap blur, just a proof of concept: i.sstatic/IYiXX0JW.png. If you go that route, I'm sure you can find a better one. Btw, the Compose preview doesn't seem to work well with this, but devices & emulators work fine – Mike M. Commented Mar 18 at 4:25
  • 1 Yeah, I was a bit surprised that drawWithCache() worked in the Preview without any state read during the draw, but I left it as is 'cause I knew you knew how to fix it if it was broken. As for that Pushing Pixels article, that's what I tried to reproduce with the last approach. Their example is injecting Skia's built-in blur into their own custom AGSL, then they just add the blend effect. Our problem is that Android doesn't offer any way to access the built-in blur as a Shader, so we don't have any way to add it to another Shader. We can kinda replace ImageFilter.makeRuntimeShader() – Mike M. Commented Mar 18 at 16:54
  • 1 …with RenderEffect.createRuntimeShaderEffect(), but there's no equivalent to ImageFilter.makeBlur() and its conversion to a Shader inside makeRuntimeShader(). That's actually the first time I'd worked with AGSL, and I can't find any good resources for it, apart from the quick reference, but that only really helps if you're already familiar with shader languages, which I'm not. Anyhoo, I'm too lazy to post answers anymore. Please feel free to copy or modify any of those examples for yours. Cheers! – Mike M. Commented Mar 18 at 16:54
 |  Show 11 more comments

1 Answer 1

Reset to default 1

I achieved result by using GraphicsLayer and RenderNode together but i'm curious if there is a way to achieve this without using GraphicsLayer. Also i had to use scroll offset to invalidate draw and if there is way to achieve invalidate without depending anything externally.

Result

First, created a GraphicsLayer that i can record content of LazyColumn and record drawn content into GraphicsLayer renderNode, it uses RenderNode also under the hood. Clipped top where it intersects with TopAppbar to not draw LazyColumn contents below it.

              .drawWithContent {

                    clipRect(
                        top = topAppbarHeight.toPx()
                    ) {
                        [email protected]()
                    }

                    graphicsLayer.record(
                        size = IntSize(size.width.toInt(), topAppbarHeight.roundToPx())
                    ) {
                        [email protected]()
                    }

Then inside Canvas where set blur effect for contentNode and call drawLayer to draw GraphicsLayer into renderNode, also need to set properties otherwise nothing is drawn into it.

Also i had to set a trigger to update which i used scroll position of LazyListState

contentNode.setPosition(0, 0, size.width.toInt(), size.height.toInt())
contentNode.setRenderEffect(
    RenderEffect.createBlurEffect(
        15f, 15f,
        Shader.TileMode.CLAMP
    )
)

// TODO This is a workaround to update draw
state.firstVisibleItemScrollOffset

drawIntoCanvas { canvas: Canvas ->
    val recordingCanvas = contentNode.beginRecording()
    canvasHolder.drawInto(recordingCanvas) {
        drawContext.also {
            it.layoutDirection = layoutDirection
            it.size = size
            it.canvas = this
        }
        drawLayer(graphicsLayer)
    }

    contentNode.endRecording()

    canvas.nativeCanvas.drawRenderNode(contentNode)
}

Full code

@RequiresApi(Build.VERSION_CODES.S)
@Preview
@Composable
fun RenderNodeSample() {

    val contentNode = remember {
        RenderNode("contentNode")
    }

    val topAppbarHeight = 180.dp
    val canvasHolder: CanvasHolder = remember {
        CanvasHolder()
    }

    val graphicsLayer = rememberGraphicsLayer()

    val state = rememberLazyListState()

    Box {
        LazyColumn(
            state = state,
            modifier = Modifier
                .background(backgroundColor)
                .fillMaxSize()
                .drawWithContent {

                    clipRect(
                        top = topAppbarHeight.toPx()
                    ) {
                        [email protected]()
                    }

                    graphicsLayer.record(
                        size = IntSize(size.width.toInt(), topAppbarHeight.roundToPx())
                    ) {
                        [email protected]()
                    }

//                    val recordingCanvas = contentNode.beginRecording()
//                    canvasHolder.drawInto(recordingCanvas) {
//                        drawContext.also {
//                            it.layoutDirection = layoutDirection
//                            it.size = size
//                            it.canvas = this
//                        }
////                        [email protected]()
//                        drawLayer(graphicsLayer)
//                    }
//
//                    contentNode.endRecording()

                },
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {

            item {
                Spacer(modifier = Modifier.height(topAppbarHeight))
            }

            items(100) {

                if (it == 5) {
                    Image(
                        modifier = Modifier.fillMaxWidth().aspectRatio(2f),
                        painter = painterResource(R.drawable.landscape11),
                        contentScale = ContentScale.Crop,
                        contentDescription = null
                    )
                } else {
                    Box(
                        modifier = Modifier.fillMaxWidth()
                            .background(Color.White, RoundedCornerShape(16.dp))
                            .padding(16.dp)
                    ) {
                        Text("Row $it", fontSize = 22.sp)
                    }
                }
            }
        }

        Box(
            modifier = Modifier.fillMaxWidth()

        ) {
            Canvas(
                modifier = Modifier
                    .drawWithContent {
                        drawContent()
                        drawRect(color = Color.White.copy(alpha = .5f))
                    }
                    .fillMaxWidth()
                    .height(topAppbarHeight)
            ) {
                contentNode.setPosition(0, 0, size.width.toInt(), size.height.toInt())
                contentNode.setRenderEffect(
                    RenderEffect.createBlurEffect(
                        15f, 15f,
                        Shader.TileMode.CLAMP
                    )
                )

                // TODO This is a workaround to update draw
                state.firstVisibleItemScrollOffset

                drawIntoCanvas { canvas: Canvas ->
                    val recordingCanvas = contentNode.beginRecording()
                    canvasHolder.drawInto(recordingCanvas) {
                        drawContext.also {
                            it.layoutDirection = layoutDirection
                            it.size = size
                            it.canvas = this
                        }
                        drawLayer(graphicsLayer)
                    }

                    contentNode.endRecording()

                    canvas.nativeCanvas.drawRenderNode(contentNode)
                }
            }

            Text(
                "Glass Blur",
                modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
                fontSize = 26.sp
            )
        }
    }
}
发布评论

评论列表(0)

  1. 暂无评论