九宫格图片或者视频支持拖拽调整顺序
This commit is contained in:
@@ -13,11 +13,11 @@ android {
|
||||
applicationId = "com.memory.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 67
|
||||
versionName = "1.8.3"
|
||||
versionCode = 76
|
||||
versionName = "1.9.2"
|
||||
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://x.amos.us.kg/api/\"")
|
||||
buildConfigField("int", "VERSION_CODE", "67")
|
||||
buildConfigField("int", "VERSION_CODE", "76")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
package com.memory.app.ui.components
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.AnimationVector2D
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
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.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInParent
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import coil.compose.AsyncImage
|
||||
import com.memory.app.ui.theme.Brand500
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed class MediaItemData {
|
||||
abstract val id: String
|
||||
|
||||
data class Image(val uri: Uri, override val id: String = uri.toString()) : MediaItemData()
|
||||
data class Video(val uri: Uri, override val id: String = uri.toString()) : MediaItemData()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ReorderableMediaGrid(
|
||||
items: List<MediaItemData>,
|
||||
onItemsReordered: (List<MediaItemData>) -> Unit,
|
||||
onRemove: ((MediaItemData) -> Unit)? = null,
|
||||
onClick: ((MediaItemData) -> Unit)? = null,
|
||||
uploadProgress: Map<Int, Float> = emptyMap(),
|
||||
isLoading: Boolean = false,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var mediaItems by remember(items) { mutableStateOf(items) }
|
||||
var draggedIndex by remember { mutableStateOf<Int?>(null) }
|
||||
var draggedItemOffset by remember { mutableStateOf(Offset.Zero) }
|
||||
|
||||
// 记录每个项目的目标位置偏移(用于动画)
|
||||
val itemOffsets = remember { mutableStateMapOf<String, Animatable<Offset, AnimationVector2D>>() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// 同步外部变化
|
||||
LaunchedEffect(items) {
|
||||
if (items != mediaItems) {
|
||||
mediaItems = items
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaItems.isEmpty()) return
|
||||
|
||||
val columns = if (mediaItems.size <= 3) mediaItems.size else 3
|
||||
val rows = (mediaItems.size + columns - 1) / columns
|
||||
val itemSize = 90.dp
|
||||
val spacing = 8.dp
|
||||
val itemSizePx = itemSize.value + spacing.value
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(spacing)
|
||||
) {
|
||||
for (row in 0 until rows) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(spacing)) {
|
||||
for (col in 0 until columns) {
|
||||
val index = row * columns + col
|
||||
if (index < mediaItems.size) {
|
||||
val item = mediaItems[index]
|
||||
val progress = uploadProgress[index]
|
||||
val isDragging = draggedIndex == index
|
||||
|
||||
// 为每个项目创建动画偏移
|
||||
val animatedOffset = itemOffsets.getOrPut(item.id) {
|
||||
Animatable(Offset.Zero, Offset.VectorConverter)
|
||||
}
|
||||
|
||||
ReorderableMediaItem(
|
||||
item = item,
|
||||
index = index,
|
||||
isDragging = isDragging,
|
||||
dragOffset = if (isDragging) draggedItemOffset else Offset.Zero,
|
||||
animatedOffset = if (!isDragging) animatedOffset.value else Offset.Zero,
|
||||
onRemove = if (!isLoading && onRemove != null) {
|
||||
{ onRemove(item) }
|
||||
} else null,
|
||||
onClick = if (onClick != null) {
|
||||
{ onClick(item) }
|
||||
} else null,
|
||||
onDragStart = {
|
||||
if (!isLoading) {
|
||||
draggedIndex = index
|
||||
draggedItemOffset = Offset.Zero
|
||||
}
|
||||
},
|
||||
onDrag = { offset ->
|
||||
val currentIndex = draggedIndex ?: return@ReorderableMediaItem
|
||||
draggedItemOffset += offset
|
||||
|
||||
// 计算拖拽项当前的行列位置
|
||||
val currentRow = currentIndex / columns
|
||||
val currentCol = currentIndex % columns
|
||||
|
||||
// 直接基于偏移量判断是否应该交换,而不是先计算 targetCol
|
||||
// 这样可以避免刚越过边界就触发交换的问题
|
||||
|
||||
// 计算应该交换到哪个方向
|
||||
val colOffset = when {
|
||||
draggedItemOffset.x > itemSizePx * 0.7f -> 1 // 向右超过 70%
|
||||
draggedItemOffset.x < -itemSizePx * 0.7f -> -1 // 向左超过 70%
|
||||
else -> 0
|
||||
}
|
||||
|
||||
val rowOffset = when {
|
||||
draggedItemOffset.y > itemSizePx * 0.7f -> 1 // 向下超过 70%
|
||||
draggedItemOffset.y < -itemSizePx * 0.7f -> -1 // 向上超过 70%
|
||||
else -> 0
|
||||
}
|
||||
|
||||
// 只有在超过阈值时才计算目标位置
|
||||
if (colOffset != 0 || rowOffset != 0) {
|
||||
val targetCol = currentCol + colOffset
|
||||
val targetRow = currentRow + rowOffset
|
||||
val targetIndex = targetRow * columns + targetCol
|
||||
|
||||
// 检查目标索引是否有效
|
||||
if (targetIndex in mediaItems.indices && targetIndex != currentIndex) {
|
||||
// 交换位置
|
||||
val newList = mediaItems.toMutableList()
|
||||
val draggedItem = newList[currentIndex]
|
||||
val targetItem = newList[targetIndex]
|
||||
|
||||
newList[currentIndex] = targetItem
|
||||
newList[targetIndex] = draggedItem
|
||||
|
||||
// 计算被交换项目需要移动的偏移量
|
||||
val swapOffset = Offset(
|
||||
(currentCol - targetCol) * itemSizePx,
|
||||
(currentRow - targetRow) * itemSizePx
|
||||
)
|
||||
|
||||
// 启动动画 - 使用更慢的动画参数
|
||||
scope.launch {
|
||||
val targetItemAnimatable = itemOffsets.getOrPut(targetItem.id) {
|
||||
Animatable(Offset.Zero, Offset.VectorConverter)
|
||||
}
|
||||
targetItemAnimatable.snapTo(swapOffset)
|
||||
targetItemAnimatable.animateTo(
|
||||
Offset.Zero,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessLow
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
mediaItems = newList
|
||||
onItemsReordered(newList)
|
||||
|
||||
// 更新拖拽索引,重置偏移量为 0
|
||||
// 从新位置重新开始计算,避免连续快速交换
|
||||
draggedIndex = targetIndex
|
||||
draggedItemOffset = Offset.Zero
|
||||
}
|
||||
}
|
||||
},
|
||||
onDragEnd = {
|
||||
draggedIndex = null
|
||||
draggedItemOffset = Offset.Zero
|
||||
},
|
||||
uploadProgress = progress
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReorderableMediaItem(
|
||||
item: MediaItemData,
|
||||
index: Int,
|
||||
isDragging: Boolean,
|
||||
dragOffset: Offset,
|
||||
animatedOffset: Offset,
|
||||
onRemove: (() -> Unit)?,
|
||||
onClick: (() -> Unit)?,
|
||||
onDragStart: () -> Unit,
|
||||
onDrag: (Offset) -> Unit,
|
||||
onDragEnd: () -> Unit,
|
||||
uploadProgress: Float? = null
|
||||
) {
|
||||
// 判断是否正在动画中
|
||||
val isAnimating = animatedOffset != Offset.Zero
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(90.dp)
|
||||
.graphicsLayer {
|
||||
// 使用 graphicsLayer 进行硬件加速的变换
|
||||
scaleX = if (isDragging) 1.05f else 1f
|
||||
scaleY = if (isDragging) 1.05f else 1f
|
||||
translationX = dragOffset.x + animatedOffset.x
|
||||
translationY = dragOffset.y + animatedOffset.y
|
||||
shadowElevation = if (isDragging) 8.dp.toPx() else 0f
|
||||
}
|
||||
.zIndex(
|
||||
when {
|
||||
isDragging -> 2f // 拖拽项最高层级
|
||||
isAnimating -> 1f // 动画项次高层级,避免被其他项遮挡
|
||||
else -> 0f // 普通项最低层级
|
||||
}
|
||||
)
|
||||
.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { onDragStart() },
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
onDrag(dragAmount)
|
||||
},
|
||||
onDragEnd = { onDragEnd() },
|
||||
onDragCancel = { onDragEnd() }
|
||||
)
|
||||
}
|
||||
.then(
|
||||
if (onClick != null && uploadProgress == null) {
|
||||
Modifier.clickable { onClick() }
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
) {
|
||||
// 图片/视频缩略图
|
||||
AsyncImage(
|
||||
model = when (item) {
|
||||
is MediaItemData.Image -> item.uri
|
||||
is MediaItemData.Video -> item.uri
|
||||
},
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(if (isDragging) Color.Gray.copy(alpha = 0.3f) else Color.Transparent),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
// 视频标识
|
||||
if (item is MediaItemData.Video) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.6f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PlayArrow,
|
||||
contentDescription = "视频",
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 上传进度遮罩
|
||||
if (uploadProgress != null && uploadProgress < 1f) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color.Black.copy(alpha = 0.5f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
progress = { uploadProgress },
|
||||
modifier = Modifier.size(36.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 3.dp,
|
||||
trackColor = Color.White.copy(alpha = 0.3f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除按钮(上传中不显示)
|
||||
if (onRemove != null && uploadProgress == null && !isDragging) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp)
|
||||
.size(22.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
.clickable { onRemove() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "移除",
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 上传完成标记
|
||||
if (uploadProgress != null && uploadProgress >= 1f) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp)
|
||||
.size(22.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Brand500),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = "完成",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 拖拽提示
|
||||
if (isDragging) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.White.copy(alpha = 0.2f))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,11 @@ import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -17,6 +20,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.MusicNote
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.outlined.Image
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.material.icons.outlined.MusicNote
|
||||
@@ -28,19 +32,30 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import coil.compose.AsyncImage
|
||||
import com.memory.app.data.api.ApiClient
|
||||
import com.memory.app.data.model.MusicInfo
|
||||
import com.memory.app.data.model.ParseMusicRequest
|
||||
import com.memory.app.data.model.User
|
||||
import com.memory.app.ui.components.MusicCard
|
||||
import com.memory.app.ui.components.VideoPlayer
|
||||
import com.memory.app.ui.components.ReorderableMediaGrid
|
||||
import com.memory.app.ui.components.MediaItemData
|
||||
import com.memory.app.ui.theme.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.sqrt
|
||||
|
||||
@Composable
|
||||
fun CreatePostScreen(
|
||||
@@ -202,35 +217,47 @@ fun CreatePostScreen(
|
||||
)
|
||||
)
|
||||
|
||||
// Selected Images - 九宫格布局
|
||||
val allMedia = selectedImages.map { MediaItemType.Image(it) } + selectedVideos.map { MediaItemType.Video(it) }
|
||||
// Selected Images - 九宫格布局(支持拖拽排序)
|
||||
val allMedia = remember(selectedImages, selectedVideos) {
|
||||
selectedImages.map { MediaItemData.Image(it) } +
|
||||
selectedVideos.map { MediaItemData.Video(it) }
|
||||
}
|
||||
var previewMediaItem by remember { mutableStateOf<MediaItemData?>(null) }
|
||||
|
||||
if (allMedia.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
val columns = if (allMedia.size <= 3) allMedia.size else 3
|
||||
val rows = (allMedia.size + columns - 1) / columns
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
for (row in 0 until rows) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
for (col in 0 until columns) {
|
||||
val index = row * columns + col
|
||||
if (index < allMedia.size) {
|
||||
val item = allMedia[index]
|
||||
val progress = uploadProgress[index]
|
||||
SelectedMediaItem(
|
||||
item = item,
|
||||
onRemove = if (!isLoading) {{
|
||||
when (item) {
|
||||
is MediaItemType.Image -> selectedImages = selectedImages - item.uri
|
||||
is MediaItemType.Video -> selectedVideos = selectedVideos - item.uri
|
||||
}
|
||||
}} else null,
|
||||
uploadProgress = progress
|
||||
)
|
||||
}
|
||||
ReorderableMediaGrid(
|
||||
items = allMedia,
|
||||
onItemsReordered = { reorderedItems ->
|
||||
val newImages = mutableListOf<Uri>()
|
||||
val newVideos = mutableListOf<Uri>()
|
||||
reorderedItems.forEach { item ->
|
||||
when (item) {
|
||||
is MediaItemData.Image -> newImages.add(item.uri)
|
||||
is MediaItemData.Video -> newVideos.add(item.uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
selectedImages = newImages
|
||||
selectedVideos = newVideos
|
||||
},
|
||||
onRemove = if (!isLoading) { item ->
|
||||
when (item) {
|
||||
is MediaItemData.Image -> selectedImages = selectedImages - item.uri
|
||||
is MediaItemData.Video -> selectedVideos = selectedVideos - item.uri
|
||||
}
|
||||
} else null,
|
||||
onClick = { item -> previewMediaItem = item },
|
||||
uploadProgress = uploadProgress,
|
||||
isLoading = isLoading
|
||||
)
|
||||
|
||||
// 预览 Dialog
|
||||
previewMediaItem?.let { item ->
|
||||
MediaPreviewDialog(
|
||||
item = item,
|
||||
onDismiss = { previewMediaItem = null }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,122 +494,6 @@ fun CreatePostScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// 媒体项类型
|
||||
private sealed class MediaItemType {
|
||||
data class Image(val uri: Uri) : MediaItemType()
|
||||
data class Video(val uri: Uri) : MediaItemType()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectedMediaItem(
|
||||
item: MediaItemType,
|
||||
onRemove: (() -> Unit)?,
|
||||
uploadProgress: Float? = null
|
||||
) {
|
||||
Box {
|
||||
AsyncImage(
|
||||
model = when (item) {
|
||||
is MediaItemType.Image -> item.uri
|
||||
is MediaItemType.Video -> item.uri
|
||||
},
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(90.dp)
|
||||
.clip(RoundedCornerShape(10.dp)),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
// 视频标识
|
||||
if (item is MediaItemType.Video) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(6.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(Color.Black.copy(alpha = 0.7f))
|
||||
.padding(horizontal = 6.dp, vertical = 3.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Videocam,
|
||||
contentDescription = "视频",
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
androidx.compose.material3.Text(
|
||||
text = "视频",
|
||||
fontSize = 10.sp,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 上传进度遮罩
|
||||
if (uploadProgress != null && uploadProgress < 1f) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(90.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color.Black.copy(alpha = 0.5f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
progress = { uploadProgress },
|
||||
modifier = Modifier.size(36.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 3.dp,
|
||||
trackColor = Color.White.copy(alpha = 0.3f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除按钮(上传中不显示)
|
||||
if (onRemove != null && uploadProgress == null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp)
|
||||
.size(22.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
.clickable { onRemove() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "移除",
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 上传完成标记
|
||||
if (uploadProgress != null && uploadProgress >= 1f) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp)
|
||||
.size(22.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Brand500),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = "完成",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreatePostEmojiPicker(
|
||||
onDismiss: () -> Unit,
|
||||
@@ -821,3 +732,190 @@ private fun MusicLinkInputDialog(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun MediaPreviewDialog(
|
||||
item: MediaItemData,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
decorFitsSystemWindows = false
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (item) {
|
||||
is MediaItemData.Image -> {
|
||||
ZoomablePreviewImage(
|
||||
imageUri = item.uri,
|
||||
onTap = onDismiss
|
||||
)
|
||||
}
|
||||
is MediaItemData.Video -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable { onDismiss() }
|
||||
) {
|
||||
VideoPlayer(
|
||||
videoUrl = item.uri.toString(),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭按钮
|
||||
IconButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(16.dp)
|
||||
.statusBarsPadding()
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "关闭",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ZoomablePreviewImage(
|
||||
imageUri: Uri,
|
||||
onTap: () -> Unit
|
||||
) {
|
||||
var scale by remember { mutableFloatStateOf(1f) }
|
||||
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||
var offsetY by remember { mutableFloatStateOf(0f) }
|
||||
var lastTapTime by remember { mutableLongStateOf(0L) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val firstDown = awaitFirstDown(requireUnconsumed = false)
|
||||
var isTap = true
|
||||
var isMultiTouch = false
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val changes = event.changes
|
||||
|
||||
// 多指缩放
|
||||
if (changes.size > 1) {
|
||||
isMultiTouch = true
|
||||
isTap = false
|
||||
|
||||
val zoom = calculateZoom(changes)
|
||||
if (zoom != 1f) {
|
||||
val newScale = (scale * zoom).coerceIn(1f, 4f)
|
||||
scale = newScale
|
||||
changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
|
||||
// 缩放状态下的拖动
|
||||
if (changes.size == 1 && scale > 1f) {
|
||||
val change = changes.first()
|
||||
val dragAmount = change.position - change.previousPosition
|
||||
if (dragAmount.getDistance() > 2f) {
|
||||
isTap = false
|
||||
offsetX += dragAmount.x
|
||||
offsetY += dragAmount.y
|
||||
val maxOffsetX = (scale - 1f) * size.width / 2
|
||||
val maxOffsetY = (scale - 1f) * size.height / 2
|
||||
offsetX = offsetX.coerceIn(-maxOffsetX, maxOffsetX)
|
||||
offsetY = offsetY.coerceIn(-maxOffsetY, maxOffsetY)
|
||||
change.consume()
|
||||
}
|
||||
}
|
||||
|
||||
// 所有手指抬起
|
||||
if (changes.all { !it.pressed }) {
|
||||
if (isTap && !isMultiTouch) {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val timeSinceLastTap = currentTime - lastTapTime
|
||||
|
||||
if (timeSinceLastTap < 300) {
|
||||
// 双击切换缩放
|
||||
if (scale > 1f) {
|
||||
scale = 1f
|
||||
offsetX = 0f
|
||||
offsetY = 0f
|
||||
} else {
|
||||
scale = 2.5f
|
||||
}
|
||||
lastTapTime = 0L
|
||||
} else {
|
||||
lastTapTime = currentTime
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// 检查是否移动太多
|
||||
if (changes.size == 1 && !isMultiTouch && scale <= 1f) {
|
||||
val change = changes.first()
|
||||
val moved = (change.position - change.previousPosition).getDistance()
|
||||
if (moved > 10f) {
|
||||
isTap = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// 延迟处理单击
|
||||
LaunchedEffect(lastTapTime) {
|
||||
if (lastTapTime > 0) {
|
||||
kotlinx.coroutines.delay(300)
|
||||
if (System.currentTimeMillis() - lastTapTime >= 300) {
|
||||
if (scale > 1f) {
|
||||
scale = 1f
|
||||
offsetX = 0f
|
||||
offsetY = 0f
|
||||
} else {
|
||||
onTap()
|
||||
}
|
||||
lastTapTime = 0L
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model = imageUri,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer(
|
||||
scaleX = scale,
|
||||
scaleY = scale,
|
||||
translationX = offsetX,
|
||||
translationY = offsetY
|
||||
),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateZoom(changes: List<androidx.compose.ui.input.pointer.PointerInputChange>): Float {
|
||||
if (changes.size < 2) return 1f
|
||||
val current = (changes[0].position - changes[1].position).getDistance()
|
||||
val previous = (changes[0].previousPosition - changes[1].previousPosition).getDistance()
|
||||
return if (previous > 0f) current / previous else 1f
|
||||
}
|
||||
|
||||
@@ -5,8 +5,17 @@ import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.AnimationVector2D
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -17,6 +26,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.MusicNote
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.outlined.Image
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.material.icons.outlined.Mood
|
||||
@@ -28,17 +38,27 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.zIndex
|
||||
import coil.compose.AsyncImage
|
||||
import com.memory.app.data.api.ApiClient
|
||||
import com.memory.app.data.model.MusicInfo
|
||||
import com.memory.app.data.model.ParseMusicRequest
|
||||
import com.memory.app.data.model.Post
|
||||
import com.memory.app.ui.components.MusicCard
|
||||
import com.memory.app.ui.components.VideoPlayer
|
||||
import com.memory.app.ui.theme.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -202,48 +222,56 @@ fun EditPostScreen(
|
||||
)
|
||||
)
|
||||
|
||||
// Images - 合并已有图片和新图片,九宫格布局
|
||||
val allImages = existingImages.map { (url, isVideo) -> ImageItem.Existing(url, isVideo) } +
|
||||
newImages.map { ImageItem.NewImage(it) } +
|
||||
newVideos.map { ImageItem.NewVideo(it) }
|
||||
if (allImages.isNotEmpty()) {
|
||||
// Images - 合并已有图片和新图片,九宫格布局(支持拖拽排序)
|
||||
val allMediaItems = remember(existingImages, newImages, newVideos) {
|
||||
existingImages.map { (url, isVideo) ->
|
||||
if (isVideo) EditMediaItem.ExistingVideo(url) else EditMediaItem.ExistingImage(url)
|
||||
} + newImages.map { EditMediaItem.NewImage(it) } +
|
||||
newVideos.map { EditMediaItem.NewVideo(it) }
|
||||
}
|
||||
var previewImageItem by remember { mutableStateOf<EditMediaItem?>(null) }
|
||||
|
||||
if (allMediaItems.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
val columns = if (allImages.size <= 3) allImages.size else 3
|
||||
val rows = (allImages.size + columns - 1) / columns
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
for (row in 0 until rows) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
for (col in 0 until columns) {
|
||||
val index = row * columns + col
|
||||
if (index < allImages.size) {
|
||||
val item = allImages[index]
|
||||
val progress = when (item) {
|
||||
is ImageItem.NewImage -> {
|
||||
val newImageIndex = newImages.indexOf(item.uri)
|
||||
if (newImageIndex >= 0) uploadProgress[existingImages.size + newImageIndex] else null
|
||||
}
|
||||
is ImageItem.NewVideo -> {
|
||||
val newVideoIndex = newVideos.indexOf(item.uri)
|
||||
if (newVideoIndex >= 0) uploadProgress[existingImages.size + newImages.size + newVideoIndex] else null
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
EditImageItem(
|
||||
item = item,
|
||||
onRemove = if (!isLoading) {{
|
||||
when (item) {
|
||||
is ImageItem.Existing -> existingImages = existingImages.filter { it.first != item.url }
|
||||
is ImageItem.NewImage -> newImages = newImages - item.uri
|
||||
is ImageItem.NewVideo -> newVideos = newVideos - item.uri
|
||||
}
|
||||
}} else null,
|
||||
uploadProgress = progress
|
||||
)
|
||||
}
|
||||
EditReorderableMediaGrid(
|
||||
items = allMediaItems,
|
||||
onItemsReordered = { reorderedItems ->
|
||||
val newExisting = mutableListOf<Pair<String, Boolean>>()
|
||||
val newImagesList = mutableListOf<Uri>()
|
||||
val newVideosList = mutableListOf<Uri>()
|
||||
|
||||
reorderedItems.forEach { item ->
|
||||
when (item) {
|
||||
is EditMediaItem.ExistingImage -> newExisting.add(item.url to false)
|
||||
is EditMediaItem.ExistingVideo -> newExisting.add(item.url to true)
|
||||
is EditMediaItem.NewImage -> newImagesList.add(item.uri)
|
||||
is EditMediaItem.NewVideo -> newVideosList.add(item.uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
existingImages = newExisting
|
||||
newImages = newImagesList
|
||||
newVideos = newVideosList
|
||||
},
|
||||
onRemove = if (!isLoading) { item ->
|
||||
when (item) {
|
||||
is EditMediaItem.ExistingImage -> existingImages = existingImages.filter { it.first != item.url }
|
||||
is EditMediaItem.ExistingVideo -> existingImages = existingImages.filter { it.first != item.url }
|
||||
is EditMediaItem.NewImage -> newImages = newImages - item.uri
|
||||
is EditMediaItem.NewVideo -> newVideos = newVideos - item.uri
|
||||
}
|
||||
} else null,
|
||||
onClick = { item -> previewImageItem = item },
|
||||
uploadProgress = uploadProgress,
|
||||
isLoading = isLoading
|
||||
)
|
||||
|
||||
// 预览 Dialog
|
||||
previewImageItem?.let { item ->
|
||||
EditMediaPreviewDialog(
|
||||
item = item,
|
||||
onDismiss = { previewImageItem = null }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,71 +482,256 @@ fun EditPostScreen(
|
||||
}
|
||||
|
||||
// 图片项类型
|
||||
private sealed class ImageItem {
|
||||
data class Existing(val url: String, val isVideo: Boolean) : ImageItem()
|
||||
data class NewImage(val uri: Uri) : ImageItem()
|
||||
data class NewVideo(val uri: Uri) : ImageItem()
|
||||
private sealed class EditMediaItem {
|
||||
abstract val id: String
|
||||
|
||||
data class ExistingImage(val url: String, override val id: String = url) : EditMediaItem()
|
||||
data class ExistingVideo(val url: String, override val id: String = url) : EditMediaItem()
|
||||
data class NewImage(val uri: Uri, override val id: String = uri.toString()) : EditMediaItem()
|
||||
data class NewVideo(val uri: Uri, override val id: String = uri.toString()) : EditMediaItem()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun EditImageItem(
|
||||
item: ImageItem,
|
||||
onRemove: (() -> Unit)?,
|
||||
uploadProgress: Float? = null
|
||||
private fun EditReorderableMediaGrid(
|
||||
items: List<EditMediaItem>,
|
||||
onItemsReordered: (List<EditMediaItem>) -> Unit,
|
||||
onRemove: ((EditMediaItem) -> Unit)? = null,
|
||||
onClick: ((EditMediaItem) -> Unit)? = null,
|
||||
uploadProgress: Map<Int, Float> = emptyMap(),
|
||||
isLoading: Boolean = false
|
||||
) {
|
||||
Box {
|
||||
AsyncImage(
|
||||
model = when (item) {
|
||||
is ImageItem.Existing -> item.url
|
||||
is ImageItem.NewImage -> item.uri
|
||||
is ImageItem.NewVideo -> item.uri
|
||||
},
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(90.dp)
|
||||
.clip(RoundedCornerShape(10.dp)),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
// 视频标识
|
||||
val isVideo = when (item) {
|
||||
is ImageItem.Existing -> item.isVideo
|
||||
is ImageItem.NewVideo -> true
|
||||
else -> false
|
||||
var mediaItems by remember(items) { mutableStateOf(items) }
|
||||
var draggedIndex by remember { mutableStateOf<Int?>(null) }
|
||||
var draggedItemOffset by remember { mutableStateOf(Offset.Zero) }
|
||||
|
||||
// 记录每个项目的目标位置偏移(用于动画)
|
||||
val itemOffsets = remember { mutableStateMapOf<String, Animatable<Offset, AnimationVector2D>>() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(items) {
|
||||
if (items != mediaItems) {
|
||||
mediaItems = items
|
||||
}
|
||||
if (isVideo) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(6.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(Color.Black.copy(alpha = 0.7f))
|
||||
.padding(horizontal = 6.dp, vertical = 3.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Videocam,
|
||||
contentDescription = "视频",
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
Text(
|
||||
text = "视频",
|
||||
fontSize = 10.sp,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
if (mediaItems.isEmpty()) return
|
||||
|
||||
val columns = if (mediaItems.size <= 3) mediaItems.size else 3
|
||||
val rows = (mediaItems.size + columns - 1) / columns
|
||||
val itemSize = 90.dp
|
||||
val spacing = 8.dp
|
||||
val itemSizePx = itemSize.value + spacing.value
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(spacing)) {
|
||||
for (row in 0 until rows) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(spacing)) {
|
||||
for (col in 0 until columns) {
|
||||
val index = row * columns + col
|
||||
if (index < mediaItems.size) {
|
||||
val item = mediaItems[index]
|
||||
val progress = uploadProgress[index]
|
||||
val isDragging = draggedIndex == index
|
||||
|
||||
// 为每个项目创建动画偏移
|
||||
val animatedOffset = itemOffsets.getOrPut(item.id) {
|
||||
Animatable(Offset.Zero, Offset.VectorConverter)
|
||||
}
|
||||
|
||||
EditReorderableMediaItem(
|
||||
item = item,
|
||||
index = index,
|
||||
isDragging = isDragging,
|
||||
dragOffset = if (isDragging) draggedItemOffset else Offset.Zero,
|
||||
animatedOffset = if (!isDragging) animatedOffset.value else Offset.Zero,
|
||||
onRemove = if (!isLoading && onRemove != null) {
|
||||
{ onRemove(item) }
|
||||
} else null,
|
||||
onClick = if (onClick != null) {
|
||||
{ onClick(item) }
|
||||
} else null,
|
||||
onDragStart = {
|
||||
if (!isLoading) {
|
||||
draggedIndex = index
|
||||
draggedItemOffset = Offset.Zero
|
||||
}
|
||||
},
|
||||
onDrag = { offset ->
|
||||
val currentIndex = draggedIndex ?: return@EditReorderableMediaItem
|
||||
draggedItemOffset += offset
|
||||
|
||||
// 计算拖拽项当前的行列位置
|
||||
val currentRow = currentIndex / columns
|
||||
val currentCol = currentIndex % columns
|
||||
|
||||
// 直接基于偏移量判断是否应该交换,而不是先计算 targetCol
|
||||
// 这样可以避免刚越过边界就触发交换的问题
|
||||
|
||||
// 计算应该交换到哪个方向
|
||||
val colOffset = when {
|
||||
draggedItemOffset.x > itemSizePx * 0.7f -> 1 // 向右超过 70%
|
||||
draggedItemOffset.x < -itemSizePx * 0.7f -> -1 // 向左超过 70%
|
||||
else -> 0
|
||||
}
|
||||
|
||||
val rowOffset = when {
|
||||
draggedItemOffset.y > itemSizePx * 0.7f -> 1 // 向下超过 70%
|
||||
draggedItemOffset.y < -itemSizePx * 0.7f -> -1 // 向上超过 70%
|
||||
else -> 0
|
||||
}
|
||||
|
||||
// 只有在超过阈值时才计算目标位置
|
||||
if (colOffset != 0 || rowOffset != 0) {
|
||||
val targetCol = currentCol + colOffset
|
||||
val targetRow = currentRow + rowOffset
|
||||
val targetIndex = targetRow * columns + targetCol
|
||||
|
||||
// 检查目标索引是否有效
|
||||
if (targetIndex in mediaItems.indices && targetIndex != currentIndex) {
|
||||
// 交换位置
|
||||
val newList = mediaItems.toMutableList()
|
||||
val draggedItem = newList[currentIndex]
|
||||
val targetItem = newList[targetIndex]
|
||||
|
||||
newList[currentIndex] = targetItem
|
||||
newList[targetIndex] = draggedItem
|
||||
|
||||
// 计算被交换项目需要移动的偏移量
|
||||
val swapOffset = Offset(
|
||||
(currentCol - targetCol) * itemSizePx,
|
||||
(currentRow - targetRow) * itemSizePx
|
||||
)
|
||||
|
||||
// 启动动画 - 使用更慢的动画参数
|
||||
scope.launch {
|
||||
val targetItemAnimatable = itemOffsets.getOrPut(targetItem.id) {
|
||||
Animatable(Offset.Zero, Offset.VectorConverter)
|
||||
}
|
||||
targetItemAnimatable.snapTo(swapOffset)
|
||||
targetItemAnimatable.animateTo(
|
||||
Offset.Zero,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessLow
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
mediaItems = newList
|
||||
onItemsReordered(newList)
|
||||
|
||||
// 更新拖拽索引,重置偏移量为 0
|
||||
// 从新位置重新开始计算,避免连续快速交换
|
||||
draggedIndex = targetIndex
|
||||
draggedItemOffset = Offset.Zero
|
||||
}
|
||||
}
|
||||
},
|
||||
onDragEnd = {
|
||||
draggedIndex = null
|
||||
draggedItemOffset = Offset.Zero
|
||||
},
|
||||
uploadProgress = progress
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditReorderableMediaItem(
|
||||
item: EditMediaItem,
|
||||
index: Int,
|
||||
isDragging: Boolean,
|
||||
dragOffset: Offset,
|
||||
animatedOffset: Offset,
|
||||
onRemove: (() -> Unit)?,
|
||||
onClick: (() -> Unit)?,
|
||||
onDragStart: () -> Unit,
|
||||
onDrag: (Offset) -> Unit,
|
||||
onDragEnd: () -> Unit,
|
||||
uploadProgress: Float? = null
|
||||
) {
|
||||
// 判断是否正在动画中
|
||||
val isAnimating = animatedOffset != Offset.Zero
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(90.dp)
|
||||
.graphicsLayer {
|
||||
// 使用 graphicsLayer 进行硬件加速的变换
|
||||
scaleX = if (isDragging) 1.05f else 1f
|
||||
scaleY = if (isDragging) 1.05f else 1f
|
||||
translationX = dragOffset.x + animatedOffset.x
|
||||
translationY = dragOffset.y + animatedOffset.y
|
||||
shadowElevation = if (isDragging) 8.dp.toPx() else 0f
|
||||
}
|
||||
.zIndex(
|
||||
when {
|
||||
isDragging -> 2f // 拖拽项最高层级
|
||||
isAnimating -> 1f // 动画项次高层级,避免被其他项遮挡
|
||||
else -> 0f // 普通项最低层级
|
||||
}
|
||||
)
|
||||
.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { onDragStart() },
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
onDrag(dragAmount)
|
||||
},
|
||||
onDragEnd = { onDragEnd() },
|
||||
onDragCancel = { onDragEnd() }
|
||||
)
|
||||
}
|
||||
.then(
|
||||
if (onClick != null && uploadProgress == null) {
|
||||
Modifier.clickable { onClick() }
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = when (item) {
|
||||
is EditMediaItem.ExistingImage -> item.url
|
||||
is EditMediaItem.ExistingVideo -> item.url
|
||||
is EditMediaItem.NewImage -> item.uri
|
||||
is EditMediaItem.NewVideo -> item.uri
|
||||
},
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(if (isDragging) Color.Gray.copy(alpha = 0.3f) else Color.Transparent),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
||||
val isVideo = item is EditMediaItem.ExistingVideo || item is EditMediaItem.NewVideo
|
||||
if (isVideo) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.6f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PlayArrow,
|
||||
contentDescription = "视频",
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 上传进度遮罩
|
||||
if (uploadProgress != null && uploadProgress < 1f) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(90.dp)
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color.Black.copy(alpha = 0.5f)),
|
||||
contentAlignment = Alignment.Center
|
||||
@@ -533,8 +746,7 @@ private fun EditImageItem(
|
||||
}
|
||||
}
|
||||
|
||||
// 删除按钮(上传中不显示)
|
||||
if (onRemove != null && uploadProgress == null) {
|
||||
if (onRemove != null && uploadProgress == null && !isDragging) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
@@ -554,7 +766,6 @@ private fun EditImageItem(
|
||||
}
|
||||
}
|
||||
|
||||
// 上传完成标记
|
||||
if (uploadProgress != null && uploadProgress >= 1f) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -573,9 +784,24 @@ private fun EditImageItem(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isDragging) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.White.copy(alpha = 0.2f))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 图片项类型(旧的,保留用于预览)
|
||||
private sealed class ImageItem {
|
||||
data class Existing(val url: String, val isVideo: Boolean) : ImageItem()
|
||||
data class NewImage(val uri: Uri) : ImageItem()
|
||||
data class NewVideo(val uri: Uri) : ImageItem()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditPostEmojiPicker(
|
||||
onDismiss: () -> Unit,
|
||||
@@ -812,3 +1038,317 @@ private fun EditMusicLinkInputDialog(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun EditMediaPreviewDialog(
|
||||
item: EditMediaItem,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
decorFitsSystemWindows = false
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (item) {
|
||||
is EditMediaItem.ExistingImage -> {
|
||||
ZoomableEditPreviewImage(
|
||||
imageUrl = item.url,
|
||||
onTap = onDismiss
|
||||
)
|
||||
}
|
||||
is EditMediaItem.ExistingVideo -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable { onDismiss() }
|
||||
) {
|
||||
VideoPlayer(
|
||||
videoUrl = item.url,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
is EditMediaItem.NewImage -> {
|
||||
ZoomableEditPreviewImageUri(
|
||||
imageUri = item.uri,
|
||||
onTap = onDismiss
|
||||
)
|
||||
}
|
||||
is EditMediaItem.NewVideo -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable { onDismiss() }
|
||||
) {
|
||||
VideoPlayer(
|
||||
videoUrl = item.uri.toString(),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭按钮
|
||||
IconButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(16.dp)
|
||||
.statusBarsPadding()
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "关闭",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ZoomableEditPreviewImage(
|
||||
imageUrl: String,
|
||||
onTap: () -> Unit
|
||||
) {
|
||||
var scale by remember { mutableFloatStateOf(1f) }
|
||||
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||
var offsetY by remember { mutableFloatStateOf(0f) }
|
||||
var lastTapTime by remember { mutableLongStateOf(0L) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val firstDown = awaitFirstDown(requireUnconsumed = false)
|
||||
var isTap = true
|
||||
var isMultiTouch = false
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val changes = event.changes
|
||||
|
||||
if (changes.size > 1) {
|
||||
isMultiTouch = true
|
||||
isTap = false
|
||||
|
||||
val zoom = calculateEditZoom(changes)
|
||||
if (zoom != 1f) {
|
||||
val newScale = (scale * zoom).coerceIn(1f, 4f)
|
||||
scale = newScale
|
||||
changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
|
||||
if (changes.size == 1 && scale > 1f) {
|
||||
val change = changes.first()
|
||||
val dragAmount = change.position - change.previousPosition
|
||||
if (dragAmount.getDistance() > 2f) {
|
||||
isTap = false
|
||||
offsetX += dragAmount.x
|
||||
offsetY += dragAmount.y
|
||||
val maxOffsetX = (scale - 1f) * size.width / 2
|
||||
val maxOffsetY = (scale - 1f) * size.height / 2
|
||||
offsetX = offsetX.coerceIn(-maxOffsetX, maxOffsetX)
|
||||
offsetY = offsetY.coerceIn(-maxOffsetY, maxOffsetY)
|
||||
change.consume()
|
||||
}
|
||||
}
|
||||
|
||||
if (changes.all { !it.pressed }) {
|
||||
if (isTap && !isMultiTouch) {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val timeSinceLastTap = currentTime - lastTapTime
|
||||
|
||||
if (timeSinceLastTap < 300) {
|
||||
if (scale > 1f) {
|
||||
scale = 1f
|
||||
offsetX = 0f
|
||||
offsetY = 0f
|
||||
} else {
|
||||
scale = 2.5f
|
||||
}
|
||||
lastTapTime = 0L
|
||||
} else {
|
||||
lastTapTime = currentTime
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if (changes.size == 1 && !isMultiTouch && scale <= 1f) {
|
||||
val change = changes.first()
|
||||
val moved = (change.position - change.previousPosition).getDistance()
|
||||
if (moved > 10f) {
|
||||
isTap = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
LaunchedEffect(lastTapTime) {
|
||||
if (lastTapTime > 0) {
|
||||
kotlinx.coroutines.delay(300)
|
||||
if (System.currentTimeMillis() - lastTapTime >= 300) {
|
||||
if (scale > 1f) {
|
||||
scale = 1f
|
||||
offsetX = 0f
|
||||
offsetY = 0f
|
||||
} else {
|
||||
onTap()
|
||||
}
|
||||
lastTapTime = 0L
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer(
|
||||
scaleX = scale,
|
||||
scaleY = scale,
|
||||
translationX = offsetX,
|
||||
translationY = offsetY
|
||||
),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ZoomableEditPreviewImageUri(
|
||||
imageUri: Uri,
|
||||
onTap: () -> Unit
|
||||
) {
|
||||
var scale by remember { mutableFloatStateOf(1f) }
|
||||
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||
var offsetY by remember { mutableFloatStateOf(0f) }
|
||||
var lastTapTime by remember { mutableLongStateOf(0L) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val firstDown = awaitFirstDown(requireUnconsumed = false)
|
||||
var isTap = true
|
||||
var isMultiTouch = false
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val changes = event.changes
|
||||
|
||||
if (changes.size > 1) {
|
||||
isMultiTouch = true
|
||||
isTap = false
|
||||
|
||||
val zoom = calculateEditZoom(changes)
|
||||
if (zoom != 1f) {
|
||||
val newScale = (scale * zoom).coerceIn(1f, 4f)
|
||||
scale = newScale
|
||||
changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
|
||||
if (changes.size == 1 && scale > 1f) {
|
||||
val change = changes.first()
|
||||
val dragAmount = change.position - change.previousPosition
|
||||
if (dragAmount.getDistance() > 2f) {
|
||||
isTap = false
|
||||
offsetX += dragAmount.x
|
||||
offsetY += dragAmount.y
|
||||
val maxOffsetX = (scale - 1f) * size.width / 2
|
||||
val maxOffsetY = (scale - 1f) * size.height / 2
|
||||
offsetX = offsetX.coerceIn(-maxOffsetX, maxOffsetX)
|
||||
offsetY = offsetY.coerceIn(-maxOffsetY, maxOffsetY)
|
||||
change.consume()
|
||||
}
|
||||
}
|
||||
|
||||
if (changes.all { !it.pressed }) {
|
||||
if (isTap && !isMultiTouch) {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val timeSinceLastTap = currentTime - lastTapTime
|
||||
|
||||
if (timeSinceLastTap < 300) {
|
||||
if (scale > 1f) {
|
||||
scale = 1f
|
||||
offsetX = 0f
|
||||
offsetY = 0f
|
||||
} else {
|
||||
scale = 2.5f
|
||||
}
|
||||
lastTapTime = 0L
|
||||
} else {
|
||||
lastTapTime = currentTime
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if (changes.size == 1 && !isMultiTouch && scale <= 1f) {
|
||||
val change = changes.first()
|
||||
val moved = (change.position - change.previousPosition).getDistance()
|
||||
if (moved > 10f) {
|
||||
isTap = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
LaunchedEffect(lastTapTime) {
|
||||
if (lastTapTime > 0) {
|
||||
kotlinx.coroutines.delay(300)
|
||||
if (System.currentTimeMillis() - lastTapTime >= 300) {
|
||||
if (scale > 1f) {
|
||||
scale = 1f
|
||||
offsetX = 0f
|
||||
offsetY = 0f
|
||||
} else {
|
||||
onTap()
|
||||
}
|
||||
lastTapTime = 0L
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model = imageUri,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer(
|
||||
scaleX = scale,
|
||||
scaleY = scale,
|
||||
translationX = offsetX,
|
||||
translationY = offsetY
|
||||
),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateEditZoom(changes: List<PointerInputChange>): Float {
|
||||
if (changes.size < 2) return 1f
|
||||
val current = (changes[0].position - changes[1].position).getDistance()
|
||||
val previous = (changes[0].previousPosition - changes[1].previousPosition).getDistance()
|
||||
return if (previous > 0f) current / previous else 1f
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user