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

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

View File

@@ -1,16 +1,13 @@
package com.memory.app.ui.components package com.memory.app.ui.components
import android.net.Uri 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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.* 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.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -22,21 +19,15 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale 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.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.memory.app.ui.theme.Brand500 import com.memory.app.ui.theme.Brand500
import kotlinx.coroutines.launch import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyGridState
sealed class MediaItemData { sealed class MediaItemData {
abstract val id: String abstract val id: String
@@ -45,7 +36,6 @@ sealed class MediaItemData {
data class Video(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 @Composable
fun ReorderableMediaGrid( fun ReorderableMediaGrid(
items: List<MediaItemData>, items: List<MediaItemData>,
@@ -57,12 +47,6 @@ fun ReorderableMediaGrid(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var mediaItems by remember(items) { mutableStateOf(items) } 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) { LaunchedEffect(items) {
@@ -74,177 +58,68 @@ fun ReorderableMediaGrid(
if (mediaItems.isEmpty()) return if (mediaItems.isEmpty()) return
val columns = if (mediaItems.size <= 3) mediaItems.size else 3 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( val lazyGridState = rememberLazyGridState()
modifier = modifier, val reorderableState = rememberReorderableLazyGridState(lazyGridState) { from, to ->
verticalArrangement = Arrangement.spacedBy(spacing) 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) { items(mediaItems, key = { it.id }) { item ->
Row(horizontalArrangement = Arrangement.spacedBy(spacing)) { ReorderableItem(reorderableState, key = item.id) { isDragging ->
for (col in 0 until columns) { val index = mediaItems.indexOf(item)
val index = row * columns + col val progress = uploadProgress[index]
if (index < mediaItems.size) {
val item = mediaItems[index] MediaItemCard(
val progress = uploadProgress[index] item = item,
val isDragging = draggedIndex == index isDragging = isDragging,
onRemove = if (!isLoading && onRemove != null) {
// 为每个项目创建动画偏移 { onRemove(item) }
val animatedOffset = itemOffsets.getOrPut(item.id) { } else null,
Animatable(Offset.Zero, Offset.VectorConverter) onClick = if (onClick != null) {
} { onClick(item) }
} else null,
ReorderableMediaItem( uploadProgress = progress,
item = item, modifier = Modifier
index = index, .aspectRatio(1f)
isDragging = isDragging, .longPressDraggableHandle(
dragOffset = if (isDragging) draggedItemOffset else Offset.Zero, enabled = !isLoading,
animatedOffset = if (!isDragging) animatedOffset.value else Offset.Zero, onDragStarted = {},
onRemove = if (!isLoading && onRemove != null) { onDragStopped = {}
{ 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 @Composable
private fun ReorderableMediaItem( private fun MediaItemCard(
item: MediaItemData, item: MediaItemData,
index: Int,
isDragging: Boolean, isDragging: Boolean,
dragOffset: Offset,
animatedOffset: Offset,
onRemove: (() -> Unit)?, onRemove: (() -> Unit)?,
onClick: (() -> Unit)?, onClick: (() -> Unit)?,
onDragStart: () -> Unit, uploadProgress: Float?,
onDrag: (Offset) -> Unit, modifier: Modifier = Modifier
onDragEnd: () -> Unit,
uploadProgress: Float? = null
) { ) {
// 判断是否正在动画中
val isAnimating = animatedOffset != Offset.Zero
Box( Box(
modifier = Modifier modifier = modifier
.size(90.dp)
.graphicsLayer { .graphicsLayer {
// 使用 graphicsLayer 进行硬件加速的变换
scaleX = if (isDragging) 1.05f else 1f scaleX = if (isDragging) 1.05f else 1f
scaleY = 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 shadowElevation = if (isDragging) 8.dp.toPx() else 0f
} }
.zIndex( .zIndex(if (isDragging) 1f else 0f)
when {
isDragging -> 2f // 拖拽项最高层级
isAnimating -> 1f // 动画项次高层级,避免被其他项遮挡
else -> 0f // 普通项最低层级
}
)
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { onDragStart() },
onDrag = { change, dragAmount ->
change.consume()
onDrag(dragAmount)
},
onDragEnd = { onDragEnd() },
onDragCancel = { onDragEnd() }
)
}
.then( .then(
if (onClick != null && uploadProgress == null) { if (onClick != null && uploadProgress == null) {
Modifier.clickable { onClick() } Modifier.clickable { onClick() }

View File

@@ -15,8 +15,11 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.* 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.rememberScrollState
import androidx.compose.foundation.verticalScroll 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.components.VideoPlayer
import com.memory.app.ui.theme.* import com.memory.app.ui.theme.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyGridState
@Composable @Composable
fun EditPostScreen( fun EditPostScreen(
@@ -491,7 +496,6 @@ private sealed class EditMediaItem {
data class NewVideo(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 @Composable
private fun EditReorderableMediaGrid( private fun EditReorderableMediaGrid(
items: List<EditMediaItem>, items: List<EditMediaItem>,
@@ -502,12 +506,6 @@ private fun EditReorderableMediaGrid(
isLoading: Boolean = false isLoading: Boolean = false
) { ) {
var mediaItems by remember(items) { mutableStateOf(items) } 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) { LaunchedEffect(items) {
if (items != mediaItems) { if (items != mediaItems) {
@@ -518,174 +516,67 @@ private fun EditReorderableMediaGrid(
if (mediaItems.isEmpty()) return if (mediaItems.isEmpty()) return
val columns = if (mediaItems.size <= 3) mediaItems.size else 3 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)) { val lazyGridState = rememberLazyGridState()
for (row in 0 until rows) { val reorderableState = rememberReorderableLazyGridState(lazyGridState) { from, to ->
Row(horizontalArrangement = Arrangement.spacedBy(spacing)) { mediaItems = mediaItems.toMutableList().apply {
for (col in 0 until columns) { add(to.index, removeAt(from.index))
val index = row * columns + col }
if (index < mediaItems.size) { onItemsReordered(mediaItems)
val item = mediaItems[index] }
val progress = uploadProgress[index]
val isDragging = draggedIndex == index LazyVerticalGrid(
columns = GridCells.Fixed(columns),
// 为每个项目创建动画偏移 state = lazyGridState,
val animatedOffset = itemOffsets.getOrPut(item.id) { horizontalArrangement = Arrangement.spacedBy(8.dp),
Animatable(Offset.Zero, Offset.VectorConverter) verticalArrangement = Arrangement.spacedBy(8.dp),
} modifier = Modifier.heightIn(max = 300.dp)
) {
EditReorderableMediaItem( items(mediaItems, key = { it.id }) { item ->
item = item, ReorderableItem(reorderableState, key = item.id) { isDragging ->
index = index, val index = mediaItems.indexOf(item)
isDragging = isDragging, val progress = uploadProgress[index]
dragOffset = if (isDragging) draggedItemOffset else Offset.Zero,
animatedOffset = if (!isDragging) animatedOffset.value else Offset.Zero, EditMediaItemCard(
onRemove = if (!isLoading && onRemove != null) { item = item,
{ onRemove(item) } isDragging = isDragging,
} else null, onRemove = if (!isLoading && onRemove != null) {
onClick = if (onClick != null) { { onRemove(item) }
{ onClick(item) } } else null,
} else null, onClick = if (onClick != null) {
onDragStart = { { onClick(item) }
if (!isLoading) { } else null,
draggedIndex = index uploadProgress = progress,
draggedItemOffset = Offset.Zero modifier = Modifier
} .aspectRatio(1f)
}, .longPressDraggableHandle(
onDrag = { offset -> enabled = !isLoading,
val currentIndex = draggedIndex ?: return@EditReorderableMediaItem onDragStarted = {},
draggedItemOffset += offset onDragStopped = {}
// 计算拖拽项当前的行列位置
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 @Composable
private fun EditReorderableMediaItem( private fun EditMediaItemCard(
item: EditMediaItem, item: EditMediaItem,
index: Int,
isDragging: Boolean, isDragging: Boolean,
dragOffset: Offset,
animatedOffset: Offset,
onRemove: (() -> Unit)?, onRemove: (() -> Unit)?,
onClick: (() -> Unit)?, onClick: (() -> Unit)?,
onDragStart: () -> Unit, uploadProgress: Float?,
onDrag: (Offset) -> Unit, modifier: Modifier = Modifier
onDragEnd: () -> Unit,
uploadProgress: Float? = null
) { ) {
// 判断是否正在动画中
val isAnimating = animatedOffset != Offset.Zero
Box( Box(
modifier = Modifier modifier = modifier
.size(90.dp)
.graphicsLayer { .graphicsLayer {
// 使用 graphicsLayer 进行硬件加速的变换
scaleX = if (isDragging) 1.05f else 1f scaleX = if (isDragging) 1.05f else 1f
scaleY = 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 shadowElevation = if (isDragging) 8.dp.toPx() else 0f
} }
.zIndex( .zIndex(if (isDragging) 1f else 0f)
when {
isDragging -> 2f // 拖拽项最高层级
isAnimating -> 1f // 动画项次高层级,避免被其他项遮挡
else -> 0f // 普通项最低层级
}
)
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { onDragStart() },
onDrag = { change, dragAmount ->
change.consume()
onDrag(dragAmount)
},
onDragEnd = { onDragEnd() },
onDragCancel = { onDragEnd() }
)
}
.then( .then(
if (onClick != null && uploadProgress == null) { if (onClick != null && uploadProgress == null) {
Modifier.clickable { onClick() } Modifier.clickable { onClick() }