From e3736707150591b413b287af768b517e347ec307 Mon Sep 17 00:00:00 2001 From: grtsinry43 Date: Mon, 6 Oct 2025 02:47:03 +0800 Subject: [PATCH] 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`. --- .../main/java/com/qingshuige/tangyuan/App.kt | 42 +++++++------- .../tangyuan/ui/components/PostCardItem.kt | 55 +++++++++++++++++-- .../tangyuan/ui/screens/ImageDetailScreen.kt | 8 +-- .../tangyuan/ui/screens/PostDetailScreen.kt | 41 ++++++++++++-- 4 files changed, 112 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/com/qingshuige/tangyuan/App.kt b/app/src/main/java/com/qingshuige/tangyuan/App.kt index 4ecdde4..145de27 100644 --- a/app/src/main/java/com/qingshuige/tangyuan/App.kt +++ b/app/src/main/java/com/qingshuige/tangyuan/App.kt @@ -83,18 +83,25 @@ fun App() { // 帖子详情页 - 统一容器管理两种模式 composable( - route = Screen.PostDetail.route, + route = Screen.PostDetail.route + "?imageIndex={imageIndex}", arguments = listOf( 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 -> val postId = backStackEntry.arguments?.getInt("postId") ?: 0 val initialMode = backStackEntry.arguments?.getString("mode") ?: "text" + val imageIndex = backStackEntry.arguments?.getInt("imageIndex") ?: 0 PostDetailContainer( postId = postId, initialMode = initialMode, + initialImageIndex = imageIndex, onBackClick = { navController.popBackStack() }, onAuthorClick = { authorId -> // TODO: 导航到用户详情页 @@ -116,6 +123,7 @@ fun App() { fun PostDetailContainer( postId: Int, initialMode: String, + initialImageIndex: Int = 0, onBackClick: () -> Unit, onAuthorClick: (Int) -> Unit, sharedTransitionScope: SharedTransitionScope, @@ -123,35 +131,35 @@ fun PostDetailContainer( ) { // 本地状态管理模式切换 var currentMode by remember { mutableStateOf(initialMode) } - var imageIndex by remember { mutableIntStateOf(0) } + var imageIndex by remember { mutableIntStateOf(initialImageIndex) } // 为模式切换创建内部AnimatedContent AnimatedContent( targetState = currentMode, transitionSpec = { - // 使用滑动动画让切换更自然 + // 使用更流畅的动画让切换更自然 when { targetState == "image" && initialState == "text" -> { slideInVertically( initialOffsetY = { it }, - animationSpec = tween(400) + animationSpec = tween(500, easing = FastOutSlowInEasing) ) togetherWith slideOutVertically( targetOffsetY = { -it }, - animationSpec = tween(400) + animationSpec = tween(500, easing = FastOutSlowInEasing) ) } targetState == "text" && initialState == "image" -> { slideInVertically( initialOffsetY = { -it }, - animationSpec = tween(400) + animationSpec = tween(500, easing = FastOutSlowInEasing) ) togetherWith slideOutVertically( targetOffsetY = { it }, - animationSpec = tween(400) + animationSpec = tween(500, easing = FastOutSlowInEasing) ) } else -> { - fadeIn(animationSpec = tween(300)) togetherWith - fadeOut(animationSpec = tween(300)) + fadeIn(animationSpec = tween(400)) togetherWith + fadeOut(animationSpec = tween(400)) } } }, @@ -167,8 +175,8 @@ fun PostDetailContainer( onSwitchToTextMode = { currentMode = "text" }, - sharedTransitionScope = sharedTransitionScope, - animatedContentScope = this@AnimatedContent + sharedTransitionScope = if (mode == initialMode) sharedTransitionScope else null, + animatedContentScope = if (mode == initialMode) animatedContentScope else null ) } else -> { @@ -180,14 +188,8 @@ fun PostDetailContainer( imageIndex = selectedImageIndex currentMode = "image" }, - sharedTransitionScope = sharedTransitionScope, - animatedContentScope = if (mode == initialMode) { - // 如果是初始模式,使用外部的animatedContentScope来保持与PostCard的共享动画 - animatedContentScope - } else { - // 如果是切换后的模式,使用内部的AnimatedContent scope - this@AnimatedContent - } + sharedTransitionScope = if (mode == initialMode) sharedTransitionScope else null, + animatedContentScope = if (mode == initialMode) animatedContentScope else null ) } } diff --git a/app/src/main/java/com/qingshuige/tangyuan/ui/components/PostCardItem.kt b/app/src/main/java/com/qingshuige/tangyuan/ui/components/PostCardItem.kt index 61945b6..35b1d33 100644 --- a/app/src/main/java/com/qingshuige/tangyuan/ui/components/PostCardItem.kt +++ b/app/src/main/java/com/qingshuige/tangyuan/ui/components/PostCardItem.kt @@ -8,6 +8,7 @@ import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.tween import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -187,7 +188,9 @@ fun PostCardItem( PostCardImages( imageUUIDs = postCard.imageUUIDs, postId = postCard.postId, - onImageClick = onImageClick + onImageClick = onImageClick, + sharedTransitionScope = sharedTransitionScope, + animatedContentScope = animatedContentScope ) } @@ -298,11 +301,14 @@ private fun PostCardContent(postCard: PostCard) { /** * 图片展示 */ +@OptIn(ExperimentalSharedTransitionApi::class) @Composable private fun PostCardImages( imageUUIDs: List, postId: Int, - onImageClick: (Int, Int) -> Unit + onImageClick: (Int, Int) -> Unit, + sharedTransitionScope: SharedTransitionScope? = null, + animatedContentScope: AnimatedContentScope? = null ) { when (imageUUIDs.size) { 1 -> { @@ -313,7 +319,20 @@ private fun PostCardImages( modifier = Modifier .fillMaxWidth() .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, onClick = { onImageClick(postId, 0) } ) @@ -331,7 +350,20 @@ private fun PostCardImages( modifier = Modifier .weight(1f) .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, onClick = { onImageClick(postId, index) } ) @@ -351,7 +383,20 @@ private fun PostCardImages( modifier = Modifier .weight(1f) .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, onClick = { onImageClick(postId, index) } ) diff --git a/app/src/main/java/com/qingshuige/tangyuan/ui/screens/ImageDetailScreen.kt b/app/src/main/java/com/qingshuige/tangyuan/ui/screens/ImageDetailScreen.kt index 65724a0..152c27c 100644 --- a/app/src/main/java/com/qingshuige/tangyuan/ui/screens/ImageDetailScreen.kt +++ b/app/src/main/java/com/qingshuige/tangyuan/ui/screens/ImageDetailScreen.kt @@ -228,8 +228,8 @@ private fun ImagePager( ) { page -> ZoomableImage( imageUrl = "${TangyuanApplication.instance.bizDomain}images/${imageUUIDs[page]}.jpg", - imageUuid = imageUUIDs[page], postId = postId, + imageIndex = page, contentDescription = "图片 ${page + 1}", sharedTransitionScope = sharedTransitionScope, animatedContentScope = animatedContentScope @@ -244,8 +244,8 @@ private fun ImagePager( @Composable private fun ZoomableImage( imageUrl: String, - imageUuid: String, postId: Int, + imageIndex: Int, contentDescription: String, sharedTransitionScope: SharedTransitionScope? = null, animatedContentScope: AnimatedContentScope? = null @@ -279,10 +279,10 @@ private fun ZoomableImage( if (sharedTransitionScope != null && animatedContentScope != null) { with(sharedTransitionScope) { mod.sharedElement( - rememberSharedContentState(key = "post_card_$postId"), + rememberSharedContentState(key = "post_image_${postId}_${imageIndex}"), animatedVisibilityScope = animatedContentScope, boundsTransform = { _, _ -> - tween(durationMillis = 500) + tween(durationMillis = 400, easing = FastOutSlowInEasing) } ) } diff --git a/app/src/main/java/com/qingshuige/tangyuan/ui/screens/PostDetailScreen.kt b/app/src/main/java/com/qingshuige/tangyuan/ui/screens/PostDetailScreen.kt index e386ff8..168855e 100644 --- a/app/src/main/java/com/qingshuige/tangyuan/ui/screens/PostDetailScreen.kt +++ b/app/src/main/java/com/qingshuige/tangyuan/ui/screens/PostDetailScreen.kt @@ -271,7 +271,7 @@ private fun PostDetailCard( rememberSharedContentState(key = "post_card_${postCard.postId}"), animatedVisibilityScope = animatedContentScope, boundsTransform = { _, _ -> - tween(durationMillis = 500) + tween(durationMillis = 400, easing = FastOutSlowInEasing) } ) } @@ -312,7 +312,9 @@ private fun PostDetailCard( PostDetailImages( imageUUIDs = postCard.imageUUIDs, postId = postCard.postId, - onImageClick = onImageClick + onImageClick = onImageClick, + sharedTransitionScope = sharedTransitionScope, + animatedContentScope = animatedContentScope ) } @@ -401,11 +403,14 @@ private fun PostDetailHeader( /** * 帖子详情图片 */ +@OptIn(ExperimentalSharedTransitionApi::class) @Composable private fun PostDetailImages( imageUUIDs: List, postId: Int, - onImageClick: (Int, Int) -> Unit + onImageClick: (Int, Int) -> Unit, + sharedTransitionScope: SharedTransitionScope? = null, + animatedContentScope: AnimatedContentScope? = null ) { when (imageUUIDs.size) { 1 -> { @@ -418,7 +423,20 @@ private fun PostDetailImages( modifier = Modifier .fillMaxWidth() .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 ) } @@ -438,7 +456,20 @@ private fun PostDetailImages( .width(200.dp) .height(150.dp) .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 ) }