Remove bloom effect and replace by linear gradient

Use semantic colors.
This commit is contained in:
Benoit Marty
2025-04-15 17:51:27 +02:00
committed by Benoit Marty
parent ca206617c4
commit ea94ec32b4
7 changed files with 125 additions and 639 deletions

View File

@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
@@ -22,7 +21,6 @@ import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -30,14 +28,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import io.element.android.appconfig.RoomListConfig
import io.element.android.compound.theme.ElementTheme
@@ -51,12 +44,10 @@ import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.avatarBloom
import io.element.android.libraries.designsystem.modifiers.backgroundVerticalGradient
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.applyScaleDown
import io.element.android.libraries.designsystem.text.roundToPx
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.text.toSp
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.DropdownMenu
@@ -73,8 +64,6 @@ import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
private val avatarBloomSize = 430.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoomListTopBar(
@@ -126,25 +115,13 @@ private fun DefaultRoomListTopBar(
canReportBug: Boolean,
modifier: Modifier = Modifier,
) {
// We need this to manually clip the top app bar in preview mode
val previewAppBarHeight = if (LocalInspectionMode.current) {
112.dp.roundToPx()
} else {
null
}
val collapsedFraction = scrollBehavior.state.collapsedFraction
var appBarHeight by remember {
mutableIntStateOf(previewAppBarHeight ?: 0)
}
val avatarData by remember(matrixUser) {
derivedStateOf {
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
}
}
val statusBarPadding = with(LocalDensity.current) { WindowInsets.statusBars.getTop(this).toDp() }
Box(modifier = modifier) {
val collapsedTitleTextStyle = ElementTheme.typography.aliasScreenTitle
val expandedTitleTextStyle = ElementTheme.typography.fontHeadingLgBold.copy(
@@ -160,40 +137,13 @@ private fun DefaultRoomListTopBar(
titleLarge = collapsedTitleTextStyle
),
) {
Column(
modifier = Modifier
.onSizeChanged {
appBarHeight = it.height
}
.avatarBloom(
avatarData = avatarData,
background = if (ElementTheme.isLightTheme) {
// Workaround to display a very subtle bloom for avatars with very soft colors
Color(0xFFF9F9F9)
} else {
ElementTheme.colors.bgCanvasDefault
},
blurSize = DpSize(avatarBloomSize, avatarBloomSize),
offset = DpOffset(24.dp, 24.dp + statusBarPadding),
clipToSize = if (appBarHeight > 0) {
DpSize(
avatarBloomSize,
appBarHeight.toDp()
)
} else {
DpSize.Unspecified
},
bottomSoftEdgeColor = ElementTheme.colors.bgCanvasDefault,
bottomSoftEdgeAlpha = if (displayFilters) {
1f
} else {
1f - collapsedFraction
},
alpha = if (areSearchResultsDisplayed) 0f else 1f,
)
.statusBarsPadding(),
) {
Column {
MediumTopAppBar(
modifier = Modifier
.backgroundVerticalGradient(
isVisible = !areSearchResultsDisplayed,
)
.statusBarsPadding(),
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent,

View File

@@ -23,9 +23,9 @@ import androidx.compose.ui.graphics.LinearGradientShader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.drawWithLayer
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.utils.drawWithLayer
/**
* Gradient background for FTUE (onboarding) screens.

View File

@@ -1,578 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.components
import android.graphics.Bitmap
import android.graphics.Typeface
import android.os.Build
import android.text.TextPaint
import androidx.annotation.FloatRange
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.center
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.ClipOp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.LinearGradientShader
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.RadialGradientShader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.toOffset
import androidx.compose.ui.unit.toSize
import coil3.SingletonImageLoader
import coil3.request.ImageRequest
import coil3.request.allowHardware
import coil3.toBitmap
import com.airbnb.android.showkase.annotation.ShowkaseComposable
import com.vanniktech.blurhash.BlurHash
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.MediumTopAppBar
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.math.max
import kotlin.math.roundToInt
/**
* Default bloom configuration values.
*/
object BloomDefaults {
/**
* Number of components to use with BlurHash to generate the blur effect.
* Larger values mean more detailed blurs.
*/
const val HASH_COMPONENTS = 4
const val ENCODE_SIZE_PX = 20
const val DECODE_SIZE_PX = 5
/** Default bloom layers. */
@Composable
fun defaultLayers() = persistentListOf(
// Bottom layer
if (ElementTheme.isLightTheme) {
BloomLayer(0.2f, BlendMode.Hardlight)
} else {
BloomLayer(0.5f, BlendMode.Exclusion)
},
// Top layer
BloomLayer(if (ElementTheme.isLightTheme) 0.8f else 0.2f, BlendMode.Color),
)
}
/**
* Bloom layer configuration.
* @param alpha The alpha value to apply to the layer.
* @param blendMode The blend mode to apply to the layer.
*/
data class BloomLayer(
val alpha: Float,
val blendMode: BlendMode,
)
/**
* Bloom effect modifier. Applies a bloom effect to the component.
* @param hash The BlurHash to use as the bloom source.
* @param background The background color to use for the bloom effect. Since we use blend modes it must be non-transparent.
* @param blurSize The size of the bloom effect. If not specified the bloom effect will be the size of the component.
* @param offset The offset to use for the bloom effect. If not specified the bloom effect will be centered on the component.
* @param clipToSize The size to use for clipping the bloom effect. If not specified the bloom effect will not be clipped.
* @param layerConfiguration The configuration for the bloom layers. If not specified the default layers configuration will be used.
* @param bottomSoftEdgeColor The color to use for the bottom soft edge. If not specified the [background] color will be used.
* @param bottomSoftEdgeHeight The height of the bottom soft edge. If not specified the bottom soft edge will not be drawn.
* @param bottomSoftEdgeAlpha The alpha value to apply to the bottom soft edge.
* @param alpha The alpha value to apply to the bloom effect.
*/
@SuppressWarnings("ModifierComposed")
fun Modifier.bloom(
hash: String?,
background: Color,
blurSize: DpSize = DpSize.Unspecified,
offset: DpOffset = DpOffset.Unspecified,
clipToSize: DpSize = DpSize.Unspecified,
layerConfiguration: ImmutableList<BloomLayer>? = null,
bottomSoftEdgeColor: Color = background,
bottomSoftEdgeHeight: Dp = 40.dp,
@FloatRange(from = 0.0, to = 1.0)
bottomSoftEdgeAlpha: Float = 1.0f,
@FloatRange(from = 0.0, to = 1.0)
alpha: Float = 1f,
) = composed {
val defaultLayers = BloomDefaults.defaultLayers()
val layers = layerConfiguration ?: defaultLayers
// Bloom only works on API 29+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return@composed this
if (hash == null) return@composed this
val hashedBitmap = remember(hash) {
BlurHash.decode(
blurHash = hash,
width = BloomDefaults.DECODE_SIZE_PX,
height = BloomDefaults.DECODE_SIZE_PX,
)?.asImageBitmap()
} ?: return@composed this
val density = LocalDensity.current
val pixelSize = remember(blurSize, density) { blurSize.toIntSize(density) }
val clipToPixelSize = remember(clipToSize, density) { clipToSize.toIntSize(density) }
val bottomSoftEdgeHeightPixels = remember(bottomSoftEdgeHeight, density) { with(density) { bottomSoftEdgeHeight.roundToPx() } }
val isRTL = LocalLayoutDirection.current == LayoutDirection.Rtl
drawWithCache {
val dstSize = if (pixelSize != IntSize.Zero) {
pixelSize
} else {
IntSize(size.width.toInt(), size.height.toInt())
}
// Calculate where to place the center of the bloom effect
val centerOffset = if (offset.isSpecified) {
if (isRTL) {
IntOffset(
size.width.roundToInt() - offset.x.roundToPx(),
size.height.roundToInt() - offset.y.roundToPx(),
)
} else {
IntOffset(
offset.x.roundToPx(),
offset.y.roundToPx(),
)
}
} else {
IntOffset(
size.center.x.toInt(),
size.center.y.toInt(),
)
}
// Calculate the offset to draw the different layers and apply clipping
// This offset is applied to place the top left corner of the bloom effect
val layersOffset = if (offset.isSpecified) {
// Offsets the layers so the center of the bloom effect is at the provided offset value
IntOffset(
centerOffset.x - dstSize.width / 2,
centerOffset.y - dstSize.height / 2,
)
} else {
// Places the layers at the center of the component
IntOffset.Zero
}
val radius = max(dstSize.width, dstSize.height).toFloat() / 2
val circularGradientShader = RadialGradientShader(
centerOffset.toOffset(),
radius,
listOf(Color.Red, Color.Transparent),
listOf(0f, 1f)
)
val circularGradientBrush = ShaderBrush(circularGradientShader)
val bottomEdgeGradient = LinearGradientShader(
from = IntOffset(0, clipToPixelSize.height - bottomSoftEdgeHeightPixels).toOffset(),
to = IntOffset(0, clipToPixelSize.height).toOffset(),
listOf(Color.Transparent, bottomSoftEdgeColor),
listOf(0f, 1f)
)
val bottomEdgeGradientBrush = ShaderBrush(bottomEdgeGradient)
onDrawBehind {
if (dstSize != IntSize.Zero) {
val circleClipPath = Path().apply {
addOval(Rect(centerOffset.toOffset(), radius - 1))
}
// Clip the external radius of bloom gradient too, otherwise we have a 1px border
clipPath(circleClipPath, clipOp = ClipOp.Intersect) {
// Draw the bloom layers
drawWithLayer {
// Clip rect to the provided size if needed
if (clipToPixelSize != IntSize.Zero) {
drawContext.canvas.clipRect(Rect(Offset.Zero, clipToPixelSize.toSize()), ClipOp.Intersect)
}
// Draw background color for blending
drawRect(background, size = pixelSize.toSize())
// Draw layers
for (layer in layers) {
drawImage(
hashedBitmap,
srcSize = IntSize(BloomDefaults.HASH_COMPONENTS, BloomDefaults.HASH_COMPONENTS),
dstSize = dstSize,
dstOffset = layersOffset,
alpha = layer.alpha * alpha,
blendMode = layer.blendMode,
)
}
// Mask the layers erasing the outer radius using the gradient brush
drawCircle(
circularGradientBrush,
radius,
centerOffset.toOffset(),
blendMode = BlendMode.DstIn
)
}
}
// Draw the bottom soft edge
drawRect(
bottomEdgeGradientBrush,
topLeft = IntOffset(0, clipToPixelSize.height - bottomSoftEdgeHeight.roundToPx()).toOffset(),
size = IntSize(pixelSize.width, bottomSoftEdgeHeight.roundToPx()).toSize(),
alpha = bottomSoftEdgeAlpha
)
}
}
}
}
/**
* Bloom effect modifier for avatars. Applies a bloom effect to the component.
* @param avatarData The avatar data to use as the bloom source.
* If the avatar data has a URL it will be used as the bloom source, otherwise the initials will be used.
* @param background The background color to use for the bloom effect. Since we use blend modes it must be non-transparent.
* @param blurSize The size of the bloom effect. If not specified the bloom effect will be the size of the component.
* @param offset The offset to use for the bloom effect. If not specified the bloom effect will be centered on the component.
* @param clipToSize The size to use for clipping the bloom effect. If not specified the bloom effect will not be clipped.
* @param bottomSoftEdgeColor The color to use for the bottom soft edge. If not specified the [background] color will be used.
* @param bottomSoftEdgeHeight The height of the bottom soft edge. If not specified the bottom soft edge will not be drawn.
* @param bottomSoftEdgeAlpha The alpha value to apply to the bottom soft edge.
* @param alpha The alpha value to apply to the bloom effect.
*/
@SuppressWarnings("ModifierComposed")
fun Modifier.avatarBloom(
avatarData: AvatarData,
background: Color,
blurSize: DpSize = DpSize.Unspecified,
offset: DpOffset = DpOffset.Unspecified,
clipToSize: DpSize = DpSize.Unspecified,
bottomSoftEdgeColor: Color = background,
bottomSoftEdgeHeight: Dp = 40.dp,
@FloatRange(from = 0.0, to = 1.0)
bottomSoftEdgeAlpha: Float = 1.0f,
@FloatRange(from = 0.0, to = 1.0)
alpha: Float = 1f,
) = composed {
// Bloom only works on API 29+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return@composed this
// Request the avatar contents to use as the bloom source
val context = LocalContext.current
if (avatarData.url != null) {
val painterRequest = remember(avatarData) {
ImageRequest.Builder(context)
.data(avatarData)
// Allow cache and default dispatchers
.defaults(ImageRequest.Defaults())
// Needed to be able to read pixels from the Bitmap for the hash
.allowHardware(false)
// Reduce size so it loads faster for large avatars
.size(BloomDefaults.ENCODE_SIZE_PX, BloomDefaults.ENCODE_SIZE_PX)
.build()
}
// By making it saveable, we'll 'cache' the previous bloom effect until a new one is loaded
var blurHash by rememberSaveable(avatarData) { mutableStateOf<String?>(null) }
LaunchedEffect(avatarData) {
withContext(Dispatchers.IO) {
val bitmap = SingletonImageLoader.get(context)
.execute(painterRequest)
.image
?.toBitmap()
?: return@withContext
blurHash = BlurHash.encode(
bitmap = bitmap,
componentX = BloomDefaults.HASH_COMPONENTS,
componentY = BloomDefaults.HASH_COMPONENTS,
)
}
}
bloom(
hash = blurHash,
background = background,
blurSize = blurSize,
offset = offset,
clipToSize = clipToSize,
bottomSoftEdgeColor = bottomSoftEdgeColor,
bottomSoftEdgeHeight = bottomSoftEdgeHeight,
bottomSoftEdgeAlpha = bottomSoftEdgeAlpha,
alpha = alpha,
)
} else {
// There is no URL so we'll generate an avatar with the initials and use that as the bloom source
val avatarColors = AvatarColorsProvider.provide(avatarData.id)
val initialsBitmap = initialsBitmap(
width = BloomDefaults.ENCODE_SIZE_PX.toDp(),
height = BloomDefaults.ENCODE_SIZE_PX.toDp(),
text = avatarData.initialLetter,
textColor = avatarColors.foreground,
backgroundColor = avatarColors.background,
)
val hash = remember(avatarData, avatarColors) {
BlurHash.encode(
bitmap = initialsBitmap.asAndroidBitmap(),
componentX = BloomDefaults.HASH_COMPONENTS,
componentY = BloomDefaults.HASH_COMPONENTS,
)
}
bloom(
hash = hash,
background = background,
blurSize = blurSize,
offset = offset,
clipToSize = clipToSize,
bottomSoftEdgeColor = bottomSoftEdgeColor,
bottomSoftEdgeHeight = bottomSoftEdgeHeight,
bottomSoftEdgeAlpha = bottomSoftEdgeAlpha,
alpha = alpha,
)
}
}
// Used to create a Bitmap version of the initials avatar
@Composable
private fun initialsBitmap(
text: String,
backgroundColor: Color,
textColor: Color,
width: Dp = 32.dp,
height: Dp = 32.dp,
): ImageBitmap = with(LocalDensity.current) {
val backgroundPaint = remember(backgroundColor) {
Paint().also { it.color = backgroundColor }
}
val resolver: FontFamily.Resolver = LocalFontFamilyResolver.current
val fontSize = remember { height.toSp() / 2 }
val typeface: Typeface = remember(resolver) {
resolver.resolve(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontStyle = FontStyle.Normal,
)
}.value as Typeface
val textPaint = remember(textColor, typeface) {
TextPaint().apply {
color = textColor.toArgb()
textSize = fontSize.toPx()
this.typeface = typeface
}
}
val textMeasurer = rememberTextMeasurer()
val result = remember(text) { textMeasurer.measure(text, TextStyle.Default.copy(fontSize = fontSize)) }
val centerPx = remember(width, height) { IntOffset(width.roundToPx() / 2, height.roundToPx() / 2) }
remember(text, width, height, backgroundColor, textColor) {
val bitmap = Bitmap.createBitmap(width.roundToPx(), height.roundToPx(), Bitmap.Config.ARGB_8888).asImageBitmap()
androidx.compose.ui.graphics.Canvas(bitmap).also { canvas ->
canvas.drawCircle(centerPx.toOffset(), width.toPx() / 2, backgroundPaint)
canvas.nativeCanvas.drawText(text, centerPx.x.toFloat() - result.size.width / 2, centerPx.y * 2f - result.size.height / 2 - 4, textPaint)
}
bitmap
}
}
// Translates DP sizes into pixel sizes, taking into account unspecified values
private fun DpSize.toIntSize(density: Density) = with(density) {
if (isSpecified) {
IntSize(width.roundToPx(), height.roundToPx())
} else {
IntSize.Zero
}
}
/**
* Helper to draw to a canvas using layers. This allows us to apply clipping to those layers only.
*/
fun DrawScope.drawWithLayer(block: DrawScope.() -> Unit) {
with(drawContext.canvas.nativeCanvas) {
val checkPoint = saveLayer(null, null)
block()
restoreToCount(checkPoint)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight
@ShowkaseComposable(group = PreviewGroup.Bloom)
@Composable
internal fun BloomPreview() {
val blurhash = "eePn{tI?xExEja}ooKWWodjtNJoKR,j@a|sBWpS3WDbGazoKWWWWj@"
var topAppBarHeight by remember { mutableIntStateOf(-1) }
val topAppBarState = rememberTopAppBarState()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(topAppBarState)
ElementPreview(
drawableFallbackForImages = CommonDrawables.sample_avatar,
) {
Scaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
Box {
MediumTopAppBar(
modifier = Modifier
.onSizeChanged { size ->
topAppBarHeight = size.height
}
.bloom(
hash = blurhash,
background = ElementTheme.colors.bgCanvasDefault,
blurSize = DpSize(430.dp, 430.dp),
offset = DpOffset(24.dp, 24.dp),
clipToSize = if (topAppBarHeight > 0) DpSize(430.dp, topAppBarHeight.toDp()) else DpSize.Zero,
),
colors = TopAppBarDefaults.largeTopAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Black.copy(alpha = 0.05f),
),
navigationIcon = {
Avatar(
avatarData = AvatarData(
id = "sample-avatar",
name = "sample",
url = "aURL",
size = AvatarSize.CurrentUserTopBar,
),
avatarType = AvatarType.User,
)
},
actions = {
IconButton(onClick = {}) {
Icon(
imageVector = CompoundIcons.ShareAndroid(),
contentDescription = null,
)
}
},
title = {
Text("Title")
},
scrollBehavior = scrollBehavior,
)
}
},
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
repeat(20) {
Text("Content", modifier = Modifier.padding(vertical = 20.dp))
}
}
}
}
}
class InitialsColorIntProvider : PreviewParameterProvider<Int> {
override val values: Sequence<Int>
get() = sequenceOf(0, 1, 2, 3, 4, 5, 6, 7)
}
@PreviewsDayNight
@Composable
@ShowkaseComposable(group = PreviewGroup.Bloom)
internal fun BloomInitialsPreview(@PreviewParameter(InitialsColorIntProvider::class) color: Int) {
ElementPreview {
val avatarColors = AvatarColorsProvider.provide("$color")
val bitmap = initialsBitmap(text = "F", backgroundColor = avatarColors.background, textColor = avatarColors.foreground)
val hash = BlurHash.encode(
bitmap = bitmap.asAndroidBitmap(),
componentX = BloomDefaults.HASH_COMPONENTS,
componentY = BloomDefaults.HASH_COMPONENTS,
)
Box(
modifier = Modifier
.size(256.dp)
.bloom(
hash = hash,
background = if (ElementTheme.isLightTheme) {
// Workaround to display a very subtle bloom for avatars with very soft colors
Color(0xFFF9F9F9)
} else {
ElementTheme.colors.bgCanvasDefault
},
bottomSoftEdgeColor = ElementTheme.colors.bgCanvasDefault,
blurSize = DpSize(256.dp, 256.dp),
),
contentAlignment = Alignment.Center
) {
Image(
modifier = Modifier
.size(32.dp)
.clip(CircleShape),
painter = BitmapPainter(bitmap),
contentDescription = null
)
}
}
}

View File

@@ -0,0 +1,92 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.modifiers
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.LocalBuildMeta
/**
* Ref: https://www.figma.com/design/kcnHxunG1LDWXsJhaNuiHz/ER-145--Workspaces-V1?node-id=1141-24692
*/
@Stable
@Composable
fun Modifier.backgroundVerticalGradient(
isVisible: Boolean = true,
isEnterpriseBuild: Boolean = LocalBuildMeta.current.isEnterpriseBuild,
): Modifier {
if (!isVisible) return this
return background(
brush = Brush.verticalGradient(
colorStops = buildList {
if (isEnterpriseBuild) {
// For enterprise builds, ensure that we are theming the gradient
add(0f to ElementTheme.colors.textActionAccent.copy(alpha = 0.5f))
add(0.75f to ElementTheme.colors.bgCanvasDefault)
add(1f to Color.Transparent)
} else {
add(0f to ElementTheme.colors.gradientSubtleStop1)
add(1 / 5f to ElementTheme.colors.gradientSubtleStop2)
add(2 / 5f to ElementTheme.colors.gradientSubtleStop3)
add(3 / 5f to ElementTheme.colors.gradientSubtleStop4)
add(4 / 5f to ElementTheme.colors.gradientSubtleStop5)
add(1f to ElementTheme.colors.gradientSubtleStop6)
}
}.toTypedArray(),
),
alpha = 0.75f,
)
}
@PreviewsDayNight
@Composable
internal fun BackgroundVerticalGradientPreview() = ElementPreview {
Box(
modifier = Modifier
.fillMaxWidth()
.height(height = 100.dp)
.backgroundVerticalGradient()
)
}
@PreviewsDayNight
@Composable
internal fun BackgroundVerticalGradientEnterprisePreview() = ElementPreview {
Box(
modifier = Modifier
.fillMaxWidth()
.height(height = 100.dp)
.backgroundVerticalGradient(
isEnterpriseBuild = true,
)
)
}
@PreviewsDayNight
@Composable
internal fun BackgroundVerticalGradientDisabledPreview() = ElementPreview {
Box(
modifier = Modifier
.fillMaxWidth()
.height(height = 100.dp)
.backgroundVerticalGradient(
isVisible = false,
)
)
}

View File

@@ -11,7 +11,6 @@ package io.element.android.libraries.designsystem.preview
object PreviewGroup {
const val AppBars = "App Bars"
const val Avatars = "Avatars"
const val Bloom = "Bloom"
const val BottomSheets = "Bottom Sheets"
const val Buttons = "Buttons"
const val DateTimePickers = "DateTime pickers"

View File

@@ -0,0 +1,22 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.utils
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.nativeCanvas
/**
* Helper to draw to a canvas using layers. This allows us to apply clipping to those layers only.
*/
fun DrawScope.drawWithLayer(block: DrawScope.() -> Unit) {
with(drawContext.canvas.nativeCanvas) {
val checkPoint = saveLayer(null, null)
block()
restoreToCount(checkPoint)
}
}

View File

@@ -61,8 +61,9 @@ class KonsistPreviewTest {
.withoutName(
"AsyncIndicatorFailurePreview",
"AsyncIndicatorLoadingPreview",
"BloomInitialsPreview",
"BloomPreview",
"BackgroundVerticalGradientDisabledPreview",
"BackgroundVerticalGradientEnterprisePreview",
"BackgroundVerticalGradientPreview",
"CallScreenPipViewPreview",
"ColorAliasesPreview",
"DefaultRoomListTopBarWithIndicatorPreview",