feat: Implement image detail view and text/image mode switching
This commit introduces a dedicated image detail screen and integrates it with the post detail view, allowing users to switch between a text-focused layout and an immersive, image-centric one.
**Key Changes:**
* **feat(ImageDetailScreen):**
* Added a new `ImageDetailScreen` for a full-screen, immersive image viewing experience.
* Implemented a `HorizontalPager` to allow swiping between multiple images.
* Included zoom (pinch-to-zoom) and double-tap-to-zoom functionality for images.
* Overlaid post information (author, content snippet) on a blurred background, which can be swiped up to switch back to the text detail view.
* The background is a blurred version of the current image, creating an ambient effect.
* **feat(Navigation):**
* Created a `PostDetailContainer` to manage the animated transition between `PostDetailScreen` (text mode) and `ImageDetailScreen` (image mode).
* Updated the navigation route for `PostDetail` to accept a `mode` parameter (`text` or `image`) to handle deep linking directly into the image view.
* Clicking an image in the feed or post details now navigates to the image mode, preserving the shared element transition.
* **refactor(PostDetail):**
* Modified `PostCardItem` and `PostDetailScreen` to pass both `postId` and the image `index` on image clicks.
* Refactored the image click handler to trigger the navigation to the new image detail mode.
This commit is contained in:
parent
6a1bc7ad97
commit
93f95bf9c3
@ -6,8 +6,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavType
|
||||
@ -21,6 +20,7 @@ import com.qingshuige.tangyuan.ui.components.PageLevel
|
||||
import com.qingshuige.tangyuan.ui.components.TangyuanBottomAppBar
|
||||
import com.qingshuige.tangyuan.ui.components.TangyuanTopBar
|
||||
import com.qingshuige.tangyuan.ui.screens.PostDetailScreen
|
||||
import com.qingshuige.tangyuan.ui.screens.ImageDetailScreen
|
||||
import com.qingshuige.tangyuan.ui.screens.TalkScreen
|
||||
import com.qingshuige.tangyuan.ui.screens.LoginScreen
|
||||
|
||||
@ -40,6 +40,9 @@ fun App() {
|
||||
onPostClick = { postId ->
|
||||
navController.navigate(Screen.PostDetail.createRoute(postId))
|
||||
},
|
||||
onImageClick = { postId, imageIndex ->
|
||||
navController.navigate(Screen.PostDetail.createRoute(postId, "image") + "?imageIndex=$imageIndex")
|
||||
},
|
||||
sharedTransitionScope = this@SharedTransitionLayout,
|
||||
animatedContentScope = this@composable
|
||||
)
|
||||
@ -78,21 +81,24 @@ fun App() {
|
||||
LoginScreen(navController = navController)
|
||||
}
|
||||
|
||||
// 帖子详情页 - 只有共享元素动画,无页面切换动画
|
||||
// 帖子详情页 - 统一容器管理两种模式
|
||||
composable(
|
||||
route = Screen.PostDetail.route,
|
||||
arguments = listOf(navArgument("postId") { type = NavType.IntType })
|
||||
arguments = listOf(
|
||||
navArgument("postId") { type = NavType.IntType },
|
||||
navArgument("mode") { type = NavType.StringType; defaultValue = "text" }
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val postId = backStackEntry.arguments?.getInt("postId") ?: 0
|
||||
PostDetailScreen(
|
||||
val initialMode = backStackEntry.arguments?.getString("mode") ?: "text"
|
||||
|
||||
PostDetailContainer(
|
||||
postId = postId,
|
||||
initialMode = initialMode,
|
||||
onBackClick = { navController.popBackStack() },
|
||||
onAuthorClick = { authorId ->
|
||||
// TODO: 导航到用户详情页
|
||||
},
|
||||
onImageClick = { imageUuid ->
|
||||
// TODO: 导航到图片查看页
|
||||
},
|
||||
sharedTransitionScope = this@SharedTransitionLayout,
|
||||
animatedContentScope = this@composable
|
||||
)
|
||||
@ -101,11 +107,99 @@ fun App() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 帖子详情容器 - 统一管理文字和图片两种模式
|
||||
* 保持与PostCard的共享元素动画,同时支持内部模式切换动画
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun PostDetailContainer(
|
||||
postId: Int,
|
||||
initialMode: String,
|
||||
onBackClick: () -> Unit,
|
||||
onAuthorClick: (Int) -> Unit,
|
||||
sharedTransitionScope: SharedTransitionScope,
|
||||
animatedContentScope: AnimatedContentScope
|
||||
) {
|
||||
// 本地状态管理模式切换
|
||||
var currentMode by remember { mutableStateOf(initialMode) }
|
||||
var imageIndex by remember { mutableIntStateOf(0) }
|
||||
|
||||
// 为模式切换创建内部AnimatedContent
|
||||
AnimatedContent(
|
||||
targetState = currentMode,
|
||||
transitionSpec = {
|
||||
// 使用滑动动画让切换更自然
|
||||
when {
|
||||
targetState == "image" && initialState == "text" -> {
|
||||
slideInVertically(
|
||||
initialOffsetY = { it },
|
||||
animationSpec = tween(400)
|
||||
) togetherWith slideOutVertically(
|
||||
targetOffsetY = { -it },
|
||||
animationSpec = tween(400)
|
||||
)
|
||||
}
|
||||
targetState == "text" && initialState == "image" -> {
|
||||
slideInVertically(
|
||||
initialOffsetY = { -it },
|
||||
animationSpec = tween(400)
|
||||
) togetherWith slideOutVertically(
|
||||
targetOffsetY = { it },
|
||||
animationSpec = tween(400)
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
fadeIn(animationSpec = tween(300)) togetherWith
|
||||
fadeOut(animationSpec = tween(300))
|
||||
}
|
||||
}
|
||||
},
|
||||
label = "detail_mode_switch"
|
||||
) { mode ->
|
||||
when (mode) {
|
||||
"image" -> {
|
||||
ImageDetailScreen(
|
||||
postId = postId,
|
||||
initialImageIndex = imageIndex,
|
||||
onBackClick = onBackClick,
|
||||
onAuthorClick = onAuthorClick,
|
||||
onSwitchToTextMode = {
|
||||
currentMode = "text"
|
||||
},
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedContentScope = this@AnimatedContent
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
PostDetailScreen(
|
||||
postId = postId,
|
||||
onBackClick = onBackClick,
|
||||
onAuthorClick = onAuthorClick,
|
||||
onImageClick = { _, selectedImageIndex ->
|
||||
imageIndex = selectedImageIndex
|
||||
currentMode = "image"
|
||||
},
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedContentScope = if (mode == initialMode) {
|
||||
// 如果是初始模式,使用外部的animatedContentScope来保持与PostCard的共享动画
|
||||
animatedContentScope
|
||||
} else {
|
||||
// 如果是切换后的模式,使用内部的AnimatedContent scope
|
||||
this@AnimatedContent
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun MainFlow(
|
||||
onLoginClick: () -> Unit,
|
||||
onPostClick: (Int) -> Unit,
|
||||
onImageClick: (Int, Int) -> Unit = { _, _ -> },
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedContentScope: AnimatedContentScope? = null
|
||||
) {
|
||||
@ -151,6 +245,7 @@ fun MainFlow(
|
||||
onAuthorClick = { authorId ->
|
||||
// TODO: 导航到用户详情页
|
||||
},
|
||||
onImageClick = onImageClick,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedContentScope = animatedContentScope
|
||||
)
|
||||
|
||||
@ -6,7 +6,10 @@ sealed class Screen(val route: String, val title: String) {
|
||||
object Topic : Screen("topic", "侃一侃")
|
||||
object Message : Screen("message", "消息")
|
||||
object User : Screen("settings", "我的")
|
||||
object PostDetail : Screen("post_detail/{postId}", "帖子详情") {
|
||||
fun createRoute(postId: Int) = "post_detail/$postId"
|
||||
object PostDetail : Screen("post_detail/{postId}/{mode}", "帖子详情") {
|
||||
fun createRoute(postId: Int, mode: String = "text") = "post_detail/$postId/$mode"
|
||||
}
|
||||
object ImageDetail : Screen("image_detail/{postId}/{imageIndex}", "图片详情") {
|
||||
fun createRoute(postId: Int, imageIndex: Int) = "image_detail/$postId/$imageIndex"
|
||||
}
|
||||
}
|
||||
@ -132,6 +132,7 @@ fun PostCardItem(
|
||||
onShareClick: (Int) -> Unit = {},
|
||||
onBookmarkClick: (Int) -> Unit = {},
|
||||
onMoreClick: (Int) -> Unit = {},
|
||||
onImageClick: (Int, Int) -> Unit = { _, _ -> },
|
||||
modifier: Modifier = Modifier,
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedContentScope: AnimatedContentScope? = null
|
||||
@ -185,7 +186,8 @@ fun PostCardItem(
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
PostCardImages(
|
||||
imageUUIDs = postCard.imageUUIDs,
|
||||
onImageClick = { /* TODO: 查看大图 */ }
|
||||
postId = postCard.postId,
|
||||
onImageClick = onImageClick
|
||||
)
|
||||
}
|
||||
|
||||
@ -299,7 +301,8 @@ private fun PostCardContent(postCard: PostCard) {
|
||||
@Composable
|
||||
private fun PostCardImages(
|
||||
imageUUIDs: List<String>,
|
||||
onImageClick: (String) -> Unit
|
||||
postId: Int,
|
||||
onImageClick: (Int, Int) -> Unit
|
||||
) {
|
||||
when (imageUUIDs.size) {
|
||||
1 -> {
|
||||
@ -312,7 +315,7 @@ private fun PostCardImages(
|
||||
.height(200.dp)
|
||||
.clip(MaterialTheme.shapes.medium),
|
||||
contentScale = ContentScale.Crop,
|
||||
onClick = { onImageClick(imageUUIDs[0]) }
|
||||
onClick = { onImageClick(postId, 0) }
|
||||
)
|
||||
}
|
||||
|
||||
@ -321,7 +324,7 @@ private fun PostCardImages(
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
imageUUIDs.forEach { uuid ->
|
||||
imageUUIDs.forEachIndexed { index, uuid ->
|
||||
ShimmerAsyncImage(
|
||||
imageUrl = "${TangyuanApplication.instance.bizDomain}images/$uuid.jpg",
|
||||
contentDescription = "文章图片",
|
||||
@ -330,7 +333,7 @@ private fun PostCardImages(
|
||||
.height(120.dp)
|
||||
.clip(MaterialTheme.shapes.medium),
|
||||
contentScale = ContentScale.Crop,
|
||||
onClick = { onImageClick(uuid) }
|
||||
onClick = { onImageClick(postId, index) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -341,7 +344,7 @@ private fun PostCardImages(
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
imageUUIDs.forEach { uuid ->
|
||||
imageUUIDs.forEachIndexed { index, uuid ->
|
||||
ShimmerAsyncImage(
|
||||
imageUrl = "${TangyuanApplication.instance.bizDomain}images/$uuid.jpg",
|
||||
contentDescription = "文章图片",
|
||||
@ -350,7 +353,7 @@ private fun PostCardImages(
|
||||
.height(100.dp)
|
||||
.clip(MaterialTheme.shapes.medium),
|
||||
contentScale = ContentScale.Crop,
|
||||
onClick = { onImageClick(uuid) }
|
||||
onClick = { onImageClick(postId, index) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,562 @@
|
||||
package com.qingshuige.tangyuan.ui.screens
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.gestures.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.BlurredEdgeTreatment
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.qingshuige.tangyuan.TangyuanApplication
|
||||
import com.qingshuige.tangyuan.model.PostCard
|
||||
import com.qingshuige.tangyuan.ui.theme.LiteraryFontFamily
|
||||
import com.qingshuige.tangyuan.ui.theme.TangyuanGeneralFontFamily
|
||||
import com.qingshuige.tangyuan.ui.theme.TangyuanShapes
|
||||
import com.qingshuige.tangyuan.utils.withPanguSpacing
|
||||
import com.qingshuige.tangyuan.viewmodel.PostDetailViewModel
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* 图片详情页面 - 以图片为主的展示界面
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun ImageDetailScreen(
|
||||
postId: Int,
|
||||
initialImageIndex: Int = 0,
|
||||
onBackClick: () -> Unit = {},
|
||||
onAuthorClick: (Int) -> Unit = {},
|
||||
onSwitchToTextMode: () -> Unit = {},
|
||||
viewModel: PostDetailViewModel = hiltViewModel(),
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedContentScope: AnimatedContentScope? = null
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
// 加载帖子详情
|
||||
LaunchedEffect(postId) {
|
||||
viewModel.loadPostDetail(postId)
|
||||
}
|
||||
|
||||
// 当有帖子数据时才显示内容
|
||||
state.postCard?.let { postCard ->
|
||||
val imageUUIDs = postCard.imageUUIDs
|
||||
val validImageIndex = initialImageIndex.coerceIn(0, imageUUIDs.size - 1)
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = validImageIndex,
|
||||
pageCount = { imageUUIDs.size }
|
||||
)
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// 背景图片(模糊效果)
|
||||
BackgroundBlurredImage(
|
||||
imageUUIDs = imageUUIDs,
|
||||
currentPage = pagerState.currentPage
|
||||
)
|
||||
|
||||
// 主要内容
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// 顶部导航栏
|
||||
ImageDetailTopBar(
|
||||
onBackClick = onBackClick,
|
||||
currentIndex = pagerState.currentPage + 1,
|
||||
totalCount = imageUUIDs.size
|
||||
)
|
||||
|
||||
// 图片轮播区域
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (imageUUIDs.isNotEmpty()) {
|
||||
ImagePager(
|
||||
imageUUIDs = imageUUIDs,
|
||||
postId = postCard.postId,
|
||||
pagerState = pagerState,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedContentScope = animatedContentScope
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 底部内容区域(模糊遮罩)
|
||||
BottomContentOverlay(
|
||||
postCard = postCard,
|
||||
onAuthorClick = onAuthorClick,
|
||||
onSwitchToTextMode = onSwitchToTextMode
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载状态
|
||||
if (state.isLoading && state.postCard == null) {
|
||||
LoadingContent()
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
state.error?.let { error ->
|
||||
ErrorContent(
|
||||
message = error,
|
||||
onRetry = {
|
||||
viewModel.clearError()
|
||||
viewModel.loadPostDetail(postId)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 背景模糊图片
|
||||
*/
|
||||
@Composable
|
||||
private fun BackgroundBlurredImage(
|
||||
imageUUIDs: List<String>,
|
||||
currentPage: Int
|
||||
) {
|
||||
if (imageUUIDs.isNotEmpty() && currentPage < imageUUIDs.size) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data("${TangyuanApplication.instance.bizDomain}images/${imageUUIDs[currentPage]}.jpg")
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.blur(radius = 20.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded),
|
||||
contentScale = ContentScale.Crop,
|
||||
alpha = 0.3f
|
||||
)
|
||||
}
|
||||
|
||||
// 渐变遮罩
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Black.copy(alpha = 0.4f),
|
||||
Color.Black.copy(alpha = 0.1f),
|
||||
Color.Black.copy(alpha = 0.6f)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶部导航栏
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ImageDetailTopBar(
|
||||
onBackClick: () -> Unit,
|
||||
currentIndex: Int,
|
||||
totalCount: Int
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "$currentIndex / $totalCount",
|
||||
fontFamily = TangyuanGeneralFontFamily,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackClick) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowBack,
|
||||
contentDescription = "返回",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片轮播组件
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
private fun ImagePager(
|
||||
imageUUIDs: List<String>,
|
||||
postId: Int,
|
||||
pagerState: PagerState,
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedContentScope: AnimatedContentScope? = null
|
||||
) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) { page ->
|
||||
ZoomableImage(
|
||||
imageUrl = "${TangyuanApplication.instance.bizDomain}images/${imageUUIDs[page]}.jpg",
|
||||
imageUuid = imageUUIDs[page],
|
||||
postId = postId,
|
||||
contentDescription = "图片 ${page + 1}",
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedContentScope = animatedContentScope
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 可缩放的图片组件
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
private fun ZoomableImage(
|
||||
imageUrl: String,
|
||||
imageUuid: String,
|
||||
postId: Int,
|
||||
contentDescription: String,
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedContentScope: AnimatedContentScope? = null
|
||||
) {
|
||||
var scale by remember { mutableFloatStateOf(1f) }
|
||||
var offset by remember { mutableStateOf(Offset.Zero) }
|
||||
|
||||
val transformableState = rememberTransformableState { zoomChange, offsetChange, _ ->
|
||||
scale = (scale * zoomChange).coerceIn(1f, 5f)
|
||||
val maxX = (scale - 1) * 300
|
||||
val maxY = (scale - 1) * 300
|
||||
offset = Offset(
|
||||
x = (offset.x + offsetChange.x).coerceIn(-maxX, maxX),
|
||||
y = (offset.y + offsetChange.y).coerceIn(-maxY, maxY)
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(imageUrl)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.let { mod ->
|
||||
if (sharedTransitionScope != null && animatedContentScope != null) {
|
||||
with(sharedTransitionScope) {
|
||||
mod.sharedElement(
|
||||
rememberSharedContentState(key = "post_card_$postId"),
|
||||
animatedVisibilityScope = animatedContentScope,
|
||||
boundsTransform = { _, _ ->
|
||||
tween(durationMillis = 500)
|
||||
}
|
||||
)
|
||||
}
|
||||
} else mod
|
||||
}
|
||||
.graphicsLayer(
|
||||
scaleX = scale,
|
||||
scaleY = scale,
|
||||
translationX = offset.x,
|
||||
translationY = offset.y
|
||||
)
|
||||
.transformable(state = transformableState)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onDoubleTap = {
|
||||
scale = if (scale > 1f) 1f else 2f
|
||||
offset = Offset.Zero
|
||||
}
|
||||
)
|
||||
},
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 底部内容遮罩
|
||||
*/
|
||||
@Composable
|
||||
private fun BottomContentOverlay(
|
||||
postCard: PostCard,
|
||||
onAuthorClick: (Int) -> Unit,
|
||||
onSwitchToTextMode: () -> Unit
|
||||
) {
|
||||
var offsetY by remember { mutableFloatStateOf(0f) }
|
||||
val swipeThreshold = -100f // 上滑超过100px触发切换
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = 0.8f)
|
||||
)
|
||||
)
|
||||
)
|
||||
.offset(y = offsetY.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectDragGestures(
|
||||
onDragEnd = {
|
||||
if (offsetY < swipeThreshold) {
|
||||
onSwitchToTextMode()
|
||||
} else {
|
||||
offsetY = 0f
|
||||
}
|
||||
}
|
||||
) { _, dragAmount ->
|
||||
offsetY = (offsetY + dragAmount.y).coerceAtMost(0f)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp)
|
||||
) {
|
||||
// 作者信息
|
||||
PostAuthorInfo(
|
||||
postCard = postCard,
|
||||
onAuthorClick = onAuthorClick
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 文章内容
|
||||
Text(
|
||||
text = postCard.textContent.withPanguSpacing(),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
lineHeight = 22.sp
|
||||
),
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = Color.White,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 分类和时间
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color.White.copy(alpha = 0.2f)
|
||||
) {
|
||||
Text(
|
||||
text = postCard.categoryName,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = postCard.getTimeDisplayText(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = TangyuanGeneralFontFamily,
|
||||
color = Color.White.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// 上滑提示 - 放在最下面居中
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "上滑查看详情",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontFamily = TangyuanGeneralFontFamily,
|
||||
color = Color.White.copy(alpha = 0.4f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 作者信息组件
|
||||
*/
|
||||
@Composable
|
||||
private fun PostAuthorInfo(
|
||||
postCard: PostCard,
|
||||
onAuthorClick: (Int) -> Unit
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable { onAuthorClick(postCard.authorId) }
|
||||
) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data("${TangyuanApplication.instance.bizDomain}images/${postCard.authorAvatar}.jpg")
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = "${postCard.authorName}的头像",
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = postCard.authorName.withPanguSpacing(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontFamily = TangyuanGeneralFontFamily,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
if (postCard.authorBio.isNotBlank()) {
|
||||
Text(
|
||||
text = postCard.authorBio.withPanguSpacing(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = Color.White.copy(alpha = 0.8f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载状态组件
|
||||
*/
|
||||
@Composable
|
||||
private fun LoadingContent() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = "正在加载图片...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误状态组件
|
||||
*/
|
||||
@Composable
|
||||
private fun ErrorContent(
|
||||
message: String,
|
||||
onRetry: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.padding(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ErrorOutline,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "加载失败",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = Color.White.copy(alpha = 0.8f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = onRetry,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White.copy(alpha = 0.2f)
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "重试",
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -51,7 +51,7 @@ fun PostDetailScreen(
|
||||
postId: Int,
|
||||
onBackClick: () -> Unit = {},
|
||||
onAuthorClick: (Int) -> Unit = {},
|
||||
onImageClick: (String) -> Unit = {},
|
||||
onImageClick: (Int, Int) -> Unit = { _, _ -> },
|
||||
viewModel: PostDetailViewModel = hiltViewModel(),
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedContentScope: AnimatedContentScope? = null
|
||||
@ -181,7 +181,7 @@ private fun PostDetailContent(
|
||||
isError: Boolean,
|
||||
errorMessage: String?,
|
||||
onAuthorClick: (Int) -> Unit,
|
||||
onImageClick: (String) -> Unit,
|
||||
onImageClick: (Int, Int) -> Unit,
|
||||
onReplyToComment: (CommentCard) -> Unit,
|
||||
onDeleteComment: (Int) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
@ -256,7 +256,7 @@ private fun PostDetailContent(
|
||||
private fun PostDetailCard(
|
||||
postCard: PostCard,
|
||||
onAuthorClick: (Int) -> Unit,
|
||||
onImageClick: (String) -> Unit,
|
||||
onImageClick: (Int, Int) -> Unit,
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedContentScope: AnimatedContentScope? = null
|
||||
) {
|
||||
@ -311,6 +311,7 @@ private fun PostDetailCard(
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
PostDetailImages(
|
||||
imageUUIDs = postCard.imageUUIDs,
|
||||
postId = postCard.postId,
|
||||
onImageClick = onImageClick
|
||||
)
|
||||
}
|
||||
@ -403,7 +404,8 @@ private fun PostDetailHeader(
|
||||
@Composable
|
||||
private fun PostDetailImages(
|
||||
imageUUIDs: List<String>,
|
||||
onImageClick: (String) -> Unit
|
||||
postId: Int,
|
||||
onImageClick: (Int, Int) -> Unit
|
||||
) {
|
||||
when (imageUUIDs.size) {
|
||||
1 -> {
|
||||
@ -416,7 +418,7 @@ private fun PostDetailImages(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.clickable { onImageClick(imageUUIDs[0]) },
|
||||
.clickable { onImageClick(postId, 0) },
|
||||
contentScale = ContentScale.FillWidth
|
||||
)
|
||||
}
|
||||
@ -425,10 +427,10 @@ private fun PostDetailImages(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(horizontal = 4.dp)
|
||||
) {
|
||||
items(imageUUIDs) { uuid ->
|
||||
items(imageUUIDs.size) { index ->
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data("${TangyuanApplication.instance.bizDomain}images/$uuid.jpg")
|
||||
.data("${TangyuanApplication.instance.bizDomain}images/${imageUUIDs[index]}.jpg")
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = "文章图片",
|
||||
@ -436,7 +438,7 @@ private fun PostDetailImages(
|
||||
.width(200.dp)
|
||||
.height(150.dp)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.clickable { onImageClick(uuid) },
|
||||
.clickable { onImageClick(postId, index) },
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
|
||||
@ -31,6 +31,7 @@ import kotlinx.coroutines.launch
|
||||
fun TalkScreen(
|
||||
onPostClick: (Int) -> Unit = {},
|
||||
onAuthorClick: (Int) -> Unit = {},
|
||||
onImageClick: (Int, Int) -> Unit = { _, _ -> },
|
||||
viewModel: TalkViewModel = hiltViewModel(),
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedContentScope: AnimatedContentScope? = null
|
||||
@ -101,6 +102,7 @@ fun TalkScreen(
|
||||
onMoreClick = { postId ->
|
||||
// TODO: 显示更多操作菜单
|
||||
},
|
||||
onImageClick = onImageClick,
|
||||
onErrorDismiss = viewModel::clearError,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedContentScope = animatedContentScope
|
||||
@ -129,6 +131,7 @@ private fun PostList(
|
||||
onShareClick: (Int) -> Unit,
|
||||
onBookmarkClick: (Int) -> Unit,
|
||||
onMoreClick: (Int) -> Unit,
|
||||
onImageClick: (Int, Int) -> Unit,
|
||||
onErrorDismiss: () -> Unit,
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedContentScope: AnimatedContentScope? = null
|
||||
@ -151,6 +154,7 @@ private fun PostList(
|
||||
onShareClick = onShareClick,
|
||||
onBookmarkClick = onBookmarkClick,
|
||||
onMoreClick = onMoreClick,
|
||||
onImageClick = onImageClick,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedContentScope = animatedContentScope
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user