Merge pull request #2399 from vitorpamplona/claude/improve-zoom-animation-5csfK
Add shared element transition animation to image/video dialogs
This commit is contained in:
+160
-27
@@ -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<BaseMediaContent> = 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<Rect?>(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<BaseMediaContent>,
|
||||
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<Boolean>,
|
||||
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 =
|
||||
|
||||
+22
-5
@@ -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<Rect?>(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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Rect?>(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<Rect?>(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,
|
||||
)
|
||||
|
||||
+6
@@ -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<Rect?>(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,
|
||||
)
|
||||
|
||||
+9
-2
@@ -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<String?>(null) }
|
||||
var sourceBounds by remember { mutableStateOf<Rect?>(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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user