Remove bloom effect and replace by linear gradient
Use semantic colors.
This commit is contained in:
committed by
Benoit Marty
parent
ca206617c4
commit
ea94ec32b4
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -61,8 +61,9 @@ class KonsistPreviewTest {
|
||||
.withoutName(
|
||||
"AsyncIndicatorFailurePreview",
|
||||
"AsyncIndicatorLoadingPreview",
|
||||
"BloomInitialsPreview",
|
||||
"BloomPreview",
|
||||
"BackgroundVerticalGradientDisabledPreview",
|
||||
"BackgroundVerticalGradientEnterprisePreview",
|
||||
"BackgroundVerticalGradientPreview",
|
||||
"CallScreenPipViewPreview",
|
||||
"ColorAliasesPreview",
|
||||
"DefaultRoomListTopBarWithIndicatorPreview",
|
||||
|
||||
Reference in New Issue
Block a user