Introduce CompositeAvatar to render heroes when main AvatarData does not have URL.

This commit is contained in:
Benoit Marty
2024-06-20 11:22:14 +02:00
parent 3aed09ef4d
commit f886bf7105
2 changed files with 170 additions and 4 deletions

View File

@@ -32,6 +32,7 @@ import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
@@ -52,18 +53,22 @@ fun Avatar(
avatarData: AvatarData,
modifier: Modifier = Modifier,
contentDescription: String? = null,
// If not null, will be used instead of the size from avatarData
forcedAvatarSize: Dp? = null,
) {
val commonModifier = modifier
.size(avatarData.size.dp)
.size(forcedAvatarSize ?: avatarData.size.dp)
.clip(CircleShape)
if (avatarData.url.isNullOrBlank()) {
InitialsAvatar(
avatarData = avatarData,
forcedAvatarSize = forcedAvatarSize,
modifier = commonModifier,
)
} else {
ImageAvatar(
avatarData = avatarData,
forcedAvatarSize = forcedAvatarSize,
modifier = commonModifier,
contentDescription = contentDescription,
)
@@ -73,6 +78,7 @@ fun Avatar(
@Composable
private fun ImageAvatar(
avatarData: AvatarData,
forcedAvatarSize: Dp?,
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
@@ -98,9 +104,15 @@ private fun ImageAvatar(
SideEffect {
Timber.e(state.result.throwable, "Error loading avatar $state\n${state.result}")
}
InitialsAvatar(avatarData = avatarData)
InitialsAvatar(
avatarData = avatarData,
forcedAvatarSize = forcedAvatarSize,
)
}
else -> InitialsAvatar(avatarData = avatarData)
else -> InitialsAvatar(
avatarData = avatarData,
forcedAvatarSize = forcedAvatarSize,
)
}
}
}
@@ -109,13 +121,14 @@ private fun ImageAvatar(
@Composable
private fun InitialsAvatar(
avatarData: AvatarData,
forcedAvatarSize: Dp?,
modifier: Modifier = Modifier,
) {
val avatarColors = AvatarColorsProvider.provide(avatarData.id, ElementTheme.isLightTheme)
Box(
modifier.background(color = avatarColors.background)
) {
val fontSize = avatarData.size.dp.toSp() / 2
val fontSize = (forcedAvatarSize ?: avatarData.size.dp).toSp() / 2
val originalFont = ElementTheme.typography.fontHeadingMdBold
val ratio = fontSize.value / originalFont.fontSize.value
val lineHeight = originalFont.lineHeight * ratio

View File

@@ -0,0 +1,153 @@
/*
* Copyright (c) 2024 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.avatar
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
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 java.util.Collections
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
@Composable
fun CompositeAvatar(
avatarData: AvatarData,
heroes: List<AvatarData>,
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
if (avatarData.url != null || heroes.isEmpty()) {
Avatar(avatarData, modifier, contentDescription)
} else {
val limitedHeroes = heroes.take(4)
val numberOfHeroes = limitedHeroes.size
if (numberOfHeroes == 4) {
// Swap 2 and 3 so that the 4th hero is at the bottom right
Collections.swap(limitedHeroes, 2, 3)
}
when (numberOfHeroes) {
0 -> {
// Cannot happen
}
1 -> {
Avatar(heroes[0], modifier, contentDescription)
}
else -> {
val angle = 2 * Math.PI / numberOfHeroes
val offsetRadius = when (numberOfHeroes) {
2 -> avatarData.size.dp.value / 4.2
3 -> avatarData.size.dp.value / 4.0
4 -> avatarData.size.dp.value / 3.1
else -> error("Unsupported number of heroes: $numberOfHeroes")
}
val heroAvatarSize = when (numberOfHeroes) {
2 -> avatarData.size.dp / 2.2f
3 -> avatarData.size.dp / 2.4f
4 -> avatarData.size.dp / 2.2f
else -> error("Unsupported number of heroes: $numberOfHeroes")
}
val angleOffset = when (numberOfHeroes) {
2 -> PI
3 -> 7 * PI / 6
4 -> 13 * PI / 4
else -> error("Unsupported number of heroes: $numberOfHeroes")
}
Box(
modifier = modifier
.size(avatarData.size.dp)
.semantics {
this.contentDescription = contentDescription.orEmpty()
},
contentAlignment = Alignment.Center,
) {
limitedHeroes.forEachIndexed { index, heroAvatar ->
val xOffset = (offsetRadius * cos(angle * index.toDouble() + angleOffset)).dp
val yOffset = (offsetRadius * sin(angle * index.toDouble() + angleOffset)).dp
Box(
modifier = Modifier
.size(heroAvatarSize)
.offset(
x = xOffset,
y = yOffset,
)
) {
Avatar(
heroAvatar,
forcedAvatarSize = heroAvatarSize,
)
}
}
}
}
}
}
}
@Preview(group = PreviewGroup.Avatars)
@Composable
internal fun CompositeAvatarPreview() = ElementThemedPreview {
val mainAvatar = anAvatarData(
id = "Zac",
name = "Zac",
size = AvatarSize.RoomListItem,
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
CompositeAvatar(
avatarData = mainAvatar,
heroes = List(0) { aHeroAvatarData(it) },
)
CompositeAvatar(
avatarData = mainAvatar,
heroes = List(1) { aHeroAvatarData(it) },
)
CompositeAvatar(
avatarData = mainAvatar,
heroes = List(2) { aHeroAvatarData(it) },
)
CompositeAvatar(
avatarData = mainAvatar,
heroes = List(3) { aHeroAvatarData(it) },
)
CompositeAvatar(
avatarData = mainAvatar,
heroes = List(4) { aHeroAvatarData(it) },
)
CompositeAvatar(
avatarData = mainAvatar,
heroes = List(5) { aHeroAvatarData(it) },
)
}
}
private fun aHeroAvatarData(i: Int) = anAvatarData(
id = ('A' + i).toString(),
name = ('A' + i).toString()
)