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:
parent
93f95bf9c3
commit
e373670715
@ -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
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) }
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user