From ea94ec32b418eab1dd3b9f8323d6da0bcfe1ab8e Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 15 Apr 2025 17:51:27 +0200 Subject: [PATCH] Remove bloom effect and replace by linear gradient Use semantic colors. --- .../home/impl/components/RoomListTopBar.kt | 64 +- .../background/OnboardingBackground.kt | 2 +- .../designsystem/components/Bloom.kt | 578 ------------------ .../designsystem/modifiers/Gradient.kt | 92 +++ .../designsystem/preview/PreviewGroup.kt | 1 - .../libraries/designsystem/utils/DrawScope.kt | 22 + .../tests/konsist/KonsistPreviewTest.kt | 5 +- 7 files changed, 125 insertions(+), 639 deletions(-) delete mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Bloom.kt create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Gradient.kt create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/DrawScope.kt diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListTopBar.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListTopBar.kt index 8071dead3d..f1f06afe6d 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListTopBar.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListTopBar.kt @@ -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, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/background/OnboardingBackground.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/background/OnboardingBackground.kt index b4830e0904..73bc7ca841 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/background/OnboardingBackground.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/background/OnboardingBackground.kt @@ -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. diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Bloom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Bloom.kt deleted file mode 100644 index fd4b380fdc..0000000000 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Bloom.kt +++ /dev/null @@ -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? = 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(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 { - override val values: Sequence - 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 - ) - } - } -} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Gradient.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Gradient.kt new file mode 100644 index 0000000000..9e1950df5f --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Gradient.kt @@ -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, + ) + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/PreviewGroup.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/PreviewGroup.kt index 79853f5983..aa2b4dd761 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/PreviewGroup.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/PreviewGroup.kt @@ -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" diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/DrawScope.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/DrawScope.kt new file mode 100644 index 0000000000..4db7082720 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/DrawScope.kt @@ -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) + } +} diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt index 3bf65574ed..240e382cf4 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt @@ -61,8 +61,9 @@ class KonsistPreviewTest { .withoutName( "AsyncIndicatorFailurePreview", "AsyncIndicatorLoadingPreview", - "BloomInitialsPreview", - "BloomPreview", + "BackgroundVerticalGradientDisabledPreview", + "BackgroundVerticalGradientEnterprisePreview", + "BackgroundVerticalGradientPreview", "CallScreenPipViewPreview", "ColorAliasesPreview", "DefaultRoomListTopBarWithIndicatorPreview",