From ae5e0d38acf5fada2c2fa461f0898e9b817d793d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Wed, 6 Sep 2023 14:27:52 +0200 Subject: [PATCH 1/3] fix: flickering placeholder avatar on profile screen [WPB-3917] --- .../com/wire/android/model/UserAvatarData.kt | 2 + .../android/ui/common/UserProfileAvatar.kt | 42 ++++++++--- .../ui/userprofile/common/UserProfileInfo.kt | 72 +++++++++++++------ .../other/OtherUserConnectionStatusInfo.kt | 2 + .../other/OtherUserProfileScreen.kt | 24 +++---- .../self/SelfUserProfileViewModel.kt | 5 +- .../android/util/ui/WireSessionImageLoader.kt | 1 - 7 files changed, 102 insertions(+), 46 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/model/UserAvatarData.kt b/app/src/main/kotlin/com/wire/android/model/UserAvatarData.kt index 519c4629a1b..3c84b0f3052 100644 --- a/app/src/main/kotlin/com/wire/android/model/UserAvatarData.kt +++ b/app/src/main/kotlin/com/wire/android/model/UserAvatarData.kt @@ -20,10 +20,12 @@ package com.wire.android.model +import androidx.compose.runtime.Stable import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.kalium.logic.data.user.ConnectionState import com.wire.kalium.logic.data.user.UserAvailabilityStatus +@Stable data class UserAvatarData( val asset: ImageAsset.UserAvatarAsset? = null, val availabilityStatus: UserAvailabilityStatus = UserAvailabilityStatus.NONE, diff --git a/app/src/main/kotlin/com/wire/android/ui/common/UserProfileAvatar.kt b/app/src/main/kotlin/com/wire/android/ui/common/UserProfileAvatar.kt index 514ac92efba..0267ff0c70f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/UserProfileAvatar.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/UserProfileAvatar.kt @@ -32,6 +32,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalInspectionMode @@ -54,10 +56,9 @@ fun UserProfileAvatar( avatarData: UserAvatarData = UserAvatarData(), size: Dp = MaterialTheme.wireDimensions.userAvatarDefaultSize, modifier: Modifier = Modifier, - clickable: Clickable? = null + clickable: Clickable? = null, + showPlaceholderIfNoAsset: Boolean = true, ) { - val painter = painter(avatarData) - Box( contentAlignment = Alignment.Center, modifier = modifier @@ -66,6 +67,7 @@ fun UserProfileAvatar( .clickable(clickable) .padding(MaterialTheme.wireDimensions.userAvatarClickablePadding) ) { + val painter = painter(avatarData, showPlaceholderIfNoAsset) Image( painter = painter, contentDescription = stringResource(R.string.content_description_user_avatar), @@ -89,20 +91,38 @@ fun UserProfileAvatar( * @see [painter] https://developer.android.com/jetpack/compose/tooling */ @Composable -private fun painter(data: UserAvatarData): Painter = if (data.connectionState == ConnectionState.BLOCKED) { - painterResource(id = R.drawable.ic_blocked_user_avatar) -} else if (LocalInspectionMode.current || data.asset == null) { - getDefaultAvatar(membership = data.membership) -} else { - data.asset.paint(R.drawable.ic_default_user_avatar) +private fun painter( + data: UserAvatarData, + showPlaceholderIfNoAsset: Boolean = true +): Painter = when { + LocalInspectionMode.current -> { + getDefaultAvatar(membership = data.membership) + } + + data.connectionState == ConnectionState.BLOCKED -> { + painterResource(id = R.drawable.ic_blocked_user_avatar) + } + + data.asset == null -> { + if (showPlaceholderIfNoAsset) getDefaultAvatar(membership = data.membership) + else ColorPainter(Color.Transparent) + } + + else -> { + data.asset.paint(getDefaultAvatarResourceId(membership = data.membership)) + } } @Composable private fun getDefaultAvatar(membership: Membership): Painter = + painterResource(id = getDefaultAvatarResourceId(membership)) + +@Composable +private fun getDefaultAvatarResourceId(membership: Membership): Int = if (membership == Membership.Service) { - painterResource(id = R.drawable.ic_default_service_avatar) + R.drawable.ic_default_service_avatar } else { - painterResource(id = R.drawable.ic_default_user_avatar) + R.drawable.ic_default_user_avatar } @Preview diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/common/UserProfileInfo.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/common/UserProfileInfo.kt index 8567bb6a5f9..b7ad7538dd1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/common/UserProfileInfo.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/common/UserProfileInfo.kt @@ -20,12 +20,16 @@ package com.wire.android.ui.userprofile.common +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.shape.CircleShape @@ -35,7 +39,10 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier @@ -56,13 +63,15 @@ import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.progress.WireCircularProgressIndicator import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.ui.theme.wireColorScheme -import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import com.wire.android.util.debug.LocalFeatureVisibilityFlags import com.wire.android.util.ifNotEmpty import com.wire.android.util.ui.UIText import com.wire.kalium.logic.data.user.ConnectionState import com.wire.kalium.logic.data.user.UserId +import kotlinx.coroutines.delay +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds @Composable fun UserProfileInfo( @@ -76,7 +85,8 @@ fun UserProfileInfo( onUserProfileClick: (() -> Unit)? = null, editableState: EditableState, modifier: Modifier = Modifier, - connection: ConnectionState = ConnectionState.ACCEPTED + connection: ConnectionState = ConnectionState.ACCEPTED, + delayToShowPlaceholderIfNoAsset: Duration = 200.milliseconds, ) { Column( horizontalAlignment = CenterHorizontally, @@ -87,29 +97,47 @@ fun UserProfileInfo( .padding(top = dimensions().spacing16x) ) { Box(contentAlignment = Alignment.Center) { - UserProfileAvatar( - size = dimensions().userAvatarDefaultBigSize, - avatarData = UserAvatarData( - asset = avatarAsset, - connectionState = connection, - membership = membership - ), - clickable = remember(editableState) { - Clickable( - enabled = editableState is EditableState.IsEditable, - clickBlockParams = ClickBlockParams(blockWhenSyncing = true, blockWhenConnecting = true), - ) { onUserProfileClick?.invoke() } - } + val userAvatarData = UserAvatarData( + asset = avatarAsset, + connectionState = connection, + membership = membership ) - if (isLoading) { + val showPlaceholderIfNoAsset = remember { mutableStateOf(!delayToShowPlaceholderIfNoAsset.isPositive()) } + val currentAssetIsNull = rememberUpdatedState(avatarAsset == null) + if (delayToShowPlaceholderIfNoAsset.isPositive()) { + LaunchedEffect(Unit) { + delay(delayToShowPlaceholderIfNoAsset) + showPlaceholderIfNoAsset.value = currentAssetIsNull.value // show placeholder if there is still no proper avatar data + } + } + Crossfade( + targetState = userAvatarData to showPlaceholderIfNoAsset.value, + label = "UserProfileInfoAvatar" + ) { (userAvatarData, showPlaceholderIfNoAsset) -> + UserProfileAvatar( + size = dimensions().userAvatarDefaultBigSize, + avatarData = userAvatarData, + clickable = remember(editableState) { + Clickable( + enabled = editableState is EditableState.IsEditable, + clickBlockParams = ClickBlockParams(blockWhenSyncing = true, blockWhenConnecting = true), + ) { onUserProfileClick?.invoke() } + }, + showPlaceholderIfNoAsset = showPlaceholderIfNoAsset + ) + } + this@Column.AnimatedVisibility(visible = isLoading) { Box( Modifier - .padding(MaterialTheme.wireDimensions.userAvatarClickablePadding) + .padding(dimensions().userAvatarClickablePadding) + .size(dimensions().userAvatarDefaultBigSize) .clip(CircleShape) - .background(MaterialTheme.wireColorScheme.onBackground.copy(alpha = 0.7f)) + .background(MaterialTheme.wireColorScheme.background.copy(alpha = 0.6f)) ) { WireCircularProgressIndicator( - progressColor = MaterialTheme.wireColorScheme.surface, + size = dimensions().spacing32x, + strokeWidth = dimensions().spacing4x, + progressColor = MaterialTheme.wireColorScheme.onBackground, modifier = Modifier.align(Alignment.Center) ) } @@ -119,6 +147,7 @@ fun UserProfileInfo( Modifier .fillMaxWidth() .wrapContentHeight() + .animateContentSize() ) { val (userDescription, editButton, teamDescription) = createRefs() @@ -135,7 +164,10 @@ fun UserProfileInfo( } ) { Text( - text = fullName.ifBlank { UIText.StringResource(R.string.username_unavailable_label).asString() }, + text = fullName.ifBlank { + if (isLoading) "" + else UIText.StringResource(R.string.username_unavailable_label).asString() + }, overflow = TextOverflow.Ellipsis, maxLines = 1, style = MaterialTheme.wireTypography.title02, diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserConnectionStatusInfo.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserConnectionStatusInfo.kt index f05b4f891a7..682b26cdbac 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserConnectionStatusInfo.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserConnectionStatusInfo.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import com.wire.android.R import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.spacers.VerticalSpace import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography @@ -73,6 +74,7 @@ fun OtherUserConnectionStatusInfo(connectionStatus: ConnectionState, membership: color = MaterialTheme.wireColorScheme.labelText, style = MaterialTheme.wireTypography.body01 ) + VerticalSpace.x24() } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt index 3bb2264b6d1..1ac3df8a6ab 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt @@ -340,21 +340,20 @@ private fun TopBarHeader( ) } -@SuppressLint("UnusedCrossfadeTargetStateParameter") @Composable private fun TopBarCollapsing(state: OtherUserProfileState) { - Crossfade(targetState = state.isDataLoading, label = "OtherUserProfileScreenTopBarCollapsing") { + Crossfade(targetState = state, label = "OtherUserProfileScreenTopBarCollapsing") { targetState -> UserProfileInfo( - userId = state.userId, - isLoading = state.isAvatarLoading, - avatarAsset = state.userAvatarAsset, - fullName = state.fullName, - userName = state.userName, - teamName = state.teamName, - membership = state.membership, + userId = targetState.userId, + isLoading = targetState.isAvatarLoading, + avatarAsset = targetState.userAvatarAsset, + fullName = targetState.fullName, + userName = targetState.userName, + teamName = targetState.teamName, + membership = targetState.membership, editableState = EditableState.NotEditable, modifier = Modifier.padding(bottom = dimensions().spacing16x), - connection = state.connectionState + connection = targetState.connectionState ) } } @@ -406,8 +405,9 @@ private fun Content( Crossfade(targetState = tabItems to state, label = "OtherUserProfile") { (tabItems, state) -> Column { - OtherUserConnectionStatusInfo(state.connectionState, state.membership) - x24() + if (!state.isDataLoading) { + OtherUserConnectionStatusInfo(state.connectionState, state.membership) + } when { state.isDataLoading || state.botService != null -> Box {} // no content visible while loading state.connectionState == ConnectionState.ACCEPTED -> { diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileViewModel.kt index 4913c0837ac..f239b15eeec 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileViewModel.kt @@ -95,7 +95,7 @@ class SelfUserProfileViewModel @Inject constructor( private val notificationManager: WireNotificationManager ) : ViewModel() { - var userProfileState by mutableStateOf(SelfUserProfileState(selfUserId)) + var userProfileState by mutableStateOf(SelfUserProfileState(userId = selfUserId, isAvatarLoading = true)) private set private lateinit var establishedCallsList: StateFlow> @@ -155,7 +155,8 @@ class SelfUserProfileViewModel @Inject constructor( userName = handle.orEmpty(), teamName = selfTeam?.name, otherAccounts = otherAccounts, - avatarAsset = userProfileState.avatarAsset + avatarAsset = userProfileState.avatarAsset, + isAvatarLoading = false, ) } } diff --git a/app/src/main/kotlin/com/wire/android/util/ui/WireSessionImageLoader.kt b/app/src/main/kotlin/com/wire/android/util/ui/WireSessionImageLoader.kt index c91d7a666b2..e9c39e11070 100644 --- a/app/src/main/kotlin/com/wire/android/util/ui/WireSessionImageLoader.kt +++ b/app/src/main/kotlin/com/wire/android/util/ui/WireSessionImageLoader.kt @@ -92,7 +92,6 @@ class WireSessionImageLoader( ) .build(), error = (fallbackData as? Int)?.let { painterResource(id = it) }, - placeholder = (fallbackData as? Int)?.let { painterResource(id = it) }, imageLoader = coilImageLoader ) From 7e663981bff4936f21ba80d2f4a34941da48165f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Wed, 6 Sep 2023 14:28:21 +0200 Subject: [PATCH 2/3] remove unused import --- .../wire/android/ui/userprofile/other/OtherUserProfileScreen.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt index 1ac3df8a6ab..fa98fb65576 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt @@ -82,7 +82,6 @@ import com.wire.android.ui.common.dialogs.UnblockUserDialogContent import com.wire.android.ui.common.dialogs.UnblockUserDialogState import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.snackbar.SwipeDismissSnackbarHost -import com.wire.android.ui.common.spacers.VerticalSpace.x24 import com.wire.android.ui.common.topBarElevation import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.visbility.rememberVisibilityState From cac02527cf2bd15ef99bde8136bfac735167a7ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Wed, 6 Sep 2023 14:49:48 +0200 Subject: [PATCH 3/3] add crossfade parameter to image loader --- app/src/main/kotlin/com/wire/android/model/ImageAsset.kt | 5 ++++- .../com/wire/android/ui/common/UserProfileAvatar.kt | 8 +++++--- .../wire/android/ui/userprofile/common/UserProfileInfo.kt | 3 ++- .../com/wire/android/util/ui/WireSessionImageLoader.kt | 4 +++- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/model/ImageAsset.kt b/app/src/main/kotlin/com/wire/android/model/ImageAsset.kt index b1b1c85fdbd..f6e30b81b11 100644 --- a/app/src/main/kotlin/com/wire/android/model/ImageAsset.kt +++ b/app/src/main/kotlin/com/wire/android/model/ImageAsset.kt @@ -74,7 +74,10 @@ sealed class ImageAsset(private val imageLoader: WireSessionImageLoader) { } @Composable - fun paint(fallbackData: Any? = null) = imageLoader.paint(asset = this, fallbackData) + fun paint( + fallbackData: Any? = null, + withCrossfadeAnimation: Boolean = false + ) = imageLoader.paint(asset = this, fallbackData = fallbackData, withCrossfadeAnimation = withCrossfadeAnimation) } fun String.parseIntoPrivateImageAsset( diff --git a/app/src/main/kotlin/com/wire/android/ui/common/UserProfileAvatar.kt b/app/src/main/kotlin/com/wire/android/ui/common/UserProfileAvatar.kt index 0267ff0c70f..5d2042bffe2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/UserProfileAvatar.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/UserProfileAvatar.kt @@ -58,6 +58,7 @@ fun UserProfileAvatar( modifier: Modifier = Modifier, clickable: Clickable? = null, showPlaceholderIfNoAsset: Boolean = true, + withCrossfadeAnimation: Boolean = false, ) { Box( contentAlignment = Alignment.Center, @@ -67,7 +68,7 @@ fun UserProfileAvatar( .clickable(clickable) .padding(MaterialTheme.wireDimensions.userAvatarClickablePadding) ) { - val painter = painter(avatarData, showPlaceholderIfNoAsset) + val painter = painter(avatarData, showPlaceholderIfNoAsset, withCrossfadeAnimation) Image( painter = painter, contentDescription = stringResource(R.string.content_description_user_avatar), @@ -93,7 +94,8 @@ fun UserProfileAvatar( @Composable private fun painter( data: UserAvatarData, - showPlaceholderIfNoAsset: Boolean = true + showPlaceholderIfNoAsset: Boolean = true, + withCrossfadeAnimation: Boolean = false, ): Painter = when { LocalInspectionMode.current -> { getDefaultAvatar(membership = data.membership) @@ -109,7 +111,7 @@ private fun painter( } else -> { - data.asset.paint(getDefaultAvatarResourceId(membership = data.membership)) + data.asset.paint(getDefaultAvatarResourceId(membership = data.membership), withCrossfadeAnimation) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/common/UserProfileInfo.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/common/UserProfileInfo.kt index b7ad7538dd1..f99943d1893 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/common/UserProfileInfo.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/common/UserProfileInfo.kt @@ -123,7 +123,8 @@ fun UserProfileInfo( clickBlockParams = ClickBlockParams(blockWhenSyncing = true, blockWhenConnecting = true), ) { onUserProfileClick?.invoke() } }, - showPlaceholderIfNoAsset = showPlaceholderIfNoAsset + showPlaceholderIfNoAsset = showPlaceholderIfNoAsset, + withCrossfadeAnimation = true, ) } this@Column.AnimatedVisibility(visible = isLoading) { diff --git a/app/src/main/kotlin/com/wire/android/util/ui/WireSessionImageLoader.kt b/app/src/main/kotlin/com/wire/android/util/ui/WireSessionImageLoader.kt index e9c39e11070..70be26d10f5 100644 --- a/app/src/main/kotlin/com/wire/android/util/ui/WireSessionImageLoader.kt +++ b/app/src/main/kotlin/com/wire/android/util/ui/WireSessionImageLoader.kt @@ -77,7 +77,8 @@ class WireSessionImageLoader( @Composable fun paint( asset: ImageAsset?, - fallbackData: Any? = null + fallbackData: Any? = null, + withCrossfadeAnimation: Boolean = false, ): Painter { var retryHash by remember { mutableStateOf(0) } val exponentialDurationHelper = remember { ExponentialDurationHelperImpl(MIN_RETRY_DELAY, MAX_RETRY_DELAY) } @@ -90,6 +91,7 @@ class WireSessionImageLoader( value = retryHash, memoryCacheKey = null ) + .crossfade(withCrossfadeAnimation) .build(), error = (fallbackData as? Int)?.let { painterResource(id = it) }, imageLoader = coilImageLoader