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 | Show 11 more comments1 Answer
Reset to default 1I 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
)
}
}
}
RenderEffect
as aShader
, 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:25drawWithCache()
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 aShader
, so we don't have any way to add it to anotherShader
. We can kinda replaceImageFilter.makeRuntimeShader()
… – Mike M. Commented Mar 18 at 16:54RenderEffect.createRuntimeShaderEffect()
, but there's no equivalent toImageFilter.makeBlur()
and its conversion to aShader
insidemakeRuntimeShader()
. 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