Extending AsyncImage to correctly use pre-loaded aspect ratio and avoid screen jittering

This commit is contained in:
Vitor Pamplona
2025-05-06 16:46:25 -04:00
parent ba6a7bb07f
commit 54532de88a
8 changed files with 197 additions and 38 deletions
@@ -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)
}
}
}
}
}
}
@@ -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)
}
}
@@ -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(
@@ -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) }
@@ -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 {
@@ -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)
@@ -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)
@@ -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 {