Introduce AvatarType to be able to render space avatars

This commit is contained in:
Benoit Marty 2025-06-23 17:08:14 +02:00
parent ae96bc632f
commit 3669128e60
22 changed files with 337 additions and 78 deletions

View file

@ -10,7 +10,6 @@ package io.element.android.libraries.designsystem.components.avatar
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
@ -37,15 +36,50 @@ import timber.log.Timber
fun Avatar(
avatarData: AvatarData,
modifier: Modifier = Modifier,
avatarType: AvatarType = AvatarType.User,
contentDescription: String? = null,
// If not null, will be used instead of the size from avatarData
forcedAvatarSize: Dp? = null,
// If true, will show initials even if avatarData.url is not null
hideImage: Boolean = false,
) {
when (avatarType) {
is AvatarType.Room -> RoomAvatar(
avatarData = avatarData,
avatarType = avatarType,
modifier = modifier,
hideAvatarImage = hideImage,
contentDescription = contentDescription,
)
AvatarType.User -> UserAvatar(
avatarData = avatarData,
modifier = modifier,
contentDescription = contentDescription,
forcedAvatarSize = forcedAvatarSize,
hideImage = hideImage,
)
is AvatarType.Space -> SpaceAvatar(
avatarData = avatarData,
avatarType = avatarType,
modifier = modifier,
hideAvatarImage = hideImage,
contentDescription = contentDescription,
)
}
}
@Composable
private fun UserAvatar(
avatarData: AvatarData,
modifier: Modifier = Modifier,
contentDescription: String? = null,
forcedAvatarSize: Dp? = null,
hideImage: Boolean = false,
) {
if (avatarData.url.isNullOrBlank() || hideImage) {
InitialLetterAvatar(
avatarData = avatarData,
avatarType = AvatarType.User,
forcedAvatarSize = forcedAvatarSize,
modifier = modifier,
contentDescription = contentDescription,
@ -53,6 +87,7 @@ fun Avatar(
} else {
ImageAvatar(
avatarData = avatarData,
avatarType = AvatarType.User,
forcedAvatarSize = forcedAvatarSize,
modifier = modifier,
contentDescription = contentDescription,
@ -61,8 +96,9 @@ fun Avatar(
}
@Composable
private fun ImageAvatar(
internal fun ImageAvatar(
avatarData: AvatarData,
avatarType: AvatarType,
forcedAvatarSize: Dp?,
modifier: Modifier = Modifier,
contentDescription: String? = null,
@ -74,7 +110,7 @@ private fun ImageAvatar(
contentScale = ContentScale.Crop,
modifier = modifier
.size(size)
.clip(CircleShape)
.clip(avatarShape(avatarType))
) {
val collectedState by painter.state.collectAsState()
when (val state = collectedState) {
@ -85,12 +121,14 @@ private fun ImageAvatar(
}
InitialLetterAvatar(
avatarData = avatarData,
avatarType = avatarType,
forcedAvatarSize = forcedAvatarSize,
contentDescription = contentDescription,
)
}
else -> InitialLetterAvatar(
avatarData = avatarData,
avatarType = avatarType,
forcedAvatarSize = forcedAvatarSize,
contentDescription = contentDescription,
)
@ -99,8 +137,9 @@ private fun ImageAvatar(
}
@Composable
private fun InitialLetterAvatar(
internal fun InitialLetterAvatar(
avatarData: AvatarData,
avatarType: AvatarType,
forcedAvatarSize: Dp?,
contentDescription: String?,
modifier: Modifier = Modifier,
@ -109,6 +148,7 @@ private fun InitialLetterAvatar(
TextAvatar(
text = avatarData.initialLetter,
size = forcedAvatarSize ?: avatarData.size.dp,
avatarType = avatarType,
colors = avatarColors,
contentDescription = contentDescription,
modifier = modifier

View file

@ -31,9 +31,10 @@ import kotlin.math.sin
private const val MAX_AVATAR_COUNT = 4
@Composable
fun AvatarCluster(
internal fun AvatarCluster(
avatars: ImmutableList<AvatarData>,
modifier: Modifier = Modifier,
avatarType: AvatarType = AvatarType.User,
hideAvatarImages: Boolean = false,
contentDescription: String? = null,
) {
@ -50,6 +51,7 @@ fun AvatarCluster(
1 -> {
Avatar(
avatarData = limitedAvatars[0],
avatarType = avatarType,
modifier = modifier,
contentDescription = contentDescription,
hideImage = hideAvatarImages
@ -78,10 +80,10 @@ fun AvatarCluster(
}
Box(
modifier = modifier
.size(size.dp)
.semantics {
this.contentDescription = contentDescription.orEmpty()
},
.size(size.dp)
.semantics {
this.contentDescription = contentDescription.orEmpty()
},
contentAlignment = Alignment.Center,
) {
limitedAvatars.forEachIndexed { index, heroAvatar ->
@ -89,15 +91,16 @@ fun AvatarCluster(
val yOffset = (offsetRadius * sin(angle * index.toDouble() + angleOffset)).dp
Box(
modifier = Modifier
.size(heroAvatarSize)
.offset(
x = xOffset,
y = yOffset,
)
.size(heroAvatarSize)
.offset(
x = xOffset,
y = yOffset,
)
) {
Avatar(
avatarData = heroAvatar,
forcedAvatarSize = heroAvatarSize,
avatarType = avatarType,
hideImage = hideAvatarImages,
)
}

View file

@ -0,0 +1,24 @@
/*
* 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.components.avatar
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Shape
@Composable
fun avatarShape(
avatarType: AvatarType,
): Shape {
return when (avatarType) {
is AvatarType.Space -> RoundedCornerShape(avatarType.cornerSize)
is AvatarType.Room,
AvatarType.User -> CircleShape
}
}

View file

@ -0,0 +1,25 @@
/*
* 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.avatar
import androidx.compose.ui.unit.Dp
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
sealed interface AvatarType {
data object User : AvatarType
data class Room(
val isTombstoned: Boolean = false,
val heroes: ImmutableList<AvatarData> = persistentListOf(),
) : AvatarType
data class Space(
val cornerSize: Dp,
) : AvatarType
}

View file

@ -9,36 +9,46 @@ package io.element.android.libraries.designsystem.components.avatar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import kotlinx.collections.immutable.ImmutableList
@Composable
fun RoomAvatar(
internal fun RoomAvatar(
avatarData: AvatarData,
heroes: ImmutableList<AvatarData>,
avatarType: AvatarType.Room,
modifier: Modifier = Modifier,
isTombstoned: Boolean = false,
hideAvatarImage: Boolean = false,
contentDescription: String? = null,
) {
when {
isTombstoned -> {
avatarType.isTombstoned -> {
TombstonedRoomAvatar(
size = avatarData.size,
modifier = modifier,
avatarType = avatarType,
contentDescription = contentDescription
)
}
avatarData.url != null || heroes.isEmpty() -> {
Avatar(
avatarData = avatarData,
modifier = modifier,
contentDescription = contentDescription,
hideImage = hideAvatarImage
)
avatarData.url != null || avatarType.heroes.isEmpty() -> {
if (avatarData.url.isNullOrBlank() || hideAvatarImage) {
InitialLetterAvatar(
avatarData = avatarData,
avatarType = avatarType,
modifier = modifier,
contentDescription = contentDescription,
forcedAvatarSize = null,
)
} else {
ImageAvatar(
avatarData = avatarData,
avatarType = avatarType,
forcedAvatarSize = null,
modifier = modifier,
contentDescription = contentDescription,
)
}
}
else -> {
AvatarCluster(
avatars = heroes,
avatars = avatarType.heroes,
modifier = modifier,
hideAvatarImages = hideAvatarImage,
contentDescription = contentDescription

View file

@ -0,0 +1,74 @@
/*
* 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.components.avatar
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.utils.CommonDrawables
@Composable
fun SpaceAvatar(
avatarData: AvatarData,
avatarType: AvatarType.Space,
modifier: Modifier = Modifier,
isTombstoned: Boolean = false,
hideAvatarImage: Boolean = false,
contentDescription: String? = null,
) {
when {
isTombstoned -> TombstonedRoomAvatar(
size = avatarData.size,
avatarType = avatarType,
modifier = modifier,
contentDescription = contentDescription,
)
avatarData.url.isNullOrBlank() || hideAvatarImage -> InitialLetterAvatar(
avatarData = avatarData,
avatarType = avatarType,
modifier = modifier,
contentDescription = contentDescription,
forcedAvatarSize = null,
)
else -> ImageAvatar(
avatarData = avatarData,
avatarType = avatarType,
forcedAvatarSize = null,
modifier = modifier,
contentDescription = contentDescription,
)
}
}
@Preview(group = PreviewGroup.Avatars)
@Composable
internal fun SpaceAvatarPreview() =
ElementThemedPreview(
drawableFallbackForImages = CommonDrawables.sample_avatar,
) {
Row(
modifier = Modifier.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
SpaceAvatar(
avatarData = anAvatarData(),
avatarType = AvatarType.Space(cornerSize = 16.dp),
)
SpaceAvatar(
avatarData = anAvatarData(),
avatarType = AvatarType.Space(cornerSize = 16.dp),
isTombstoned = true,
)
}
}

View file

@ -8,9 +8,11 @@
package io.element.android.libraries.designsystem.components.avatar
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -34,12 +36,13 @@ internal fun TextAvatar(
size: Dp,
colors: AvatarColors,
contentDescription: String?,
avatarType: AvatarType,
modifier: Modifier = Modifier,
) {
Box(
modifier
.size(size)
.clip(CircleShape)
.clip(avatarShape(avatarType))
.background(color = colors.background)
) {
val fontSize = size.toSp() / 2
@ -64,13 +67,25 @@ internal fun TextAvatar(
@Preview(group = PreviewGroup.Avatars)
@Composable
internal fun TextAvatarPreview() = ElementPreview {
TextAvatar(
text = "AB",
size = 40.dp,
colors = AvatarColors(
background = ElementTheme.colors.bgSubtlePrimary,
foreground = ElementTheme.colors.iconPrimary,
),
contentDescription = null,
)
Row(
modifier = Modifier.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
listOf(
AvatarType.User,
AvatarType.Room(),
AvatarType.Space(8.dp),
).forEach { avatarType ->
TextAvatar(
text = "AB",
size = 40.dp,
colors = AvatarColors(
background = ElementTheme.colors.bgSubtlePrimary,
foreground = ElementTheme.colors.iconPrimary,
),
avatarType = avatarType,
contentDescription = null,
)
}
}
}

View file

@ -16,9 +16,10 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
@Composable
fun TombstonedRoomAvatar(
internal fun TombstonedRoomAvatar(
size: AvatarSize,
modifier: Modifier = Modifier,
avatarType: AvatarType,
contentDescription: String? = null,
) {
TextAvatar(
@ -29,7 +30,8 @@ fun TombstonedRoomAvatar(
foreground = ElementTheme.colors.iconTertiary
),
modifier = modifier,
contentDescription = contentDescription
avatarType = avatarType,
contentDescription = contentDescription,
)
}
@ -38,6 +40,7 @@ fun TombstonedRoomAvatar(
internal fun TombstonedRoomAvatarPreview() = ElementPreview {
TombstonedRoomAvatar(
size = AvatarSize.RoomListItem,
avatarType = AvatarType.Room(),
contentDescription = null,
)
}