九宫格图片或者视频支持拖拽调整顺序

This commit is contained in:
amos wong
2026-03-08 22:23:50 +08:00
parent ed615a1f94
commit e6726d2dea
4 changed files with 1233 additions and 237 deletions

View File

@@ -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 {

View File

@@ -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))
)
}
}
}

View File

@@ -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
}

View File

@@ -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
}