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 f63cfceb63..129f4f8585 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 @@ -59,12 +62,20 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue 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.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 @@ -102,11 +113,35 @@ 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) } + + LaunchedEffect(Unit) { + 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 } + Dialog( - onDismissRequest = onDismiss, + onDismissRequest = dismissWithAnimation, properties = DialogProperties( usePlatformDefaultWidth = true, @@ -123,11 +158,52 @@ 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) + var fullBounds by remember { mutableStateOf(null) } + + Box( + modifier = + Modifier + .fillMaxSize() + .onGloballyPositioned { fullBounds = it.boundsInWindow() }, + ) { + // Background surface that fades in as the content grows to fullscreen. + Surface( + modifier = + Modifier + .fillMaxSize() + .graphicsLayer { alpha = progress.value }, + ) {} + + // Content layer that grows from the source bounds to fullscreen (and back on exit). + Box( + modifier = + Modifier + .fillMaxSize() + .graphicsLayer { + val src = sourceBounds + val full = fullBounds + if (src != null && full != null && full.width > 0f && full.height > 0f) { + transformOrigin = TransformOrigin(0f, 0f) + val startScaleX = src.width / full.width + val startScaleY = src.height / full.height + scaleX = lerp(startScaleX, 1f, progress.value) + scaleY = lerp(startScaleY, 1f, progress.value) + translationX = lerp(src.left - full.left, 0f, progress.value) + translationY = lerp(src.top - full.top, 0f, progress.value) + } else { + // No source bounds provided: fall back to a simple fade. + alpha = progress.value + } + }, + ) { + DialogContent(allImages, imageUrl, dismissWithAnimation, accountViewModel) + } } } } 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 1bd2aec21b..08f3ccc1c5 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 ad3c2f1077..acf1df765c 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 1f2a822ef0..c14e49490c 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 7cc5855387..a2b940d7a4 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, )