Extending AsyncImage to correctly use pre-loaded aspect ratio and avoid screen jittering
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+16
-6
@@ -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)
|
||||
|
||||
+15
-5
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user