From 103845225be476450243e3d909c3181712879b37 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 21 Oct 2025 15:19:35 +0100 Subject: [PATCH 1/5] two images case: test for aspect ratio of first image and adapt gallery based on that --- .../amethyst/ui/components/ImageGallery.kt | 60 +++++++++++++++---- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt index ba3e63640..aedccaae0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.unit.Dp import com.vitorpamplona.amethyst.commons.richtext.ImageGalleryParagraph import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState +import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size5dp @@ -43,6 +44,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList private const val ASPECT_RATIO = 4f / 3f +private const val PORTRAIT_ASPECT_RATIO = 3f / 4f private val IMAGE_SPACING: Dp = Size5dp @Composable @@ -115,19 +117,51 @@ private fun TwoImageGallery( accountViewModel: AccountViewModel, roundedCorner: Boolean, ) { - Row( - modifier = Modifier.aspectRatio(ASPECT_RATIO), - horizontalArrangement = Arrangement.spacedBy(IMAGE_SPACING), - ) { - images.take(2).forEach { image -> - GalleryImage( - image = image, - allImages = images, - modifier = Modifier.weight(1f).fillMaxSize(), - roundedCorner = roundedCorner, - contentScale = ContentScale.Crop, - accountViewModel = accountViewModel, - ) + val firstImage = images.firstOrNull() + val firstImageAspectRatio = + firstImage + ?.dim + ?.takeIf { it.hasSize() } + ?.aspectRatio() + ?: firstImage?.url?.let { MediaAspectRatioCache.get(it) } + + val isLandscape = firstImageAspectRatio?.let { it >= 1f } ?: false + val itemAspectRatio = + firstImageAspectRatio + ?.takeIf { it > 0f } + ?: if (isLandscape) ASPECT_RATIO else PORTRAIT_ASPECT_RATIO + + if (isLandscape) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(IMAGE_SPACING), + ) { + images.take(2).forEach { image -> + GalleryImage( + image = image, + allImages = images, + modifier = Modifier.fillMaxWidth().aspectRatio(itemAspectRatio), + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(IMAGE_SPACING), + ) { + images.take(2).forEach { image -> + GalleryImage( + image = image, + allImages = images, + modifier = Modifier.weight(1f, fill = true).aspectRatio(itemAspectRatio), + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } } } } From 50cbbb55b6a5a071f45ebaae1725456a1db97eb1 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 21 Oct 2025 15:34:19 +0100 Subject: [PATCH 2/5] three images case: test for aspect ratio of first image and adapt gallery based on that --- .../amethyst/ui/components/ImageGallery.kt | 90 +++++++++++++++---- 1 file changed, 74 insertions(+), 16 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt index aedccaae0..332a1f619 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt @@ -22,17 +22,22 @@ package com.vitorpamplona.amethyst.ui.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.richtext.ImageGalleryParagraph import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState @@ -172,32 +177,85 @@ private fun ThreeImageGallery( accountViewModel: AccountViewModel, roundedCorner: Boolean, ) { - Row( - modifier = Modifier.aspectRatio(ASPECT_RATIO), - horizontalArrangement = Arrangement.spacedBy(IMAGE_SPACING), - ) { - GalleryImage( - image = images[0], - allImages = images, - modifier = Modifier.weight(2f).fillMaxSize(), - roundedCorner = roundedCorner, - contentScale = ContentScale.Crop, - accountViewModel = accountViewModel, - ) + val firstImage = images.first() + val firstImageAspectRatio = + firstImage + .dim + ?.takeIf { it.hasSize() } + ?.aspectRatio() + ?: MediaAspectRatioCache.get(firstImage.url) + val remainingImages = images.drop(1) + val isLandscape = firstImageAspectRatio?.let { it >= 1f } ?: false + val firstItemAspectRatio = + firstImageAspectRatio + ?.takeIf { it > 0f } + ?: if (isLandscape) ASPECT_RATIO else PORTRAIT_ASPECT_RATIO + + if (isLandscape) { Column( - modifier = Modifier.weight(1f).fillMaxSize(), + modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(IMAGE_SPACING), ) { - images.drop(1).forEach { image -> + GalleryImage( + image = firstImage, + allImages = images, + modifier = Modifier.fillMaxWidth().aspectRatio(firstItemAspectRatio), + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(IMAGE_SPACING), + ) { + remainingImages.forEach { image -> + GalleryImage( + image = image, + allImages = images, + modifier = Modifier.weight(1f).aspectRatio(1f), + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } + } + } + } else { + BoxWithConstraints { + val horizontalSpacing = IMAGE_SPACING + val columnWidth = ((maxWidth - horizontalSpacing).coerceAtLeast(0.dp)) / 2 + val rowHeight = (columnWidth * 2) + IMAGE_SPACING + + Row( + modifier = Modifier.fillMaxWidth().height(rowHeight), + horizontalArrangement = Arrangement.spacedBy(horizontalSpacing), + ) { GalleryImage( - image = image, + image = firstImage, allImages = images, - modifier = Modifier.weight(1f).fillMaxSize(), + modifier = Modifier.width(columnWidth).fillMaxHeight(), roundedCorner = roundedCorner, contentScale = ContentScale.Crop, accountViewModel = accountViewModel, ) + + Column( + modifier = Modifier.width(columnWidth).fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(IMAGE_SPACING), + ) { + remainingImages.forEach { image -> + GalleryImage( + image = image, + allImages = images, + modifier = Modifier.fillMaxWidth().aspectRatio(1f), + roundedCorner = roundedCorner, + contentScale = ContentScale.Crop, + accountViewModel = accountViewModel, + ) + } + } } } } From c735c358bbf6ab200c112d4ba9bfba98cb191562 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 21 Oct 2025 15:47:20 +0100 Subject: [PATCH 3/5] extracted common logic --- .../amethyst/ui/components/ImageGallery.kt | 66 ++++++++++--------- 1 file changed, 36 insertions(+), 30 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt index 332a1f619..99df5600e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt @@ -52,6 +52,35 @@ private const val ASPECT_RATIO = 4f / 3f private const val PORTRAIT_ASPECT_RATIO = 3f / 4f private val IMAGE_SPACING: Dp = Size5dp +private data class FirstImageOrientation( + val aspectRatio: Float, + val isLandscape: Boolean, +) + +private fun MediaUrlImage.resolvedAspectRatio(): Float? = + dim + ?.takeIf { it.hasSize() } + ?.aspectRatio() + ?: MediaAspectRatioCache.get(url) + +private fun MediaUrlImage?.resolveOrientation( + landscapeDefaultAspectRatio: Float, + portraitDefaultAspectRatio: Float, + defaultToLandscape: Boolean = false, +): FirstImageOrientation { + val ratio = this?.resolvedAspectRatio() + val isLandscape = ratio?.let { it >= 1f } ?: defaultToLandscape + val resolvedAspectRatio = + ratio + ?.takeIf { it > 0f } + ?: if (isLandscape) landscapeDefaultAspectRatio else portraitDefaultAspectRatio + + return FirstImageOrientation( + aspectRatio = resolvedAspectRatio, + isLandscape = isLandscape, + ) +} + @Composable private fun GalleryImage( image: MediaUrlImage, @@ -122,21 +151,9 @@ private fun TwoImageGallery( accountViewModel: AccountViewModel, roundedCorner: Boolean, ) { - val firstImage = images.firstOrNull() - val firstImageAspectRatio = - firstImage - ?.dim - ?.takeIf { it.hasSize() } - ?.aspectRatio() - ?: firstImage?.url?.let { MediaAspectRatioCache.get(it) } + val orientation = images.firstOrNull().resolveOrientation(ASPECT_RATIO, PORTRAIT_ASPECT_RATIO) - val isLandscape = firstImageAspectRatio?.let { it >= 1f } ?: false - val itemAspectRatio = - firstImageAspectRatio - ?.takeIf { it > 0f } - ?: if (isLandscape) ASPECT_RATIO else PORTRAIT_ASPECT_RATIO - - if (isLandscape) { + if (orientation.isLandscape) { Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(IMAGE_SPACING), @@ -145,7 +162,7 @@ private fun TwoImageGallery( GalleryImage( image = image, allImages = images, - modifier = Modifier.fillMaxWidth().aspectRatio(itemAspectRatio), + modifier = Modifier.fillMaxWidth().aspectRatio(orientation.aspectRatio), roundedCorner = roundedCorner, contentScale = ContentScale.Crop, accountViewModel = accountViewModel, @@ -161,7 +178,7 @@ private fun TwoImageGallery( GalleryImage( image = image, allImages = images, - modifier = Modifier.weight(1f, fill = true).aspectRatio(itemAspectRatio), + modifier = Modifier.weight(1f, fill = true).aspectRatio(orientation.aspectRatio), roundedCorner = roundedCorner, contentScale = ContentScale.Crop, accountViewModel = accountViewModel, @@ -178,21 +195,10 @@ private fun ThreeImageGallery( roundedCorner: Boolean, ) { val firstImage = images.first() - val firstImageAspectRatio = - firstImage - .dim - ?.takeIf { it.hasSize() } - ?.aspectRatio() - ?: MediaAspectRatioCache.get(firstImage.url) - + val orientation = firstImage.resolveOrientation(ASPECT_RATIO, PORTRAIT_ASPECT_RATIO) val remainingImages = images.drop(1) - val isLandscape = firstImageAspectRatio?.let { it >= 1f } ?: false - val firstItemAspectRatio = - firstImageAspectRatio - ?.takeIf { it > 0f } - ?: if (isLandscape) ASPECT_RATIO else PORTRAIT_ASPECT_RATIO - if (isLandscape) { + if (orientation.isLandscape) { Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(IMAGE_SPACING), @@ -200,7 +206,7 @@ private fun ThreeImageGallery( GalleryImage( image = firstImage, allImages = images, - modifier = Modifier.fillMaxWidth().aspectRatio(firstItemAspectRatio), + modifier = Modifier.fillMaxWidth().aspectRatio(orientation.aspectRatio), roundedCorner = roundedCorner, contentScale = ContentScale.Crop, accountViewModel = accountViewModel, From a055f473cbab604b65c81ce58e81ae2c0dc09bb7 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 21 Oct 2025 15:56:18 +0100 Subject: [PATCH 4/5] =?UTF-8?q?prevent=20race=20condition:=20Added=20a=20s?= =?UTF-8?q?tate-aware=20rememberFirstImageOrientation=20helper=20so=20each?= =?UTF-8?q?=20gallery=20watches=20the=20cache=20until=20the=20first=20imag?= =?UTF-8?q?e=E2=80=99s=20real=20aspect=20ratio=20is=20known?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../amethyst/ui/components/ImageGallery.kt | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt index 99df5600e..0aad95ea8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt @@ -34,6 +34,11 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.Dp @@ -47,6 +52,8 @@ import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size5dp import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive private const val ASPECT_RATIO = 4f / 3f private const val PORTRAIT_ASPECT_RATIO = 3f / 4f @@ -81,6 +88,32 @@ private fun MediaUrlImage?.resolveOrientation( ) } +@Composable +private fun rememberFirstImageOrientation(firstImage: MediaUrlImage?): FirstImageOrientation { + val initialOrientation = + remember(firstImage) { + firstImage.resolveOrientation(ASPECT_RATIO, PORTRAIT_ASPECT_RATIO) + } + + var orientation by remember(firstImage) { mutableStateOf(initialOrientation) } + + LaunchedEffect(firstImage) { + if (firstImage == null) return@LaunchedEffect + if (firstImage.dim?.hasSize() == true) return@LaunchedEffect + + while (isActive) { + val ratio = firstImage.resolvedAspectRatio() + if (ratio != null && ratio > 0f) { + orientation = FirstImageOrientation(ratio, ratio >= 1f) + break + } + delay(200) + } + } + + return orientation +} + @Composable private fun GalleryImage( image: MediaUrlImage, @@ -151,7 +184,7 @@ private fun TwoImageGallery( accountViewModel: AccountViewModel, roundedCorner: Boolean, ) { - val orientation = images.firstOrNull().resolveOrientation(ASPECT_RATIO, PORTRAIT_ASPECT_RATIO) + val orientation = rememberFirstImageOrientation(images.firstOrNull()) if (orientation.isLandscape) { Column( @@ -195,7 +228,7 @@ private fun ThreeImageGallery( roundedCorner: Boolean, ) { val firstImage = images.first() - val orientation = firstImage.resolveOrientation(ASPECT_RATIO, PORTRAIT_ASPECT_RATIO) + val orientation = rememberFirstImageOrientation(firstImage) val remainingImages = images.drop(1) if (orientation.isLandscape) { From f7ed4d2aaaea6da62d4367969bdf8b99d95eaa76 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 22 Oct 2025 09:26:14 +0100 Subject: [PATCH 5/5] =?UTF-8?q?NEEDS=20testing:=20Swapped=20the=20local=20?= =?UTF-8?q?var=20orientation=20by=20remember=20{=20mutableStateOf(...)=20}?= =?UTF-8?q?=20for=20an=20explicit=20MutableState:=20we=20now=20create=20or?= =?UTF-8?q?ientationState=20=3D=20remember(firstImage)=20{=20mutableStateO?= =?UTF-8?q?f(...)=20},=20update=20it=20with=20=20=20=20=20orientationState?= =?UTF-8?q?.value=20=3D=20=E2=80=A6,=20and=20return=20orientationState.val?= =?UTF-8?q?ue.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../amethyst/ui/components/ImageGallery.kt | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt index 0aad95ea8..9c1599ce0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt @@ -35,10 +35,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.Dp @@ -90,13 +88,11 @@ private fun MediaUrlImage?.resolveOrientation( @Composable private fun rememberFirstImageOrientation(firstImage: MediaUrlImage?): FirstImageOrientation { - val initialOrientation = + val orientationState = remember(firstImage) { - firstImage.resolveOrientation(ASPECT_RATIO, PORTRAIT_ASPECT_RATIO) + mutableStateOf(firstImage.resolveOrientation(ASPECT_RATIO, PORTRAIT_ASPECT_RATIO)) } - var orientation by remember(firstImage) { mutableStateOf(initialOrientation) } - LaunchedEffect(firstImage) { if (firstImage == null) return@LaunchedEffect if (firstImage.dim?.hasSize() == true) return@LaunchedEffect @@ -104,14 +100,14 @@ private fun rememberFirstImageOrientation(firstImage: MediaUrlImage?): FirstImag while (isActive) { val ratio = firstImage.resolvedAspectRatio() if (ratio != null && ratio > 0f) { - orientation = FirstImageOrientation(ratio, ratio >= 1f) + orientationState.value = FirstImageOrientation(ratio, ratio >= 1f) break } delay(200) } } - return orientation + return orientationState.value } @Composable