From 54532de88a72284632b8f182f34204fd5fbb992a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 6 May 2025 16:46:25 -0400 Subject: [PATCH] Extending AsyncImage to correctly use pre-loaded aspect ratio and avoid screen jittering --- .../amethyst/ui/components/MyAsyncImage.kt | 118 ++++++++++++++++++ .../amethyst/ui/note/UsernameDisplay.kt | 2 +- .../ui/note/elements/DefaultImageHeader.kt | 22 +++- .../amethyst/ui/note/types/LiveActivity.kt | 17 ++- .../amethyst/ui/note/types/LongForm.kt | 33 ++--- .../amethyst/ui/note/types/Video.kt | 11 +- .../amethyst/ui/note/types/Wiki.kt | 12 +- .../loggedIn/threadview/ThreadFeedView.kt | 20 ++- 8 files changed, 197 insertions(+), 38 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt new file mode 100644 index 000000000..dde185f1d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt @@ -0,0 +1,118 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.size +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import coil3.compose.AsyncImagePainter +import coil3.compose.SubcomposeAsyncImage +import coil3.compose.SubcomposeAsyncImageContent +import com.vitorpamplona.amethyst.model.MediaAspectRatioCache +import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size40dp +import com.vitorpamplona.amethyst.ui.theme.Size6dp +import com.vitorpamplona.amethyst.ui.theme.Size75dp + +@Composable +fun MyAsyncImage( + imageUrl: String, + contentDescription: String?, + contentScale: ContentScale, + mainImageModifier: Modifier, + loadedImageModifier: Modifier, + accountViewModel: AccountViewModel, + onError: @Composable () -> Unit, +) { + val ratio = MediaAspectRatioCache.get(imageUrl) + val showImage = remember { mutableStateOf(accountViewModel.settings.showImages.value) } + + CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) { + if (it) { + SubcomposeAsyncImage( + model = imageUrl, + contentDescription = contentDescription, + contentScale = contentScale, + modifier = mainImageModifier, + ) { + val state by painter.state.collectAsState() + when (state) { + is AsyncImagePainter.State.Loading -> { + if (ratio != null) { + Box(loadedImageModifier.aspectRatio(ratio), contentAlignment = Alignment.Center) { + LoadingAnimation(Size40dp, Size6dp) + } + } else { + WaitAndDisplay { + DisplayUrlWithLoadingSymbol(imageUrl) + } + } + } + is AsyncImagePainter.State.Error -> { + onError() + } + is AsyncImagePainter.State.Success -> { + SubcomposeAsyncImageContent(loadedImageModifier) + + SideEffect { + val drawable = (state as AsyncImagePainter.State.Success).result.image + MediaAspectRatioCache.add(imageUrl, drawable.width, drawable.height) + } + } + else -> {} + } + } + } else { + if (ratio != null) { + Box(loadedImageModifier.aspectRatio(ratio), contentAlignment = Alignment.Center) { + IconButton( + modifier = Modifier.size(Size75dp), + onClick = { showImage.value = true }, + ) { + DownloadForOfflineIcon(Size75dp, MaterialTheme.colorScheme.onBackground) + } + } + } else { + Box(loadedImageModifier.aspectRatio(16 / 9.0f), contentAlignment = Alignment.Center) { + IconButton( + modifier = Modifier.size(Size75dp), + onClick = { showImage.value = true }, + ) { + DownloadForOfflineIcon(Size75dp, MaterialTheme.colorScheme.onBackground) + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index 1f25fcfa0..2481b60f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -70,7 +70,7 @@ fun WatchAuthor( inner(noteAuthor) } else { val authorState by observeNote(baseNote, accountViewModel) - authorState?.note?.author?.let { + authorState.note.author?.let { inner(it) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt index fa49b0bd0..75dd08b12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt @@ -30,11 +30,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserBanner +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.WatchAuthor import com.vitorpamplona.amethyst.ui.painterRes @@ -67,25 +67,35 @@ fun BannerImage( ) { val banner by observeUserBanner(author, accountViewModel) - BannerImage(banner, modifier) + BannerImage(banner, modifier, accountViewModel) } @Composable fun BannerImage( banner: String?, modifier: Modifier = Modifier, + accountViewModel: AccountViewModel, ) { if (!banner.isNullOrBlank()) { - AsyncImage( - model = banner, + MyAsyncImage( + imageUrl = banner, contentDescription = stringRes( R.string.preview_card_image_for, banner, ), contentScale = ContentScale.Crop, - modifier = modifier, - placeholder = painterRes(R.drawable.profile_banner), + mainImageModifier = Modifier, + loadedImageModifier = modifier, + accountViewModel = accountViewModel, + onError = { + Image( + painter = painterRes(R.drawable.profile_banner), + contentDescription = stringRes(R.string.profile_banner), + contentScale = ContentScale.Crop, + modifier = modifier, + ) + }, ) } else { Image( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt index 14789fbfb..d51f7aea3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt @@ -44,7 +44,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note @@ -52,12 +51,14 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.playback.composable.VideoView import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.navigation.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.ScheduledFlag @@ -220,7 +221,19 @@ fun RenderLiveActivityEventInner( Row( verticalAlignment = Alignment.CenterVertically, ) { - AsyncImage(model = it, contentDescription = null, modifier = MaterialTheme.colorScheme.imageModifier) + MyAsyncImage( + imageUrl = it, + contentDescription = + stringRes( + R.string.preview_card_image_for, + it, + ), + contentScale = ContentScale.FillWidth, + mainImageModifier = Modifier.fillMaxWidth(), + loadedImageModifier = MaterialTheme.colorScheme.imageModifier, + accountViewModel = accountViewModel, + onError = { DefaultImageHeader(baseNote, accountViewModel) }, + ) } } ?: run { DisplayAuthorBanner(baseNote, accountViewModel) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt index 479b8ee5b..a748113e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LongForm.kt @@ -35,9 +35,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -83,21 +83,22 @@ fun LongFormHeader( QuoteBorder, ), ) { - if (accountViewModel.settings.showImages.value) { - image?.let { - AsyncImage( - model = it, - contentDescription = - stringRes( - R.string.preview_card_image_for, - it, - ), - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), - ) - } ?: run { - DefaultImageHeader(note, accountViewModel) - } + image?.let { + MyAsyncImage( + imageUrl = it, + contentDescription = + stringRes( + R.string.preview_card_image_for, + it, + ), + contentScale = ContentScale.FillWidth, + mainImageModifier = Modifier.fillMaxWidth(), + loadedImageModifier = Modifier, + accountViewModel = accountViewModel, + onError = { DefaultImageHeader(note, accountViewModel) }, + ) + } ?: run { + DefaultImageHeader(note, accountViewModel) } title?.let { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt index 882053d59..3224f8476 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt @@ -41,13 +41,13 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.ZoomableContentView @@ -131,15 +131,18 @@ fun VideoDisplay( modifier = Modifier.clickable { runCatching { uri.openUri(imeta.url) } }, ) { image?.let { - AsyncImage( - model = it, + MyAsyncImage( + imageUrl = it, contentDescription = stringRes( R.string.preview_card_image_for, it, ), contentScale = ContentScale.FillWidth, - modifier = MaterialTheme.colorScheme.imageModifier, + mainImageModifier = Modifier, + loadedImageModifier = MaterialTheme.colorScheme.imageModifier, + accountViewModel = accountViewModel, + onError = { DefaultImageHeader(note, accountViewModel) }, ) } ?: run { DefaultImageHeader(note, accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Wiki.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Wiki.kt index 9bde2174e..f9b4c5b45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Wiki.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Wiki.kt @@ -36,9 +36,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -46,6 +46,7 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent @@ -91,15 +92,18 @@ private fun WikiNoteHeader( if (automaticallyShowUrlPreview) { image?.let { - AsyncImage( - model = it, + MyAsyncImage( + imageUrl = it, contentDescription = stringRes( R.string.preview_card_image_for, it, ), contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), + mainImageModifier = Modifier, + loadedImageModifier = MaterialTheme.colorScheme.imageModifier, + accountViewModel = accountViewModel, + onError = { DefaultImageHeader(note, accountViewModel) }, ) } ?: run { DefaultImageHeader(note, accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 73eb1f32c..1e0c62a12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -78,6 +78,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status import com.vitorpamplona.amethyst.ui.components.ZoomableContentView import com.vitorpamplona.amethyst.ui.feeds.FeedState @@ -504,7 +505,7 @@ private fun FullBleedNoteCompose( when (noteEvent) { is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote, accountViewModel) - is LongTextNoteEvent -> RenderLongFormHeaderForThread(noteEvent) + is LongTextNoteEvent -> RenderLongFormHeaderForThread(noteEvent, baseNote, accountViewModel) is WikiNoteEvent -> RenderWikiHeaderForThread(noteEvent, accountViewModel, nav) is ClassifiedsEvent -> RenderClassifiedsReaderForThread(noteEvent, baseNote, accountViewModel, nav) } @@ -925,19 +926,28 @@ private fun RenderClassifiedsReaderForThread( } @Composable -private fun RenderLongFormHeaderForThread(noteEvent: LongTextNoteEvent) { +private fun RenderLongFormHeaderForThread( + noteEvent: LongTextNoteEvent, + note: Note, + accountViewModel: AccountViewModel, +) { Column(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) { noteEvent.image()?.let { - AsyncImage( - model = it, + MyAsyncImage( + imageUrl = it, contentDescription = stringRes( R.string.preview_card_image_for, it, ), contentScale = ContentScale.FillWidth, - modifier = MaterialTheme.colorScheme.imageModifier, + mainImageModifier = Modifier, + loadedImageModifier = MaterialTheme.colorScheme.imageModifier, + accountViewModel = accountViewModel, + onError = { DefaultImageHeader(note, accountViewModel) }, ) + } ?: run { + DefaultImageHeader(note, accountViewModel) } noteEvent.title()?.let {