From f886bf7105102256fae643bfb1499a85fd3e9dc8 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 20 Jun 2024 11:22:14 +0200 Subject: [PATCH] Introduce CompositeAvatar to render heroes when main AvatarData does not have URL. --- .../designsystem/components/avatar/Avatar.kt | 21 ++- .../components/avatar/CompositeAvatar.kt | 153 ++++++++++++++++++ 2 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/CompositeAvatar.kt diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt index 173716aee3..4e8183977f 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/Avatar.kt @@ -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 diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/CompositeAvatar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/CompositeAvatar.kt new file mode 100644 index 0000000000..df33f791a9 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/CompositeAvatar.kt @@ -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, + 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() +)