From b7984656b773627b1fb48f0c06fd6f43f4b6c34a Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Thu, 17 Sep 2026 10:59:06 +0100 Subject: [PATCH 01/16] 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" From 5fd54570520cee0597a5ce831725b45b18e4fc1d Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Fri, 25 Sep 2026 09:25:05 +0100 Subject: [PATCH 02/16] Blur under the nav bar --- .../com/example/compose/jetchat/conversation/Conversation.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 802bd55af6..509c461322 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 @@ -290,7 +290,7 @@ fun ChannelNameBar( JetchatAppBar( modifier = modifier .backdropBlur( - tint = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), + fallbackColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), elevation = 0.dp, spec = BlurRadiusSpec.verticalGradient( listOf( From eddd6be6bd779fd35182e3d189e3200ead231aa9 Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Thu, 17 Sep 2026 10:59:06 +0100 Subject: [PATCH 03/16] Update backdropBlur to use BlurRenderSpec --- .../jetchat/blur/BackdropBlurModifier.kt | 271 +++++++++++++----- .../jetchat/components/JetchatAppBar.kt | 23 +- .../jetchat/conversation/Conversation.kt | 151 +++++++--- 3 files changed, 318 insertions(+), 127 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 } } From d65b6be886a82d9227c44c1dda048ff4c1512a6a Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Fri, 25 Sep 2026 09:25:05 +0100 Subject: [PATCH 04/16] Blur under the nav bar --- .../com/example/compose/jetchat/conversation/Conversation.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 802bd55af6..509c461322 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 @@ -290,7 +290,7 @@ fun ChannelNameBar( JetchatAppBar( modifier = modifier .backdropBlur( - tint = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), + fallbackColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), elevation = 0.dp, spec = BlurRadiusSpec.verticalGradient( listOf( From 560e48358f3491058ae4ac10cafad8b5ed370f9d Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Thu, 17 Sep 2026 10:59:06 +0100 Subject: [PATCH 05/16] Update backdropBlur to use BlurRenderSpec --- .../jetchat/blur/BackdropBlurModifier.kt | 271 +++++++++++++----- .../jetchat/components/JetchatAppBar.kt | 23 +- .../jetchat/conversation/Conversation.kt | 151 +++++++--- 3 files changed, 318 insertions(+), 127 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 } } From 6246dabe4bdd3474c1deb1698461d6687fb8ab54 Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Fri, 25 Sep 2026 09:25:05 +0100 Subject: [PATCH 06/16] Blur under the nav bar --- .../com/example/compose/jetchat/conversation/Conversation.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 802bd55af6..509c461322 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 @@ -290,7 +290,7 @@ fun ChannelNameBar( JetchatAppBar( modifier = modifier .backdropBlur( - tint = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), + fallbackColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), elevation = 0.dp, spec = BlurRadiusSpec.verticalGradient( listOf( From 1d2b346733551887e02e4f2e2ae356efe52187d2 Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Tue, 29 Sep 2026 14:18:43 +0100 Subject: [PATCH 07/16] spotless --- .../com/example/compose/jetchat/conversation/Conversation.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 509c461322..be4871aeb6 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 @@ -452,7 +452,7 @@ fun Messages( val jumpToBottomButtonEnabled by remember { derivedStateOf { scrollState.firstVisibleItemIndex != 0 || - scrollState.firstVisibleItemScrollOffset > jumpThreshold + scrollState.firstVisibleItemScrollOffset > jumpThreshold } } From 84c75711a5a10207ca0076a10667edca48bba4c2 Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Thu, 17 Sep 2026 10:59:06 +0100 Subject: [PATCH 08/16] Update backdropBlur to use BlurRenderSpec --- .../jetchat/blur/BackdropBlurModifier.kt | 271 +++++++++++++----- .../jetchat/components/JetchatAppBar.kt | 23 +- .../jetchat/conversation/Conversation.kt | 151 +++++++--- 3 files changed, 318 insertions(+), 127 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 } } From c25dad65d80ca500421910009df399d9a1298a6d Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Fri, 25 Sep 2026 09:25:05 +0100 Subject: [PATCH 09/16] Blur under the nav bar --- .../com/example/compose/jetchat/conversation/Conversation.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 802bd55af6..509c461322 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 @@ -290,7 +290,7 @@ fun ChannelNameBar( JetchatAppBar( modifier = modifier .backdropBlur( - tint = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), + fallbackColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), elevation = 0.dp, spec = BlurRadiusSpec.verticalGradient( listOf( From 1d0d547cb6d1c7ed66c40ea4a4818adcaa074745 Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Tue, 29 Sep 2026 14:18:43 +0100 Subject: [PATCH 10/16] spotless --- .../com/example/compose/jetchat/conversation/Conversation.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 509c461322..be4871aeb6 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 @@ -452,7 +452,7 @@ fun Messages( val jumpToBottomButtonEnabled by remember { derivedStateOf { scrollState.firstVisibleItemIndex != 0 || - scrollState.firstVisibleItemScrollOffset > jumpThreshold + scrollState.firstVisibleItemScrollOffset > jumpThreshold } } From fec5247b7341477b374b7881a90ea6848aef5b04 Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Wed, 30 Sep 2026 08:54:46 +0100 Subject: [PATCH 11/16] Fix test --- .../com/example/compose/jetchat/conversation/Conversation.kt | 5 +++++ 1 file changed, 5 insertions(+) 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 be4871aeb6..ec173cbfda 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 @@ -99,6 +99,7 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview @@ -303,8 +304,12 @@ fun ChannelNameBar( onNavIconPressed = onNavIconPressed, navigationIcon = {}, title = { + val navDrawerDescription = stringResource(R.string.navigation_drawer_open) Surface( onClick = onNavIconPressed, + modifier = Modifier.semantics { + contentDescription = navDrawerDescription + }, shape = RoundedCornerShape(20.dp), color = MaterialTheme.colorScheme.surface.copy(alpha = 0.65f), ) { From 2a8b8191b95442fe107f5d020bec1eac9c57b156 Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Thu, 1 Oct 2026 10:15:29 +0100 Subject: [PATCH 12/16] Enable parallel sync --- .gitignore | 1 + Jetchat/gradle.properties | 3 +++ 2 files changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index ddccb823a4..d20fec1841 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ proguard-project.txt *.iml .idea .kotlin/ +Jetchat/gradle/gradle-daemon-jvm.properties diff --git a/Jetchat/gradle.properties b/Jetchat/gradle.properties index 9299bc6d0f..95df5e3b8e 100644 --- a/Jetchat/gradle.properties +++ b/Jetchat/gradle.properties @@ -37,3 +37,6 @@ android.useAndroidX=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true From 8c620e50922eeefa7cd4850429d45c9cd008a0d1 Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Thu, 1 Oct 2026 10:39:06 +0100 Subject: [PATCH 13/16] Add backdrop blur to UserInput --- .../jetchat/conversation/Conversation.kt | 73 +++++++++++-------- 1 file changed, 43 insertions(+), 30 deletions(-) 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 6310d9d63f..949afc8456 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 @@ -219,6 +219,45 @@ fun ConversationContent( scrollBehavior = scrollBehavior, ) }, + bottomBar = { + UserInput( + onMessageSent = { content -> + uiState.addMessage( + Message(authorMe, content, timeNow), + ) + }, + onVideoMessageSent = { videoUri, content -> + uiState.addMessage( + Message( + author = authorMe, + content = content, + timestamp = timeNow, + videoUri = videoUri, + ), + ) + }, + resetScroll = { + scope.launch { + scrollState.scrollToItem(0) + } + }, + // let this element handle the padding so that the elevation is shown behind the + // navigation bar + modifier = Modifier + .imePadding() + .backdropBlur( + fallbackColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), + elevation = 0.dp, + spec = BlurRadiusSpec.verticalGradient( + listOf( + BlurStop(0f, 0.dp), + BlurStop(0.5f, 32.dp), + ), + ), + ) + .navigationBarsPadding(), + ) + }, // Exclude ime and navigation bar padding so this can be added by the UserInput composable contentWindowInsets = ScaffoldDefaults .contentWindowInsets @@ -229,7 +268,6 @@ fun ConversationContent( Column( Modifier .fillMaxSize() - .padding(bottom = paddingValues.calculateBottomPadding()) .background(color = background) .border(width = 2.dp, color = borderStroke) .dragAndDropTarget( @@ -248,37 +286,10 @@ fun ConversationContent( navigateToProfile = navigateToProfile, modifier = Modifier.weight(1f), scrollState = scrollState, - contentPadding = PaddingValues(top = paddingValues.calculateTopPadding()), + contentPadding = paddingValues, onVideoClick = { videoUri -> activeVideoUri = videoUri }, onMessageLikeToggled = onMessageLikeToggled, ) - UserInput( - onMessageSent = { content -> - uiState.addMessage( - Message(authorMe, content, timeNow), - ) - }, - onVideoMessageSent = { videoUri, content -> - uiState.addMessage( - Message( - author = authorMe, - content = content, - timestamp = timeNow, - videoUri = videoUri, - ), - ) - }, - resetScroll = { - scope.launch { - scrollState.scrollToItem(0) - } - }, - // let this element handle the padding so that the elevation is shown behind the - // navigation bar - modifier = Modifier - .navigationBarsPadding() - .imePadding(), - ) } } @@ -493,7 +504,9 @@ fun Messages( scrollState.animateScrollToItem(0) } }, - modifier = Modifier.align(Alignment.BottomCenter), + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = contentPadding.calculateBottomPadding()), ) } } From a37e122e5e67a570b8900fa34e156e91b3a204bc Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Thu, 1 Oct 2026 10:40:35 +0100 Subject: [PATCH 14/16] Spotless --- .../example/compose/jetchat/conversation/Conversation.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 949afc8456..4e93f65237 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 @@ -40,8 +40,8 @@ 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.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -102,10 +102,10 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.paint import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.graphics.blur.BlurRadiusSpec import androidx.compose.ui.graphics.blur.BlurStop +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale From 2cfee4fc0f5d10cf46ecbc1d283ef834e8c70300 Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Thu, 1 Oct 2026 10:44:27 +0100 Subject: [PATCH 15/16] Remove changes to gitignore --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index d20fec1841..ddccb823a4 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,3 @@ proguard-project.txt *.iml .idea .kotlin/ -Jetchat/gradle/gradle-daemon-jvm.properties From 0e336776cf494f81babfd9d3d4bc619610d50739 Mon Sep 17 00:00:00 2001 From: Rebecca Franks Date: Thu, 1 Oct 2026 12:06:25 +0100 Subject: [PATCH 16/16] Gate the effects with 37 check --- .../jetchat/blur/BackdropBlurModifier.kt | 12 ++-- .../compose/jetchat/blur/BaseBackdropNode.kt | 26 ++++---- .../compose/jetchat/blur/MagnifierShader.kt | 62 +++++++++---------- .../jetchat/conversation/Conversation.kt | 6 +- 4 files changed, 51 insertions(+), 55 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 b9a2918b6a..6d17f0eeed 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 @@ -36,7 +36,7 @@ import androidx.compose.ui.unit.dp /** * Applies an in-window backdrop [RenderEffect] to content drawn behind this composable in the window. * - * On supported platforms (Android 17 / SDK 37+), this leverages [RenderNode.setBackdropRenderEffect] + * On supported platforms (Android 17 / SDK 37.2+), this leverages [RenderNode.setBackdropRenderEffect] * to apply hardware-accelerated visual effects (like blur) to the backdrop before this composable * is drawn, enabling translucent floating navigation bars, top app bars, and frosted-glass cards. * @@ -46,7 +46,7 @@ import androidx.compose.ui.unit.dp * @param elevation Optional elevation shadow cast by this component. * @param outerShadowOnly If true, clips out the shadow cast beneath the outline area so the shadow * does not darken the translucent frosted glass interior. - * @param fallbackColor An optional fallback background color for platforms earlier than Android 17. + * @param fallbackColor An optional fallback background color for platforms earlier than Android 17 (37.2). */ fun Modifier.backdropRenderEffect( renderEffect: RenderEffect?, @@ -75,7 +75,7 @@ fun Modifier.backdropRenderEffect( 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 = if (Build.VERSION.SDK_INT_FULL >= Build.VERSION_CODES_FULL.CINNAMON_BUN) { renderEffect?.asAndroidRenderEffect() } else { null @@ -364,9 +364,10 @@ private class BackdropBlurNode( private var cachedSpec: BlurRadiusSpec? = null override fun resolveRenderEffect(density: Density, size: Size): RenderEffect? { + if (Build.VERSION.SDK_INT_FULL < Build.VERSION_CODES_FULL.CINNAMON_BUN) return null val currentDensity = density.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) { + cachedEffect = if (size.width > 0f && size.height > 0f) { try { spec.createRenderEffect(size, density).asAndroidRenderEffect() } catch (_: Throwable) { @@ -431,11 +432,12 @@ private class BackdropEllipticalBlurNode( private var cachedRadiusY: Dp = 0.dp override fun resolveRenderEffect(density: Density): RenderEffect? { + if (Build.VERSION.SDK_INT_FULL < Build.VERSION_CODES_FULL.CINNAMON_BUN) return null 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)) { + cachedEffect = if (rxPx > 0f || ryPx > 0f) { RenderEffect.createBlurEffect( rxPx.coerceAtLeast(0.01f), ryPx.coerceAtLeast(0.01f), diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/blur/BaseBackdropNode.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/blur/BaseBackdropNode.kt index 7715097cfb..42dcaf6323 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/blur/BaseBackdropNode.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/blur/BaseBackdropNode.kt @@ -24,6 +24,7 @@ import android.graphics.RenderEffect import android.graphics.RenderNode import android.os.Build import android.util.Log +import androidx.annotation.RequiresApi import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color @@ -85,11 +86,15 @@ abstract class BaseBackdropNode( isDirty = true } - // Suppress NewApi: Android Lint does not yet recognize Build.VERSION.SDK_INT_FULL - // checks as satisfying minor SDK 37.2 requirements. + // Suppress NewApi: Emulator/device images may report CINNAMON_BUN (37.0) even when + // setBackdropRenderEffect is annotated with CINNAMON_BUN_2 (37.2). @SuppressLint("NewApi") override fun ContentDrawScope.draw() { - val effect = resolveRenderEffect(this, size) + val effect = if (Build.VERSION.SDK_INT_FULL >= Build.VERSION_CODES_FULL.CINNAMON_BUN) { + resolveRenderEffect(this, size) + } else { + null + } if (Build.VERSION.SDK_INT_FULL >= Build.VERSION_CODES_FULL.CINNAMON_BUN && effect != null) { val widthPx = size.width.roundToInt() val heightPx = size.height.roundToInt() @@ -189,6 +194,7 @@ abstract class BaseBackdropNode( /** * Helper to populate an [AndroidOutline] from a Compose [Outline]. */ +@RequiresApi(Build.VERSION_CODES_FULL.CINNAMON_BUN) private fun updateAndroidOutline(androidOutline: AndroidOutline, composeOutline: Outline, width: Int, height: Int) { androidOutline.alpha = 1.0f when (composeOutline) { @@ -228,22 +234,12 @@ private fun updateAndroidOutline(androidOutline: AndroidOutline, composeOutline: AndroidPath.Direction.CW, ) } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - androidOutline.setPath(path) - } else { - @Suppress("DEPRECATION") - androidOutline.setConvexPath(path) - } + androidOutline.setPath(path) } } is Outline.Generic -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - androidOutline.setPath(composeOutline.path.asAndroidPath()) - } else { - @Suppress("DEPRECATION") - androidOutline.setConvexPath(composeOutline.path.asAndroidPath()) - } + androidOutline.setPath(composeOutline.path.asAndroidPath()) } } } diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/blur/MagnifierShader.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/blur/MagnifierShader.kt index 5867aa6e59..017b4ca0ad 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/blur/MagnifierShader.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/blur/MagnifierShader.kt @@ -138,7 +138,7 @@ fun createMagnifierEffect( specularIntensity: Float = 0.15f, tileMode: Shader.TileMode = Shader.TileMode.CLAMP, ): RenderEffect? { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return null + if (Build.VERSION.SDK_INT_FULL < Build.VERSION_CODES_FULL.CINNAMON_BUN) return null if (size.width <= 0f || size.height <= 0f) return null val blurEffect = if (blurRadiusPx > 0f) { @@ -149,41 +149,39 @@ fun createMagnifierEffect( ) } else null - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - try { - val lensShader = RuntimeShader(MAGNIFIER_LENS_SHADER).apply { - setFloatUniform("size", size.width, size.height) - setFloatUniform("zoom", zoom) - setFloatUniform("lensCurvature", lensCurvature) - setFloatUniform("chromaticAberration", chromaticAberrationPx) - } - val lensEffect = RenderEffect.createRuntimeShaderEffect(lensShader, "content") - - val stage1 = if (blurEffect != null) { - // inner = blurEffect (blurs backdrop first) - // outer = lensEffect (magnifies the blurred backdrop) - RenderEffect.createChainEffect(lensEffect, blurEffect) - } else { - lensEffect - } + try { + val lensShader = RuntimeShader(MAGNIFIER_LENS_SHADER).apply { + setFloatUniform("size", size.width, size.height) + setFloatUniform("zoom", zoom) + setFloatUniform("lensCurvature", lensCurvature) + setFloatUniform("chromaticAberration", chromaticAberrationPx) + } + val lensEffect = RenderEffect.createRuntimeShaderEffect(lensShader, "content") + + val stage1 = if (blurEffect != null) { + // inner = blurEffect (blurs backdrop first) + // outer = lensEffect (magnifies the blurred backdrop) + RenderEffect.createChainEffect(lensEffect, blurEffect) + } else { + lensEffect + } - if (rimIntensity > 0f || specularIntensity > 0f) { - val glassShader = RuntimeShader(GLASS_FINISH_SHADER).apply { - setFloatUniform("size", size.width, size.height) - setFloatUniform("rimIntensity", rimIntensity) - setFloatUniform("specularIntensity", specularIntensity) - } - val glassEffect = RenderEffect.createRuntimeShaderEffect(glassShader, "content") - - // inner = stage1 (magnified backdrop) - // outer = glassEffect (adds lens rim highlight & specular sheen) - return RenderEffect.createChainEffect(glassEffect, stage1) + if (rimIntensity > 0f || specularIntensity > 0f) { + val glassShader = RuntimeShader(GLASS_FINISH_SHADER).apply { + setFloatUniform("size", size.width, size.height) + setFloatUniform("rimIntensity", rimIntensity) + setFloatUniform("specularIntensity", specularIntensity) } + val glassEffect = RenderEffect.createRuntimeShaderEffect(glassShader, "content") - return stage1 - } catch (t: Throwable) { - Log.w("BackdropMagnifier", "Failed to create chained magnifier effect: ${t.message}") + // inner = stage1 (magnified backdrop) + // outer = glassEffect (adds lens rim highlight & specular sheen) + return RenderEffect.createChainEffect(glassEffect, stage1) } + + return stage1 + } catch (t: Throwable) { + Log.w("BackdropMagnifier", "Failed to create chained magnifier effect: ${t.message}") } return blurEffect 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 4e93f65237..4d203dca80 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 @@ -362,7 +362,7 @@ fun ChannelNameBar( ) { Image( painter = painterResource(id = R.drawable.ali), - contentDescription = null, + contentDescription = "Ali Conors", contentScale = ContentScale.Crop, modifier = Modifier .size(24.dp) @@ -371,7 +371,7 @@ fun ChannelNameBar( ) Image( painter = painterResource(id = R.drawable.someone_else), - contentDescription = null, + contentDescription = "Taylor Brooks", contentScale = ContentScale.Crop, modifier = Modifier .size(24.dp) @@ -380,7 +380,7 @@ fun ChannelNameBar( ) Image( painter = painterResource(id = R.drawable.placeholder), - contentDescription = null, + contentDescription = "John Glenn", contentScale = ContentScale.Crop, modifier = Modifier .size(24.dp)