[Compound] Bloom (#1253)

* Add `bloom` and `avatarBloom` modifiers.

* Add `ConnectivityIndicatorContainer` to control the padding needed at the top.

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa
2023-09-11 15:43:23 +02:00
committed by GitHub
parent ddb8f66c32
commit 94dc6f41e2
67 changed files with 913 additions and 173 deletions

1
changelog.d/1217.feature Normal file
View File

@@ -0,0 +1 @@
Implement Bloom effect modifier.

View File

@@ -18,6 +18,9 @@ package io.element.android.features.networkmonitor.api.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -27,34 +30,44 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.WifiOff
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
/**
* A view that displays a connectivity indicator when the device is offline, adding a default
* padding to make sure the status bar is not overlapped.
*/
@Composable
fun ConnectivityIndicatorView(
isOnline: Boolean,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
val isIndicatorVisible = remember { MutableTransitionState(!isOnline) }.apply { targetState = !isOnline }
val isStatusBarPaddingVisible = remember { MutableTransitionState(isOnline) }.apply { targetState = isOnline }
@@ -78,6 +91,46 @@ fun ConnectivityIndicatorView(
}
}
/**
* A view that displays a connectivity indicator when the device is offline, passing the padding
* needed to make sure the status bar is not overlapped to its content views.
*/
@Composable
fun ConnectivityIndicatorContainer(
isOnline: Boolean,
modifier: Modifier = Modifier,
content: @Composable (topPadding: Dp) -> Unit,
) {
val isIndicatorVisible = remember { MutableTransitionState(!isOnline) }.apply { targetState = !isOnline }
val statusBarTopPadding = if (LocalInspectionMode.current) {
// Needed to get valid UI previews
24.dp
} else {
WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 6.dp
}
val target = remember(isOnline) { if (isOnline) 0.dp else statusBarTopPadding }
val animationStateOffset by animateDpAsState(
targetValue = target,
animationSpec = spring(
stiffness = Spring.StiffnessMediumLow,
visibilityThreshold = 1.dp,
),
label = "insets-animation",
)
content(animationStateOffset)
// Display the network indicator with an animation
AnimatedVisibility(
visibleState = isIndicatorVisible,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
Indicator(modifier)
}
}
@Composable
private fun Indicator(modifier: Modifier = Modifier) {
Row(

View File

@@ -18,13 +18,13 @@ package io.element.android.features.roomlist.impl
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -45,7 +45,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import io.element.android.features.leaveroom.api.LeaveRoomView
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer
import io.element.android.features.roomlist.impl.components.RequestVerificationHeader
import io.element.android.features.roomlist.impl.components.RoomListMenuAction
import io.element.android.features.roomlist.impl.components.RoomListTopBar
@@ -76,8 +76,10 @@ fun RoomListView(
onMenuActionClicked: (RoomListMenuAction) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
ConnectivityIndicatorContainer(
modifier = modifier,
isOnline = state.hasNetworkConnection,
) { topPadding ->
Box {
fun onRoomLongClicked(
roomListRoomSummary: RoomListRoomSummary
@@ -96,6 +98,7 @@ fun RoomListView(
LeaveRoomView(state = state.leaveRoomState)
RoomListContent(
modifier = Modifier.padding(top = topPadding),
state = state,
onVerifyClicked = onVerifyClicked,
onRoomClicked = onRoomClicked,
@@ -111,6 +114,8 @@ fun RoomListView(
onRoomClicked = onRoomClicked,
onRoomLongClicked = { onRoomLongClicked(it) },
modifier = Modifier
.statusBarsPadding()
.padding(top = topPadding)
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
)

View File

@@ -17,10 +17,13 @@
package io.element.android.features.roomlist.impl.components
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -30,24 +33,37 @@ 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
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.input.nestedscroll.nestedScroll
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.tooling.preview.Preview
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import io.element.android.features.roomlist.impl.R
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatarBloom
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
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
import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.MediumTopAppBar
@@ -60,6 +76,9 @@ import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.designsystem.R as CommonR
private val avatarBloomSize = 430.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -89,6 +108,7 @@ fun RoomListTopBar(
DefaultRoomListTopBar(
matrixUser = matrixUser,
areSearchResultsDisplayed = areSearchResultsDisplayed,
onOpenSettings = onOpenSettings,
onSearchClicked = onToggleSearch,
onMenuActionClicked = onMenuActionClicked,
@@ -101,6 +121,7 @@ fun RoomListTopBar(
@Composable
private fun DefaultRoomListTopBar(
matrixUser: MatrixUser?,
areSearchResultsDisplayed: Boolean,
scrollBehavior: TopAppBarScrollBehavior,
onOpenSettings: () -> Unit,
onSearchClicked: () -> Unit,
@@ -108,94 +129,139 @@ private fun DefaultRoomListTopBar(
modifier: Modifier = Modifier,
) {
var showMenu by remember { mutableStateOf(false) }
MediumTopAppBar(
modifier = modifier
.nestedScroll(scrollBehavior.nestedScrollConnection),
title = {
val fontStyle = if (scrollBehavior.state.collapsedFraction > 0.5)
ElementTheme.typography.aliasScreenTitle
else
ElementTheme.typography.fontHeadingLgBold.copy(
// Due to a limitation of MediumTopAppBar, and to avoid the text to be truncated,
// ensure that the font size will never be bigger than 28.dp.
fontSize = 28.dp.applyScaleDown().toSp()
)
Text(
style = fontStyle,
text = stringResource(id = R.string.screen_roomlist_main_space_title)
)
},
navigationIcon = {
if (matrixUser != null) {
IconButton(
modifier = Modifier.testTag(TestTags.homeScreenSettings),
onClick = onOpenSettings
) {
val avatarData by remember {
derivedStateOf {
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
}
}
Avatar(avatarData, contentDescription = stringResource(CommonStrings.common_settings))
// 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) {
MediumTopAppBar(
modifier = Modifier
.onSizeChanged {
appBarHeight = it.height
}
}
},
actions = {
IconButton(
onClick = onSearchClicked,
) {
Icon(
imageVector = Icons.Default.Search,
tint = ElementTheme.materialColors.secondary,
contentDescription = stringResource(CommonStrings.action_search),
.nestedScroll(scrollBehavior.nestedScrollConnection)
.avatarBloom(
avatarData = avatarData,
background = ElementTheme.materialColors.background,
blurSize = DpSize(avatarBloomSize, avatarBloomSize),
offset = DpOffset(24.dp, 24.dp + statusBarPadding),
clipToSize = if (appBarHeight > 0) DpSize(
avatarBloomSize,
appBarHeight.toDp()
) else DpSize.Unspecified,
bottomSoftEdgeAlpha = 1f - collapsedFraction,
alpha = if (areSearchResultsDisplayed) 0f else 1f,
)
}
IconButton(
onClick = { showMenu = !showMenu }
) {
Icon(
imageVector = Icons.Default.MoreVert,
tint = ElementTheme.materialColors.secondary,
contentDescription = null,
.statusBarsPadding(),
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent,
),
title = {
val fontStyle = if (scrollBehavior.state.collapsedFraction > 0.5)
ElementTheme.typography.aliasScreenTitle
else
ElementTheme.typography.fontHeadingLgBold.copy(
// Due to a limitation of MediumTopAppBar, and to avoid the text to be truncated,
// ensure that the font size will never be bigger than 28.dp.
fontSize = 28.dp.applyScaleDown().toSp()
)
Text(
style = fontStyle,
text = stringResource(id = R.string.screen_roomlist_main_space_title)
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
onClick = {
showMenu = false
onMenuActionClicked(RoomListMenuAction.InviteFriends)
},
text = { Text(stringResource(id = CommonStrings.action_invite)) },
leadingIcon = {
Icon(
Icons.Outlined.Share,
tint = ElementTheme.materialColors.secondary,
contentDescription = null,
},
navigationIcon = {
avatarData?.let {
IconButton(
modifier = Modifier.testTag(TestTags.homeScreenSettings),
onClick = onOpenSettings
) {
Avatar(
avatarData = it,
contentDescription = stringResource(CommonStrings.common_settings),
)
}
)
DropdownMenuItem(
onClick = {
showMenu = false
onMenuActionClicked(RoomListMenuAction.ReportBug)
},
text = { Text(stringResource(id = CommonStrings.common_report_a_bug)) },
leadingIcon = {
Icon(
Icons.Outlined.BugReport,
tint = ElementTheme.materialColors.secondary,
contentDescription = null,
)
}
)
}
},
scrollBehavior = scrollBehavior,
windowInsets = WindowInsets(0.dp),
)
}
},
actions = {
IconButton(
onClick = onSearchClicked,
) {
Icon(
resourceId = CommonR.drawable.ic_search,
contentDescription = stringResource(CommonStrings.action_search),
)
}
IconButton(
onClick = { showMenu = !showMenu }
) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = null,
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
onClick = {
showMenu = false
onMenuActionClicked(RoomListMenuAction.InviteFriends)
},
text = { Text(stringResource(id = CommonStrings.action_invite)) },
leadingIcon = {
Icon(
Icons.Outlined.Share,
tint = ElementTheme.materialColors.secondary,
contentDescription = null,
)
}
)
DropdownMenuItem(
onClick = {
showMenu = false
onMenuActionClicked(RoomListMenuAction.ReportBug)
},
text = { Text(stringResource(id = CommonStrings.common_report_a_bug)) },
leadingIcon = {
Icon(
Icons.Outlined.BugReport,
tint = ElementTheme.materialColors.secondary,
contentDescription = null,
)
}
)
}
},
scrollBehavior = scrollBehavior,
windowInsets = WindowInsets(0.dp),
)
HorizontalDivider(modifier =
Modifier.fillMaxWidth()
.alpha(collapsedFraction)
.align(Alignment.BottomCenter),
color = ElementTheme.materialColors.outlineVariant,
)
}
}
@Preview
@@ -211,6 +277,7 @@ internal fun DefaultRoomListTopBarDarkPreview() = ElementPreviewDark { DefaultRo
private fun DefaultRoomListTopBarPreview() {
DefaultRoomListTopBar(
matrixUser = MatrixUser(UserId("@id:domain"), "Alice"),
areSearchResultsDisplayed = false,
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()),
onOpenSettings = {},
onSearchClicked = {},

View File

@@ -0,0 +1,534 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.components
import android.graphics.Bitmap
import android.graphics.Typeface
import android.graphics.drawable.BitmapDrawable
import android.os.Build
import android.text.TextPaint
import androidx.annotation.FloatRange
import androidx.compose.foundation.Image
import androidx.compose.foundation.isSystemInDarkTheme
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.material.icons.Icons
import androidx.compose.material.icons.filled.Share
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.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.ContentScale
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.res.painterResource
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 coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
import com.airbnb.android.showkase.annotation.ShowkaseComposable
import com.vanniktech.blurhash.BlurHash
import io.element.android.libraries.designsystem.R
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
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.theme.ElementTheme
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
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 = 8
/** Default bloom layers. */
@Composable
fun defaultLayers() = persistentListOf(
// Bottom layer
if (isSystemInDarkTheme()) {
BloomLayer(0.5f, BlendMode.Exclusion)
} else {
BloomLayer(0.2f, BlendMode.Hardlight)
},
// Top layer
BloomLayer(if (isSystemInDarkTheme()) 0.2f else 0.8f, 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 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.
*/
fun Modifier.bloom(
hash: String?,
background: Color,
blurSize: DpSize = DpSize.Unspecified,
offset: DpOffset = DpOffset.Unspecified,
clipToSize: DpSize = DpSize.Unspecified,
layerConfiguration: ImmutableList<BloomLayer>? = null,
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(hash, BloomDefaults.HASH_COMPONENTS, BloomDefaults.HASH_COMPONENTS)?.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, background),
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. If `null` is passed, no bloom effect will be applied.
* @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 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.
*/
fun Modifier.avatarBloom(
avatarData: AvatarData?,
background: Color,
blurSize: DpSize = DpSize.Unspecified,
offset: DpOffset = DpOffset.Unspecified,
clipToSize: DpSize = DpSize.Unspecified,
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
avatarData ?: return@composed this
if (avatarData.url != null) {
// Request the avatar contents to use as the bloom source
val painter = rememberAsyncImagePainter(
ImageRequest.Builder(LocalContext.current)
.data(avatarData)
// Needed to be able to read pixels from the Bitmap for the hash
.allowHardware(false)
.build()
)
var blurHash by remember { mutableStateOf<String?>(null) }
LaunchedEffect(avatarData) {
val drawable = painter.imageLoader.execute(painter.request).drawable ?: return@LaunchedEffect
val bitmap = (drawable as? BitmapDrawable)?.bitmap ?: return@LaunchedEffect
blurHash = BlurHash.encode(bitmap, BloomDefaults.HASH_COMPONENTS, BloomDefaults.HASH_COMPONENTS)
}
bloom(
hash = blurHash,
background = background,
blurSize = blurSize,
offset = offset,
clipToSize = clipToSize,
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, ElementTheme.isLightTheme)
val initialsBitmap = initialsBitmap(
width = avatarData.size.dp,
height = avatarData.size.dp,
text = avatarData.initial,
textColor = avatarColors.foreground,
backgroundColor = avatarColors.background,
)
val hash = remember(avatarData, avatarColors) {
BlurHash.encode(initialsBitmap.asAndroidBitmap(), BloomDefaults.HASH_COMPONENTS, BloomDefaults.HASH_COMPONENTS)
}
bloom(
hash = hash,
background = background,
blurSize = blurSize,
offset = offset,
clipToSize = clipToSize,
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)
@DayNightPreviews
@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 {
Scaffold(
modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
Box {
MediumTopAppBar(
modifier = Modifier
.onSizeChanged { size ->
topAppBarHeight = size.height
}
.bloom(
hash = blurhash,
background = ElementTheme.materialColors.background,
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 = {
Image(
modifier = Modifier
.padding(start = 8.dp)
.size(32.dp)
.clip(CircleShape),
painter = painterResource(id = R.drawable.sample_avatar),
contentScale = ContentScale.Crop,
contentDescription = null
)
},
actions = {
IconButton(onClick = {}) {
Icon(imageVector = Icons.Default.Share, 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 InitialsColorStateProvider : PreviewParameterProvider<Int> {
override val values: Sequence<Int>
get() = sequenceOf(0, 1, 2, 3, 4, 5, 6, 7)
}
@DayNightPreviews
@Composable
@ShowkaseComposable(group = PreviewGroup.Bloom)
internal fun BloomInitialsPreview(@PreviewParameter(InitialsColorStateProvider::class) color: Int) {
ElementPreview {
val avatarColors = AvatarColorsProvider.provide("$color", ElementTheme.isLightTheme)
val bitmap = initialsBitmap(text = "F", backgroundColor = avatarColors.background, textColor = avatarColors.foreground)
val hash = BlurHash.encode(bitmap.asAndroidBitmap(), BloomDefaults.HASH_COMPONENTS, BloomDefaults.HASH_COMPONENTS)
Box(
modifier = Modifier.size(256.dp)
.bloom(
hash = hash,
background = ElementTheme.materialColors.background,
blurSize = DpSize(256.dp, 256.dp),
),
contentAlignment = Alignment.Center
) {
Image(
modifier = Modifier
.size(32.dp)
.clip(CircleShape),
painter = BitmapPainter(bitmap),
contentDescription = null
)
}
}
}

View File

@@ -20,7 +20,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
enum class AvatarSize(val dp: Dp) {
CurrentUserTopBar(28.dp),
CurrentUserTopBar(32.dp),
RoomHeader(96.dp),
RoomListItem(52.dp),

View File

@@ -19,6 +19,7 @@ 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,25 @@
<!--
~ Copyright (c) 2023 New Vector Ltd
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M15.05 16.46C13.79 17.43 12.2 18 10.5 18 6.36 18 3 14.64 3 10.5 3 6.36 6.36 3 10.5 3c4.14 0 7.5 3.36 7.5 7.5 0 1.71-0.57 3.29-1.54 4.55l3.25 3.24c0.39 0.4 0.39 1.03 0 1.42-0.4 0.39-1.03 0.39-1.42 0l-3.24-3.25ZM16 10.5C16 7.46 13.54 5 10.5 5S5 7.46 5 10.5 7.46 16 10.5 16s5.5-2.46 5.5-5.5Z"/>
</vector>