diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index f63cfceb6..1f3cd7faf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -28,6 +28,9 @@ import android.os.Looper import android.view.WindowManager import android.widget.Toast import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.clickable @@ -57,14 +60,23 @@ import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.util.lerp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.core.net.toUri @@ -94,6 +106,8 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.zoomable @@ -102,11 +116,62 @@ import net.engawapg.lib.zoomable.zoomable fun ZoomableImageDialog( imageUrl: BaseMediaContent, allImages: ImmutableList = listOf(imageUrl).toImmutableList(), + sourceBounds: Rect? = null, onDismiss: () -> Unit, accountViewModel: AccountViewModel, ) { + // Animation progress: 0f = at source position/size, 1f = fullscreen. + val progress = remember { Animatable(0f) } + var isExiting by remember { mutableStateOf(false) } + + // Natural layout bounds of the currently-visible image/video inside the dialog. + // Used as the "target" of the grow animation so the image itself — not the dialog + // viewport — aligns with the tapped thumbnail at progress = 0. + var imageBounds by remember { mutableStateOf(null) } + + // Start the enter animation as soon as valid image bounds are available. Without + // this gate, the animation can begin before onGloballyPositioned has reported real + // bounds and the graphicsLayer falls back to its alpha-only branch. + LaunchedEffect(Unit) { + snapshotFlow { imageBounds } + .filter { it != null && it.width > 0f && it.height > 0f } + .first() + progress.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing), + ) + } + + LaunchedEffect(isExiting) { + if (isExiting) { + progress.animateTo( + targetValue = 0f, + animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing), + ) + onDismiss() + } + } + + val dismissWithAnimation: () -> Unit = { if (!isExiting) isExiting = true } + val progressProvider: () -> Float = { progress.value } + + // Accept the first set of valid bounds unconditionally. Subsequent updates are + // only accepted while the progress Animatable is idle, so a layout change + // mid-transition (e.g. an async image finishing loading, or a pager settling) + // can't re-target the transform and cause a hiccup. + val updateImageBounds: (Rect) -> Unit = { newBounds -> + if (newBounds.width > 0f && newBounds.height > 0f) { + val current = imageBounds + if (current == null) { + imageBounds = newBounds + } else if (!progress.isRunning && current != newBounds) { + imageBounds = newBounds + } + } + } + Dialog( - onDismissRequest = onDismiss, + onDismissRequest = dismissWithAnimation, properties = DialogProperties( usePlatformDefaultWidth = true, @@ -123,11 +188,31 @@ fun ZoomableImageDialog( val attributes = WindowManager.LayoutParams() attributes.copyFrom(activityWindow.attributes) attributes.type = dialogWindow.attributes.type + // Disable the system dim so the thumbnail stays visible behind the growing dialog. + attributes.dimAmount = 0f + attributes.flags = attributes.flags and WindowManager.LayoutParams.FLAG_DIM_BEHIND.inv() dialogWindow.attributes = attributes } - Surface(Modifier.fillMaxSize()) { - DialogContent(allImages, imageUrl, onDismiss, accountViewModel) + Box(modifier = Modifier.fillMaxSize()) { + // Background surface that fades in as the content grows to fullscreen. + Surface( + modifier = + Modifier + .fillMaxSize() + .graphicsLayer { alpha = progressProvider() }, + ) {} + + DialogContent( + allImages = allImages, + imageUrl = imageUrl, + sourceBounds = sourceBounds, + imageBounds = imageBounds, + onImageBoundsChanged = updateImageBounds, + progress = progressProvider, + onDismiss = dismissWithAnimation, + accountViewModel = accountViewModel, + ) } } } @@ -137,6 +222,10 @@ fun ZoomableImageDialog( private fun DialogContent( allImages: ImmutableList, imageUrl: BaseMediaContent, + sourceBounds: Rect?, + imageBounds: Rect?, + onImageBoundsChanged: (Rect) -> Unit, + progress: () -> Float, onDismiss: () -> Unit, accountViewModel: AccountViewModel, ) { @@ -179,31 +268,63 @@ private fun DialogContent( ), Alignment.TopCenter, ) { - if (allImages.size > 1) { - SlidingCarousel( - pagerState = pagerState, - ) { index -> - allImages.getOrNull(index)?.let { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - RenderImageOrVideo( - content = it, - roundedCorner = false, - isFiniteHeight = true, - controllerVisible = controllerVisible, - accountViewModel = accountViewModel, - ) + // Transformed image/video container. Only this layer scales & translates so the + // image aligns with the tapped thumbnail on enter/exit. Controls stay put. + Box( + modifier = + Modifier + .fillMaxSize() + .graphicsLayer { + val src = sourceBounds + val img = imageBounds + if (src != null && img != null && img.width > 0f && img.height > 0f) { + transformOrigin = TransformOrigin(0f, 0f) + val startScaleX = src.width / img.width + val startScaleY = src.height / img.height + val p = progress() + scaleX = lerp(startScaleX, 1f, p) + scaleY = lerp(startScaleY, 1f, p) + translationX = lerp(src.left - img.left * startScaleX, 0f, p) + translationY = lerp(src.top - img.top * startScaleY, 0f, p) + } else { + // No source bounds: fall back to a plain fade. + alpha = progress() + } + }, + ) { + if (allImages.size > 1) { + SlidingCarousel( + pagerState = pagerState, + ) { index -> + allImages.getOrNull(index)?.let { pageContent -> + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + RenderImageOrVideo( + content = pageContent, + roundedCorner = false, + isFiniteHeight = true, + controllerVisible = controllerVisible, + accountViewModel = accountViewModel, + onContentBoundsChanged = + if (index == pagerState.currentPage) { + onImageBoundsChanged + } else { + null + }, + ) + } } } - } - } else { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - RenderImageOrVideo( - content = imageUrl, - roundedCorner = false, - isFiniteHeight = true, - controllerVisible = controllerVisible, - accountViewModel = accountViewModel, - ) + } else { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + RenderImageOrVideo( + content = imageUrl, + roundedCorner = false, + isFiniteHeight = true, + controllerVisible = controllerVisible, + accountViewModel = accountViewModel, + onContentBoundsChanged = onImageBoundsChanged, + ) + } } } @@ -211,6 +332,8 @@ private fun DialogContent( visible = controllerVisible.value, enter = remember { fadeIn() }, exit = remember { fadeOut() }, + // Also fade with the grow animation so controls appear/disappear alongside it. + modifier = Modifier.graphicsLayer { alpha = progress().coerceIn(0f, 1f) }, ) { Row( modifier = @@ -373,6 +496,7 @@ private fun RenderImageOrVideo( isFiniteHeight: Boolean, controllerVisible: MutableState, accountViewModel: AccountViewModel, + onContentBoundsChanged: ((Rect) -> Unit)? = null, ) { val contentScale = if (isFiniteHeight) { @@ -381,7 +505,16 @@ private fun RenderImageOrVideo( ContentScale.FillWidth } - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { + val rowModifier = + if (onContentBoundsChanged != null) { + Modifier + .fillMaxWidth() + .onGloballyPositioned { onContentBoundsChanged(it.boundsInWindow()) } + } else { + Modifier.fillMaxWidth() + } + + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = rowModifier) { when (content) { is MediaUrlImage -> { val mainModifier = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 1bd2aec21..08f3ccc1c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -58,8 +58,11 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler @@ -139,6 +142,11 @@ fun ZoomableContentView( accountViewModel: AccountViewModel, ) { var dialogOpen by remember(content) { mutableStateOf(false) } + var sourceBounds by remember(content) { mutableStateOf(null) } + val boundsTrackingModifier = + Modifier.onGloballyPositioned { coordinates -> + sourceBounds = coordinates.boundsInWindow() + } when (content) { is MediaUrlImage -> { @@ -147,6 +155,7 @@ fun ZoomableContentView( val mainImageModifier = Modifier .fillMaxWidth() + .then(boundsTrackingModifier) .clickable { dialogOpen = true } val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth() UrlImageView(content, contentScale, mainImageModifier, loadedImageModifier, controllerVisible, accountViewModel = accountViewModel) @@ -156,7 +165,10 @@ fun ZoomableContentView( is MediaUrlVideo -> { SensitivityWarning(content.contentWarning, accountViewModel) { - Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Box( + modifier = Modifier.fillMaxWidth().then(boundsTrackingModifier), + contentAlignment = Alignment.Center, + ) { VideoView( videoUri = content.url, mimeType = content.mimeType, @@ -180,6 +192,7 @@ fun ZoomableContentView( val mainImageModifier = Modifier .fillMaxWidth() + .then(boundsTrackingModifier) .clickable { dialogOpen = true } val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth() @@ -189,7 +202,10 @@ fun ZoomableContentView( is MediaLocalVideo -> { content.localFile?.let { - Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Box( + modifier = Modifier.fillMaxWidth().then(boundsTrackingModifier), + contentAlignment = Alignment.Center, + ) { VideoView( videoUri = it.toUri().toString(), mimeType = content.mimeType, @@ -209,12 +225,13 @@ fun ZoomableContentView( if (dialogOpen) { ZoomableImageDialog( - content, - images, + imageUrl = content, + allImages = images, + sourceBounds = sourceBounds, onDismiss = { dialogOpen = false }, - accountViewModel, + accountViewModel = accountViewModel, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt index ad3c2f107..acf1df765 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt @@ -47,7 +47,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight @@ -101,6 +104,7 @@ fun RenderAppDefinition( if (!theAppMetadata.banner.isNullOrBlank()) { var zoomImageDialogOpen by remember { mutableStateOf(false) } + var bannerSourceBounds by remember { mutableStateOf(null) } AsyncImage( model = theAppMetadata.banner, @@ -110,6 +114,7 @@ fun RenderAppDefinition( Modifier .fillMaxWidth() .height(125.dp) + .onGloballyPositioned { bannerSourceBounds = it.boundsInWindow() } .combinedClickable( onClick = {}, onLongClick = { @@ -125,6 +130,7 @@ fun RenderAppDefinition( if (zoomImageDialogOpen) { ZoomableImageDialog( imageUrl = RichTextParser.parseImageOrVideo(theAppMetadata.banner!!), + sourceBounds = bannerSourceBounds, onDismiss = { zoomImageDialogOpen = false }, accountViewModel = accountViewModel, ) @@ -153,6 +159,7 @@ fun RenderAppDefinition( verticalAlignment = Alignment.Bottom, ) { var zoomImageDialogOpen by remember { mutableStateOf(false) } + var pictureSourceBounds by remember { mutableStateOf(null) } Box(Modifier.size(100.dp)) { theAppMetadata.picture?.let { picture -> AsyncImage( @@ -168,6 +175,7 @@ fun RenderAppDefinition( ).clip(shape = CircleShape) .fillMaxSize() .background(MaterialTheme.colorScheme.background) + .onGloballyPositioned { pictureSourceBounds = it.boundsInWindow() } .combinedClickable( onClick = { zoomImageDialogOpen = true }, onLongClick = { @@ -181,6 +189,7 @@ fun RenderAppDefinition( if (zoomImageDialogOpen) { ZoomableImageDialog( imageUrl = RichTextParser.parseImageOrVideo(theAppMetadata.picture!!), + sourceBounds = pictureSourceBounds, onDismiss = { zoomImageDialogOpen = false }, accountViewModel = accountViewModel, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt index 1f2a822ef..c14e49490 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawBanner.kt @@ -32,7 +32,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage @@ -68,6 +71,7 @@ fun DrawBanner( val clipboardManager = LocalClipboard.current val scope = rememberCoroutineScope() var zoomImageDialogOpen by remember { mutableStateOf(false) } + var sourceBounds by remember { mutableStateOf(null) } AsyncImage( model = banner, @@ -79,6 +83,7 @@ fun DrawBanner( Modifier .fillMaxWidth() .height(150.dp) + .onGloballyPositioned { sourceBounds = it.boundsInWindow() } .combinedClickable( onClick = { zoomImageDialogOpen = true }, onLongClick = { @@ -92,6 +97,7 @@ fun DrawBanner( if (zoomImageDialogOpen) { ZoomableImageDialog( imageUrl = RichTextParser.parseImageOrVideo(banner), + sourceBounds = sourceBounds, onDismiss = { zoomImageDialogOpen = false }, accountViewModel = accountViewModel, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt index 7cc585538..a2b940d7a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt @@ -51,6 +51,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext @@ -173,12 +176,15 @@ fun ZoomableUserPicture( val scope = rememberCoroutineScope() var zoomImageUrl by remember { mutableStateOf(null) } + var sourceBounds by remember { mutableStateOf(null) } ClickableUserPicture( baseUser = baseUser, accountViewModel = accountViewModel, size = size, - modifier = MaterialTheme.colorScheme.userProfileBorderModifier, + modifier = + MaterialTheme.colorScheme.userProfileBorderModifier + .onGloballyPositioned { sourceBounds = it.boundsInWindow() }, onClick = { val pic = baseUser.profilePicture() if (pic != null) { @@ -197,7 +203,8 @@ fun ZoomableUserPicture( zoomImageUrl?.let { ZoomableImageDialog( - RichTextParser.parseImageOrVideo(it), + imageUrl = RichTextParser.parseImageOrVideo(it), + sourceBounds = sourceBounds, onDismiss = { zoomImageUrl = null }, accountViewModel = accountViewModel, )