refactor: Improve shared element transition for images

This commit refactors the shared element transitions for images to be more specific and robust, creating a smoother animation between the post list/detail and the full-screen image viewer.

**Key Changes:**

*   **Refined Shared Element Keys:**
    *   The shared element key for images is now based on both `postId` and `imageIndex` (e.g., `post_image_{postId}_{imageIndex}`). This replaces the previous, less specific key (`post_card_{postId}`), ensuring that the correct image animates, especially in multi-image posts.

*   **Improved Animation:**
    *   The animation `tween` duration has been standardized to 400ms with a `FastOutSlowInEasing` curve for a more polished and consistent feel across all image transitions.

*   **Navigation & State Handling:**
    *   The navigation route for `PostDetail` now accepts an `imageIndex` parameter, allowing direct navigation to a specific image within the image detail view.
    *   The `PostDetailContainer` now uses the passed `initialImageIndex` to correctly display the selected image when entering image mode.
    *   Shared element transitions are now conditionally applied only during the initial navigation into a mode (text or image) to prevent animation conflicts when switching between them.

*   **Component Updates:**
    *   `PostCardImages` and `PostDetailImages` now accept `sharedTransitionScope` and `animatedContentScope` to correctly apply the shared element modifier to each image.
    *   `ImageDetailScreen` has been updated to use the new `imageIndex`-based keying for its `ZoomableImage`.
This commit is contained in:
grtsinry43 2025-10-06 02:47:03 +08:00
parent 93f95bf9c3
commit e373670715
Signed by: grtsinry43
GPG Key ID: F3305FB3A978C934
4 changed files with 112 additions and 34 deletions

View File

@ -83,18 +83,25 @@ fun App() {
// 帖子详情页 - 统一容器管理两种模式 // 帖子详情页 - 统一容器管理两种模式
composable( composable(
route = Screen.PostDetail.route, route = Screen.PostDetail.route + "?imageIndex={imageIndex}",
arguments = listOf( arguments = listOf(
navArgument("postId") { type = NavType.IntType }, navArgument("postId") { type = NavType.IntType },
navArgument("mode") { type = NavType.StringType; defaultValue = "text" } navArgument("mode") { type = NavType.StringType; defaultValue = "text" },
navArgument("imageIndex") {
type = NavType.IntType
defaultValue = 0
nullable = false
}
) )
) { backStackEntry -> ) { backStackEntry ->
val postId = backStackEntry.arguments?.getInt("postId") ?: 0 val postId = backStackEntry.arguments?.getInt("postId") ?: 0
val initialMode = backStackEntry.arguments?.getString("mode") ?: "text" val initialMode = backStackEntry.arguments?.getString("mode") ?: "text"
val imageIndex = backStackEntry.arguments?.getInt("imageIndex") ?: 0
PostDetailContainer( PostDetailContainer(
postId = postId, postId = postId,
initialMode = initialMode, initialMode = initialMode,
initialImageIndex = imageIndex,
onBackClick = { navController.popBackStack() }, onBackClick = { navController.popBackStack() },
onAuthorClick = { authorId -> onAuthorClick = { authorId ->
// TODO: 导航到用户详情页 // TODO: 导航到用户详情页
@ -116,6 +123,7 @@ fun App() {
fun PostDetailContainer( fun PostDetailContainer(
postId: Int, postId: Int,
initialMode: String, initialMode: String,
initialImageIndex: Int = 0,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onAuthorClick: (Int) -> Unit, onAuthorClick: (Int) -> Unit,
sharedTransitionScope: SharedTransitionScope, sharedTransitionScope: SharedTransitionScope,
@ -123,35 +131,35 @@ fun PostDetailContainer(
) { ) {
// 本地状态管理模式切换 // 本地状态管理模式切换
var currentMode by remember { mutableStateOf(initialMode) } var currentMode by remember { mutableStateOf(initialMode) }
var imageIndex by remember { mutableIntStateOf(0) } var imageIndex by remember { mutableIntStateOf(initialImageIndex) }
// 为模式切换创建内部AnimatedContent // 为模式切换创建内部AnimatedContent
AnimatedContent( AnimatedContent(
targetState = currentMode, targetState = currentMode,
transitionSpec = { transitionSpec = {
// 使用滑动动画让切换更自然 // 使用更流畅的动画让切换更自然
when { when {
targetState == "image" && initialState == "text" -> { targetState == "image" && initialState == "text" -> {
slideInVertically( slideInVertically(
initialOffsetY = { it }, initialOffsetY = { it },
animationSpec = tween(400) animationSpec = tween(500, easing = FastOutSlowInEasing)
) togetherWith slideOutVertically( ) togetherWith slideOutVertically(
targetOffsetY = { -it }, targetOffsetY = { -it },
animationSpec = tween(400) animationSpec = tween(500, easing = FastOutSlowInEasing)
) )
} }
targetState == "text" && initialState == "image" -> { targetState == "text" && initialState == "image" -> {
slideInVertically( slideInVertically(
initialOffsetY = { -it }, initialOffsetY = { -it },
animationSpec = tween(400) animationSpec = tween(500, easing = FastOutSlowInEasing)
) togetherWith slideOutVertically( ) togetherWith slideOutVertically(
targetOffsetY = { it }, targetOffsetY = { it },
animationSpec = tween(400) animationSpec = tween(500, easing = FastOutSlowInEasing)
) )
} }
else -> { else -> {
fadeIn(animationSpec = tween(300)) togetherWith fadeIn(animationSpec = tween(400)) togetherWith
fadeOut(animationSpec = tween(300)) fadeOut(animationSpec = tween(400))
} }
} }
}, },
@ -167,8 +175,8 @@ fun PostDetailContainer(
onSwitchToTextMode = { onSwitchToTextMode = {
currentMode = "text" currentMode = "text"
}, },
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = if (mode == initialMode) sharedTransitionScope else null,
animatedContentScope = this@AnimatedContent animatedContentScope = if (mode == initialMode) animatedContentScope else null
) )
} }
else -> { else -> {
@ -180,14 +188,8 @@ fun PostDetailContainer(
imageIndex = selectedImageIndex imageIndex = selectedImageIndex
currentMode = "image" currentMode = "image"
}, },
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = if (mode == initialMode) sharedTransitionScope else null,
animatedContentScope = if (mode == initialMode) { animatedContentScope = if (mode == initialMode) animatedContentScope else null
// 如果是初始模式使用外部的animatedContentScope来保持与PostCard的共享动画
animatedContentScope
} else {
// 如果是切换后的模式使用内部的AnimatedContent scope
this@AnimatedContent
}
) )
} }
} }

View File

@ -8,6 +8,7 @@ import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@ -187,7 +188,9 @@ fun PostCardItem(
PostCardImages( PostCardImages(
imageUUIDs = postCard.imageUUIDs, imageUUIDs = postCard.imageUUIDs,
postId = postCard.postId, postId = postCard.postId,
onImageClick = onImageClick onImageClick = onImageClick,
sharedTransitionScope = sharedTransitionScope,
animatedContentScope = animatedContentScope
) )
} }
@ -298,11 +301,14 @@ private fun PostCardContent(postCard: PostCard) {
/** /**
* 图片展示 * 图片展示
*/ */
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
private fun PostCardImages( private fun PostCardImages(
imageUUIDs: List<String>, imageUUIDs: List<String>,
postId: Int, postId: Int,
onImageClick: (Int, Int) -> Unit onImageClick: (Int, Int) -> Unit,
sharedTransitionScope: SharedTransitionScope? = null,
animatedContentScope: AnimatedContentScope? = null
) { ) {
when (imageUUIDs.size) { when (imageUUIDs.size) {
1 -> { 1 -> {
@ -313,7 +319,20 @@ private fun PostCardImages(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(200.dp) .height(200.dp)
.clip(MaterialTheme.shapes.medium), .clip(MaterialTheme.shapes.medium)
.let { mod ->
if (sharedTransitionScope != null && animatedContentScope != null) {
with(sharedTransitionScope) {
mod.sharedElement(
rememberSharedContentState(key = "post_image_${postId}_0"),
animatedVisibilityScope = animatedContentScope,
boundsTransform = { _, _ ->
tween(durationMillis = 400, easing = FastOutSlowInEasing)
}
)
}
} else mod
},
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
onClick = { onImageClick(postId, 0) } onClick = { onImageClick(postId, 0) }
) )
@ -331,7 +350,20 @@ private fun PostCardImages(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.height(120.dp) .height(120.dp)
.clip(MaterialTheme.shapes.medium), .clip(MaterialTheme.shapes.medium)
.let { mod ->
if (sharedTransitionScope != null && animatedContentScope != null) {
with(sharedTransitionScope) {
mod.sharedElement(
rememberSharedContentState(key = "post_image_${postId}_$index"),
animatedVisibilityScope = animatedContentScope,
boundsTransform = { _, _ ->
tween(durationMillis = 400, easing = FastOutSlowInEasing)
}
)
}
} else mod
},
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
onClick = { onImageClick(postId, index) } onClick = { onImageClick(postId, index) }
) )
@ -351,7 +383,20 @@ private fun PostCardImages(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.height(100.dp) .height(100.dp)
.clip(MaterialTheme.shapes.medium), .clip(MaterialTheme.shapes.medium)
.let { mod ->
if (sharedTransitionScope != null && animatedContentScope != null) {
with(sharedTransitionScope) {
mod.sharedElement(
rememberSharedContentState(key = "post_image_${postId}_$index"),
animatedVisibilityScope = animatedContentScope,
boundsTransform = { _, _ ->
tween(durationMillis = 400, easing = FastOutSlowInEasing)
}
)
}
} else mod
},
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
onClick = { onImageClick(postId, index) } onClick = { onImageClick(postId, index) }
) )

View File

@ -228,8 +228,8 @@ private fun ImagePager(
) { page -> ) { page ->
ZoomableImage( ZoomableImage(
imageUrl = "${TangyuanApplication.instance.bizDomain}images/${imageUUIDs[page]}.jpg", imageUrl = "${TangyuanApplication.instance.bizDomain}images/${imageUUIDs[page]}.jpg",
imageUuid = imageUUIDs[page],
postId = postId, postId = postId,
imageIndex = page,
contentDescription = "图片 ${page + 1}", contentDescription = "图片 ${page + 1}",
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedContentScope = animatedContentScope animatedContentScope = animatedContentScope
@ -244,8 +244,8 @@ private fun ImagePager(
@Composable @Composable
private fun ZoomableImage( private fun ZoomableImage(
imageUrl: String, imageUrl: String,
imageUuid: String,
postId: Int, postId: Int,
imageIndex: Int,
contentDescription: String, contentDescription: String,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedContentScope: AnimatedContentScope? = null animatedContentScope: AnimatedContentScope? = null
@ -279,10 +279,10 @@ private fun ZoomableImage(
if (sharedTransitionScope != null && animatedContentScope != null) { if (sharedTransitionScope != null && animatedContentScope != null) {
with(sharedTransitionScope) { with(sharedTransitionScope) {
mod.sharedElement( mod.sharedElement(
rememberSharedContentState(key = "post_card_$postId"), rememberSharedContentState(key = "post_image_${postId}_${imageIndex}"),
animatedVisibilityScope = animatedContentScope, animatedVisibilityScope = animatedContentScope,
boundsTransform = { _, _ -> boundsTransform = { _, _ ->
tween(durationMillis = 500) tween(durationMillis = 400, easing = FastOutSlowInEasing)
} }
) )
} }

View File

@ -271,7 +271,7 @@ private fun PostDetailCard(
rememberSharedContentState(key = "post_card_${postCard.postId}"), rememberSharedContentState(key = "post_card_${postCard.postId}"),
animatedVisibilityScope = animatedContentScope, animatedVisibilityScope = animatedContentScope,
boundsTransform = { _, _ -> boundsTransform = { _, _ ->
tween(durationMillis = 500) tween(durationMillis = 400, easing = FastOutSlowInEasing)
} }
) )
} }
@ -312,7 +312,9 @@ private fun PostDetailCard(
PostDetailImages( PostDetailImages(
imageUUIDs = postCard.imageUUIDs, imageUUIDs = postCard.imageUUIDs,
postId = postCard.postId, postId = postCard.postId,
onImageClick = onImageClick onImageClick = onImageClick,
sharedTransitionScope = sharedTransitionScope,
animatedContentScope = animatedContentScope
) )
} }
@ -401,11 +403,14 @@ private fun PostDetailHeader(
/** /**
* 帖子详情图片 * 帖子详情图片
*/ */
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
private fun PostDetailImages( private fun PostDetailImages(
imageUUIDs: List<String>, imageUUIDs: List<String>,
postId: Int, postId: Int,
onImageClick: (Int, Int) -> Unit onImageClick: (Int, Int) -> Unit,
sharedTransitionScope: SharedTransitionScope? = null,
animatedContentScope: AnimatedContentScope? = null
) { ) {
when (imageUUIDs.size) { when (imageUUIDs.size) {
1 -> { 1 -> {
@ -418,7 +423,20 @@ private fun PostDetailImages(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(MaterialTheme.shapes.medium) .clip(MaterialTheme.shapes.medium)
.clickable { onImageClick(postId, 0) }, .clickable { onImageClick(postId, 0) }
.let { mod ->
if (sharedTransitionScope != null && animatedContentScope != null) {
with(sharedTransitionScope) {
mod.sharedElement(
rememberSharedContentState(key = "post_image_${postId}_0"),
animatedVisibilityScope = animatedContentScope,
boundsTransform = { _, _ ->
tween(durationMillis = 400, easing = FastOutSlowInEasing)
}
)
}
} else mod
},
contentScale = ContentScale.FillWidth contentScale = ContentScale.FillWidth
) )
} }
@ -438,7 +456,20 @@ private fun PostDetailImages(
.width(200.dp) .width(200.dp)
.height(150.dp) .height(150.dp)
.clip(MaterialTheme.shapes.medium) .clip(MaterialTheme.shapes.medium)
.clickable { onImageClick(postId, index) }, .clickable { onImageClick(postId, index) }
.let { mod ->
if (sharedTransitionScope != null && animatedContentScope != null) {
with(sharedTransitionScope) {
mod.sharedElement(
rememberSharedContentState(key = "post_image_${postId}_$index"),
animatedVisibilityScope = animatedContentScope,
boundsTransform = { _, _ ->
tween(durationMillis = 400, easing = FastOutSlowInEasing)
}
)
}
} else mod
},
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
} }