Introduce CompositeAvatar to render heroes when main AvatarData does not have URL.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
Reference in New Issue
Block a user