引用现成的库实现九宫格拖拽排序功能

This commit is contained in:
amos wong
2026-03-12 20:52:34 +08:00
parent e6726d2dea
commit 28e08cb72c
3 changed files with 102 additions and 333 deletions

View File

@@ -13,11 +13,11 @@ android {
applicationId = "com.memory.app"
minSdk = 26
targetSdk = 35
versionCode = 76
versionName = "1.9.2"
versionCode = 87
versionName = "2.0.3"
buildConfigField("String", "API_BASE_URL", "\"https://x.amos.us.kg/api/\"")
buildConfigField("int", "VERSION_CODE", "76")
buildConfigField("int", "VERSION_CODE", "87")
}
signingConfigs {
@@ -91,6 +91,9 @@ dependencies {
// DataStore
implementation("androidx.datastore:datastore-preferences:1.1.1")
// Reorderable - 拖拽排序
implementation("sh.calvin.reorderable:reorderable:2.4.0")
// Debug
debugImplementation("androidx.compose.ui:ui-tooling")
}

View File

@@ -1,16 +1,13 @@
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.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -22,21 +19,15 @@ 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
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyGridState
sealed class MediaItemData {
abstract val id: String
@@ -45,7 +36,6 @@ sealed class MediaItemData {
data class Video(val uri: Uri, override val id: String = uri.toString()) : MediaItemData()
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ReorderableMediaGrid(
items: List<MediaItemData>,
@@ -57,12 +47,6 @@ fun ReorderableMediaGrid(
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) {
@@ -74,177 +58,68 @@ fun ReorderableMediaGrid(
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)
val lazyGridState = rememberLazyGridState()
val reorderableState = rememberReorderableLazyGridState(lazyGridState) { from, to ->
mediaItems = mediaItems.toMutableList().apply {
add(to.index, removeAt(from.index))
}
onItemsReordered(mediaItems)
}
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
state = lazyGridState,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier
.heightIn(max = 300.dp)
) {
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
items(mediaItems, key = { it.id }) { item ->
ReorderableItem(reorderableState, key = item.id) { isDragging ->
val index = mediaItems.indexOf(item)
val progress = uploadProgress[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
MediaItemCard(
item = item,
isDragging = isDragging,
onRemove = if (!isLoading && onRemove != null) {
{ onRemove(item) }
} else null,
onClick = if (onClick != null) {
{ onClick(item) }
} else null,
uploadProgress = progress,
modifier = Modifier
.aspectRatio(1f)
.longPressDraggableHandle(
enabled = !isLoading,
onDragStarted = {},
onDragStopped = {}
)
}
}
)
}
}
}
}
@Composable
private fun ReorderableMediaItem(
private fun MediaItemCard(
item: MediaItemData,
index: Int,
isDragging: Boolean,
dragOffset: Offset,
animatedOffset: Offset,
onRemove: (() -> Unit)?,
onClick: (() -> Unit)?,
onDragStart: () -> Unit,
onDrag: (Offset) -> Unit,
onDragEnd: () -> Unit,
uploadProgress: Float? = null
uploadProgress: Float?,
modifier: Modifier = Modifier
) {
// 判断是否正在动画中
val isAnimating = animatedOffset != Offset.Zero
Box(
modifier = Modifier
.size(90.dp)
modifier = modifier
.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() }
)
}
.zIndex(if (isDragging) 1f else 0f)
.then(
if (onClick != null && uploadProgress == null) {
Modifier.clickable { onClick() }

View File

@@ -15,8 +15,11 @@ 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.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -61,6 +64,8 @@ import com.memory.app.ui.components.MusicCard
import com.memory.app.ui.components.VideoPlayer
import com.memory.app.ui.theme.*
import kotlinx.coroutines.launch
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyGridState
@Composable
fun EditPostScreen(
@@ -491,7 +496,6 @@ private sealed class EditMediaItem {
data class NewVideo(val uri: Uri, override val id: String = uri.toString()) : EditMediaItem()
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun EditReorderableMediaGrid(
items: List<EditMediaItem>,
@@ -502,12 +506,6 @@ private fun EditReorderableMediaGrid(
isLoading: Boolean = 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) {
@@ -518,174 +516,67 @@ private fun EditReorderableMediaGrid(
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 lazyGridState = rememberLazyGridState()
val reorderableState = rememberReorderableLazyGridState(lazyGridState) { from, to ->
mediaItems = mediaItems.toMutableList().apply {
add(to.index, removeAt(from.index))
}
onItemsReordered(mediaItems)
}
// 为每个项目创建动画偏移
val animatedOffset = itemOffsets.getOrPut(item.id) {
Animatable(Offset.Zero, Offset.VectorConverter)
}
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
state = lazyGridState,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.heightIn(max = 300.dp)
) {
items(mediaItems, key = { it.id }) { item ->
ReorderableItem(reorderableState, key = item.id) { isDragging ->
val index = mediaItems.indexOf(item)
val progress = uploadProgress[index]
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
EditMediaItemCard(
item = item,
isDragging = isDragging,
onRemove = if (!isLoading && onRemove != null) {
{ onRemove(item) }
} else null,
onClick = if (onClick != null) {
{ onClick(item) }
} else null,
uploadProgress = progress,
modifier = Modifier
.aspectRatio(1f)
.longPressDraggableHandle(
enabled = !isLoading,
onDragStarted = {},
onDragStopped = {}
)
}
}
)
}
}
}
}
@Composable
private fun EditReorderableMediaItem(
private fun EditMediaItemCard(
item: EditMediaItem,
index: Int,
isDragging: Boolean,
dragOffset: Offset,
animatedOffset: Offset,
onRemove: (() -> Unit)?,
onClick: (() -> Unit)?,
onDragStart: () -> Unit,
onDrag: (Offset) -> Unit,
onDragEnd: () -> Unit,
uploadProgress: Float? = null
uploadProgress: Float?,
modifier: Modifier = Modifier
) {
// 判断是否正在动画中
val isAnimating = animatedOffset != Offset.Zero
Box(
modifier = Modifier
.size(90.dp)
modifier = modifier
.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() }
)
}
.zIndex(if (isDragging) 1f else 0f)
.then(
if (onClick != null && uploadProgress == null) {
Modifier.clickable { onClick() }