From b7984656b773627b1fb48f0c06fd6f43f4b6c34a Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Thu, 17 Sep 2026 10:59:06 +0100 Subject: [PATCH] Update backdropBlur to use BlurRenderSpec --- .../jetchat/blur/BackdropBlurModifier.kt | 271 +++++++++++++----- .../jetchat/components/JetchatAppBar.kt | 23 +- .../jetchat/conversation/Conversation.kt | 151 +++++++--- Jetchat/gradle/libs.versions.toml | 2 +- 4 files changed, 319 insertions(+), 128 deletions(-) diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/blur/BackdropBlurModifier.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/blur/BackdropBlurModifier.kt index c6560d6028..b9a2918b6a 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/blur/BackdropBlurModifier.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/blur/BackdropBlurModifier.kt @@ -21,9 +21,11 @@ import android.graphics.RenderNode import android.graphics.Shader import android.os.Build import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.blur.BlurRadiusSpec import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.platform.InspectorInfo @@ -31,53 +33,6 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -/** - * Specification for blur radius parameters, capable of creating a hardware [RenderEffect] - * for use with [RenderNode.setBackdropRenderEffect]. - * - * @param radiusX The horizontal blur radius. - * @param radiusY The vertical blur radius (defaults to [radiusX]). - * @param tileMode The tile mode for handling edges (defaults to [Shader.TileMode.CLAMP]). - */ -data class BlurRadiusSpec(val radiusX: Dp, val radiusY: Dp = radiusX, val tileMode: Shader.TileMode = Shader.TileMode.CLAMP) { - /** - * Creates an Android [RenderEffect] configured with this specification. - */ - fun createRenderEffect(density: Density): RenderEffect? { - val rxPx = with(density) { radiusX.toPx() } - val ryPx = with(density) { radiusY.toPx() } - return createRenderEffect(rxPx, ryPx, tileMode) - } - - companion object { - /** - * Creates a hardware [RenderEffect] blur effect from pixel radii. - */ - fun createRenderEffect( - radiusXPx: Float, - radiusYPx: Float = radiusXPx, - tileMode: Shader.TileMode = Shader.TileMode.CLAMP, - ): RenderEffect? { - if (Build.VERSION.SDK_INT >= 31 && (radiusXPx > 0f || radiusYPx > 0f)) { - return RenderEffect.createBlurEffect( - radiusXPx.coerceAtLeast(0.01f), - radiusYPx.coerceAtLeast(0.01f), - tileMode, - ) - } - return null - } - - /** - * Creates a hardware [RenderEffect] blur effect from a [Dp] radius. - */ - fun createRenderEffect(radius: Dp, density: Density, tileMode: Shader.TileMode = Shader.TileMode.CLAMP): RenderEffect? { - val px = with(density) { radius.toPx() } - return createRenderEffect(px, px, tileMode) - } - } -} - /** * Applies an in-window backdrop [RenderEffect] to content drawn behind this composable in the window. * @@ -110,10 +65,36 @@ fun Modifier.backdropRenderEffect( ) /** - * Draws the content behind this composable blurred with the specified [radius], + * Overload of [backdropRenderEffect] accepting Compose's [androidx.compose.ui.graphics.RenderEffect]. + */ +fun Modifier.backdropRenderEffect( + renderEffect: androidx.compose.ui.graphics.RenderEffect?, + shape: Shape = RectangleShape, + tint: Color = Color.Unspecified, + elevation: Dp = 0.dp, + outerShadowOnly: Boolean = true, + fallbackColor: Color = if (tint.isSpecified) tint else Color.Transparent, +): Modifier = backdropRenderEffect( + renderEffect = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + renderEffect?.asAndroidRenderEffect() + } else { + null + }, + shape = shape, + tint = tint, + elevation = elevation, + outerShadowOnly = outerShadowOnly, + fallbackColor = fallbackColor, +) + +/** + * Draws the content behind this composable blurred according to the provided [spec], * clipped to [shape], beneath this composable's own content. * - * @param radius The blur radius to apply to the backdrop. + * Uses Compose's built-in [BlurRadiusSpec] to configure uniform or spatially-varying blur + * radii (such as [BlurRadiusSpec.uniform], [BlurRadiusSpec.verticalGradient], etc.). + * + * @param spec The [BlurRadiusSpec] defining the blur radius or gradient. * @param shape The shape of the frosted-glass region. * @param tint An optional translucent color overlay drawn over the blurred backdrop. * @param elevation Optional elevation shadow cast by this component. @@ -121,15 +102,14 @@ fun Modifier.backdropRenderEffect( * @param fallbackColor An optional fallback background color for platforms earlier than Android 17. */ fun Modifier.backdropBlur( - radius: Dp, + spec: BlurRadiusSpec, shape: Shape = RectangleShape, tint: Color = Color.Unspecified, elevation: Dp = 0.dp, outerShadowOnly: Boolean = true, fallbackColor: Color = if (tint.isSpecified) tint else Color.Transparent, -): Modifier = backdropBlur( - radiusX = radius, - radiusY = radius, +): Modifier = this then BackdropBlurElement( + spec = spec, shape = shape, tint = tint, elevation = elevation, @@ -138,10 +118,10 @@ fun Modifier.backdropBlur( ) /** - * Overload of [backdropBlur] allowing independent horizontal and vertical blur radii. + * Draws the content behind this composable blurred with the specified uniform [radius], + * clipped to [shape], beneath this composable's own content. * - * @param radiusX The horizontal blur radius. - * @param radiusY The vertical blur radius. + * @param radius The blur radius to apply to the backdrop. * @param shape The shape of the frosted-glass region. * @param tint An optional translucent color overlay drawn over the blurred backdrop. * @param elevation Optional elevation shadow cast by this component. @@ -149,15 +129,14 @@ fun Modifier.backdropBlur( * @param fallbackColor An optional fallback background color for platforms earlier than Android 17. */ fun Modifier.backdropBlur( - radiusX: Dp, - radiusY: Dp, + radius: Dp, shape: Shape = RectangleShape, tint: Color = Color.Unspecified, elevation: Dp = 0.dp, outerShadowOnly: Boolean = true, fallbackColor: Color = if (tint.isSpecified) tint else Color.Transparent, ): Modifier = backdropBlur( - spec = BlurRadiusSpec(radiusX, radiusY), + spec = BlurRadiusSpec.uniform(radius), shape = shape, tint = tint, elevation = elevation, @@ -166,23 +145,44 @@ fun Modifier.backdropBlur( ) /** - * Overload of [backdropBlur] configured via a [BlurRadiusSpec]. + * Overload of [backdropBlur] allowing independent horizontal and vertical blur radii. + * + * @param radiusX The horizontal blur radius. + * @param radiusY The vertical blur radius. + * @param shape The shape of the frosted-glass region. + * @param tint An optional translucent color overlay drawn over the blurred backdrop. + * @param elevation Optional elevation shadow cast by this component. + * @param outerShadowOnly If true, clips out the shadow cast beneath the outline area. + * @param fallbackColor An optional fallback background color for platforms earlier than Android 17. */ fun Modifier.backdropBlur( - spec: BlurRadiusSpec, + radiusX: Dp, + radiusY: Dp, shape: Shape = RectangleShape, tint: Color = Color.Unspecified, elevation: Dp = 0.dp, outerShadowOnly: Boolean = true, fallbackColor: Color = if (tint.isSpecified) tint else Color.Transparent, -): Modifier = this then BackdropBlurElement( - spec = spec, - shape = shape, - tint = tint, - elevation = elevation, - outerShadowOnly = outerShadowOnly, - fallbackColor = fallbackColor, -) +): Modifier = if (radiusX == radiusY) { + backdropBlur( + spec = BlurRadiusSpec.uniform(radiusX), + shape = shape, + tint = tint, + elevation = elevation, + outerShadowOnly = outerShadowOnly, + fallbackColor = fallbackColor, + ) +} else { + this then BackdropEllipticalBlurElement( + radiusX = radiusX, + radiusY = radiusY, + shape = shape, + tint = tint, + elevation = elevation, + outerShadowOnly = outerShadowOnly, + fallbackColor = fallbackColor, + ) +} private data class BackdropRenderEffectElement( val renderEffect: RenderEffect?, @@ -262,6 +262,49 @@ private data class BackdropBlurElement( } } +private data class BackdropEllipticalBlurElement( + val radiusX: Dp, + val radiusY: Dp, + val shape: Shape, + val tint: Color, + val elevation: Dp, + val outerShadowOnly: Boolean, + val fallbackColor: Color, +) : ModifierNodeElement() { + override fun create(): BackdropEllipticalBlurNode = BackdropEllipticalBlurNode( + radiusX = radiusX, + radiusY = radiusY, + shape = shape, + tint = tint, + elevation = elevation, + outerShadowOnly = outerShadowOnly, + fallbackColor = fallbackColor, + ) + + override fun update(node: BackdropEllipticalBlurNode) { + node.update( + radiusX = radiusX, + radiusY = radiusY, + shape = shape, + tint = tint, + elevation = elevation, + outerShadowOnly = outerShadowOnly, + fallbackColor = fallbackColor, + ) + } + + override fun InspectorInfo.inspectableProperties() { + name = "backdropBlur" + properties["radiusX"] = radiusX + properties["radiusY"] = radiusY + properties["shape"] = shape + properties["tint"] = tint + properties["elevation"] = elevation + properties["outerShadowOnly"] = outerShadowOnly + properties["fallbackColor"] = fallbackColor + } +} + private class BackdropRenderEffectNode( var renderEffect: RenderEffect?, shape: Shape, @@ -317,13 +360,23 @@ private class BackdropBlurNode( private var cachedEffect: RenderEffect? = null private var cachedDensity: Float = -1f + private var cachedSize: Size = Size.Unspecified private var cachedSpec: BlurRadiusSpec? = null - override fun resolveRenderEffect(density: Density): RenderEffect? { + override fun resolveRenderEffect(density: Density, size: Size): RenderEffect? { val currentDensity = density.density - if (cachedEffect == null || cachedDensity != currentDensity || cachedSpec != spec) { - cachedEffect = spec.createRenderEffect(density) + if (cachedEffect == null || cachedDensity != currentDensity || cachedSize != size || cachedSpec != spec) { + cachedEffect = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && size.width > 0f && size.height > 0f) { + try { + spec.createRenderEffect(size, density).asAndroidRenderEffect() + } catch (_: Throwable) { + null + } + } else { + null + } cachedDensity = currentDensity + cachedSize = size cachedSpec = spec } return cachedEffect @@ -361,3 +414,77 @@ private class BackdropBlurNode( } } } + +private class BackdropEllipticalBlurNode( + var radiusX: Dp, + var radiusY: Dp, + shape: Shape, + tint: Color, + elevation: Dp, + outerShadowOnly: Boolean, + fallbackColor: Color, +) : BaseBackdropNode(shape, tint, elevation, outerShadowOnly, fallbackColor) { + + private var cachedEffect: RenderEffect? = null + private var cachedDensity: Float = -1f + private var cachedRadiusX: Dp = 0.dp + private var cachedRadiusY: Dp = 0.dp + + override fun resolveRenderEffect(density: Density): RenderEffect? { + val currentDensity = density.density + if (cachedEffect == null || cachedDensity != currentDensity || cachedRadiusX != radiusX || cachedRadiusY != radiusY) { + val rxPx = with(density) { radiusX.toPx() } + val ryPx = with(density) { radiusY.toPx() } + cachedEffect = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && (rxPx > 0f || ryPx > 0f)) { + RenderEffect.createBlurEffect( + rxPx.coerceAtLeast(0.01f), + ryPx.coerceAtLeast(0.01f), + Shader.TileMode.CLAMP, + ) + } else { + null + } + cachedDensity = currentDensity + cachedRadiusX = radiusX + cachedRadiusY = radiusY + } + return cachedEffect + } + + fun update(radiusX: Dp, radiusY: Dp, shape: Shape, tint: Color, elevation: Dp, outerShadowOnly: Boolean, fallbackColor: Color) { + var changed = false + if (this.radiusX != radiusX) { + this.radiusX = radiusX + cachedEffect = null + changed = true + } + if (this.radiusY != radiusY) { + this.radiusY = radiusY + cachedEffect = null + changed = true + } + if (this.shape != shape) { + this.shape = shape + changed = true + } + if (this.tint != tint) { + this.tint = tint + changed = true + } + if (this.elevation != elevation) { + this.elevation = elevation + changed = true + } + if (this.outerShadowOnly != outerShadowOnly) { + this.outerShadowOnly = outerShadowOnly + changed = true + } + if (this.fallbackColor != fallbackColor) { + this.fallbackColor = fallbackColor + changed = true + } + if (changed) { + markDirty() + } + } +} diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatAppBar.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatAppBar.kt index f76636b0b3..15d8f36d2d 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatAppBar.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/components/JetchatAppBar.kt @@ -22,9 +22,9 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable @@ -44,8 +44,17 @@ fun JetchatAppBar( onNavIconPressed: () -> Unit = { }, title: @Composable () -> Unit, actions: @Composable RowScope.() -> Unit = {}, + navigationIcon: @Composable () -> Unit = { + JetchatIcon( + contentDescription = stringResource(id = R.string.navigation_drawer_open), + modifier = Modifier + .size(64.dp) + .clickable(onClick = onNavIconPressed) + .padding(16.dp), + ) + }, ) { - CenterAlignedTopAppBar( + TopAppBar( modifier = modifier, actions = actions, title = title, @@ -54,15 +63,7 @@ fun JetchatAppBar( containerColor = Color.Transparent, scrolledContainerColor = Color.Transparent, ), - navigationIcon = { - JetchatIcon( - contentDescription = stringResource(id = R.string.navigation_drawer_open), - modifier = Modifier - .size(64.dp) - .clickable(onClick = onNavIconPressed) - .padding(16.dp), - ) - }, + navigationIcon = navigationIcon, ) } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt index 6dff34079f..802bd55af6 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.draganddrop.dragAndDropTarget +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -58,8 +59,10 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledIconButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -86,6 +89,8 @@ import androidx.compose.ui.draganddrop.toAndroidDragEvent import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.blur.BlurRadiusSpec +import androidx.compose.ui.graphics.blur.BlurStop import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.LastBaseline @@ -95,12 +100,12 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.example.compose.jetchat.FunctionalityNotAvailablePopup import com.example.compose.jetchat.R -import com.example.compose.jetchat.blur.BlurRadiusSpec import com.example.compose.jetchat.blur.backdropBlur import com.example.compose.jetchat.components.JetchatAppBar import com.example.compose.jetchat.data.exampleUiState @@ -200,17 +205,21 @@ fun ConversationContent( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), ) { paddingValues -> Column( - Modifier.fillMaxSize() + Modifier + .fillMaxSize() .padding(bottom = paddingValues.calculateBottomPadding()) .background(color = background) .border(width = 2.dp, color = borderStroke) - .dragAndDropTarget(shouldStartDragAndDrop = { event -> - event - .mimeTypes() - .contains( - ClipDescription.MIMETYPE_TEXT_PLAIN, - ) - }, target = dragAndDropCallback), + .dragAndDropTarget( + shouldStartDragAndDrop = { event -> + event + .mimeTypes() + .contains( + ClipDescription.MIMETYPE_TEXT_PLAIN, + ) + }, + target = dragAndDropCallback, + ), ) { Messages( messages = uiState.messages, @@ -243,7 +252,9 @@ fun ConversationContent( }, // let this element handle the padding so that the elevation is shown behind the // navigation bar - modifier = Modifier.navigationBarsPadding().imePadding(), + modifier = Modifier + .navigationBarsPadding() + .imePadding(), ) } } @@ -281,46 +292,98 @@ fun ChannelNameBar( .backdropBlur( tint = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), elevation = 0.dp, - radius = 12.dp, + spec = BlurRadiusSpec.verticalGradient( + listOf( + BlurStop(0.5f, 32.dp), + BlurStop(1f, 0.dp), + ), + ), ), scrollBehavior = scrollBehavior, onNavIconPressed = onNavIconPressed, + navigationIcon = {}, title = { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - // Channel name - Text( - text = channelName, - style = MaterialTheme.typography.titleMedium, - ) - // Number of members - Text( - text = stringResource(R.string.members, channelMembers), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Surface( + onClick = onNavIconPressed, + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.65f), + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + text = channelName.removePrefix("#"), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy((-4).dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(id = R.drawable.ali), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .border(1.dp, MaterialTheme.colorScheme.surface, CircleShape), + ) + Image( + painter = painterResource(id = R.drawable.someone_else), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .border(1.dp, MaterialTheme.colorScheme.surface, CircleShape), + ) + Image( + painter = painterResource(id = R.drawable.placeholder), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .border(1.dp, MaterialTheme.colorScheme.surface, CircleShape), + ) + } + } } }, actions = { - // Search icon - Icon( - painterResource(id = R.drawable.ic_search), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .clickable(onClick = { functionalityNotAvailablePopupShown = true }) - .padding(horizontal = 12.dp, vertical = 16.dp) - .height(24.dp), - contentDescription = stringResource(id = R.string.search), - ) - // Info icon - Icon( - painterResource(id = R.drawable.ic_info), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .clickable(onClick = { functionalityNotAvailablePopupShown = true }) - .padding(horizontal = 12.dp, vertical = 16.dp) - .height(24.dp), - contentDescription = stringResource(id = R.string.info), - ) + FilledIconButton( + onClick = { functionalityNotAvailablePopupShown = true }, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + modifier = Modifier.size(40.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_search), + contentDescription = stringResource(id = R.string.search), + modifier = Modifier.size(20.dp), + ) + } + Spacer(modifier = Modifier.width(8.dp)) + FilledIconButton( + onClick = { functionalityNotAvailablePopupShown = true }, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + modifier = Modifier.size(40.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_info), + contentDescription = stringResource(id = R.string.info), + modifier = Modifier.size(20.dp), + ) + } + Spacer(modifier = Modifier.width(8.dp)) }, ) } @@ -389,7 +452,7 @@ fun Messages( val jumpToBottomButtonEnabled by remember { derivedStateOf { scrollState.firstVisibleItemIndex != 0 || - scrollState.firstVisibleItemScrollOffset > jumpThreshold + scrollState.firstVisibleItemScrollOffset > jumpThreshold } } diff --git a/Jetchat/gradle/libs.versions.toml b/Jetchat/gradle/libs.versions.toml index f5e80315d1..b86936ae7e 100644 --- a/Jetchat/gradle/libs.versions.toml +++ b/Jetchat/gradle/libs.versions.toml @@ -8,7 +8,7 @@ android-material3 = "1.14.0" androidGradlePlugin = "9.3.1" androidx-activity-compose = "1.13.0" androidx-appcompat = "1.8.0" -androidx-compose-bom = "2026.08.01" +androidx-compose-bom = "2026.09.00" androidx-constraintlayout = "1.1.2" androidx-core-splashscreen = "1.2.0" androidx-corektx = "1.19.0"