From 4ada46a549f7cd073a2be56856989530f0efe14c Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 23 Jul 2026 14:31:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E6=94=AF=E6=8C=81=E6=8C=89?= =?UTF-8?q?=E4=BD=8F=E8=B7=A8=E8=A1=8C=E6=BB=91=E5=8A=A8=E8=BF=9E=E7=BB=AD?= =?UTF-8?q?=E5=A4=9A=E9=80=89=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plus/rua/project/DateRecorderViewModel.kt | 12 +++ .../plus/rua/project/ui/DateRecorderScreen.kt | 97 ++++++++++++++++++- 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/core/src/main/kotlin/plus/rua/project/DateRecorderViewModel.kt b/core/src/main/kotlin/plus/rua/project/DateRecorderViewModel.kt index 74f120c..c926001 100644 --- a/core/src/main/kotlin/plus/rua/project/DateRecorderViewModel.kt +++ b/core/src/main/kotlin/plus/rua/project/DateRecorderViewModel.kt @@ -140,6 +140,18 @@ class DateRecorderViewModel( _selectedIds.value = setOf(id) } + /** + * 批量设置选中的记录 ID 集合(响应按住滑动多选操作)。 + * + * @param ids 选中的记录 ID 集合 + */ + fun setSelectedIds(ids: Set) { + if (!_selectionMode.value) { + _selectionMode.value = true + } + _selectedIds.value = ids + } + /** * 切换某条记录的选中状态。 */ diff --git a/core/src/main/kotlin/plus/rua/project/ui/DateRecorderScreen.kt b/core/src/main/kotlin/plus/rua/project/ui/DateRecorderScreen.kt index 70e596f..5177ea5 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/DateRecorderScreen.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/DateRecorderScreen.kt @@ -17,8 +17,10 @@ import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -30,8 +32,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridState 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 @@ -44,7 +48,11 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.PhotoCamera import androidx.compose.material.icons.filled.Sort +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import kotlinx.coroutines.launch import androidx.compose.material3.AlertDialog import androidx.compose.material3.BottomAppBar import androidx.compose.material3.Card @@ -306,7 +314,8 @@ fun DateRecorderScreen( photoRoot = repo, onOpenRecord = onOpenRecord, onToggleSelection = viewModel::toggleSelection, - onStartSelectionWith = viewModel::startSelectionModeWith + onStartSelectionWith = viewModel::startSelectionModeWith, + onSetSelectedIds = viewModel::setSelectedIds ) } } @@ -345,11 +354,58 @@ private fun RecordGrid( photoRoot: DateRecorderRepository, onOpenRecord: (Long) -> Unit, onToggleSelection: (Long) -> Unit, - onStartSelectionWith: (Long) -> Unit + onStartSelectionWith: (Long) -> Unit, + onSetSelectedIds: (Set) -> Unit ) { + val gridState = rememberLazyGridState() + val coroutineScope = rememberCoroutineScope() + + var initialDragIndex by remember { mutableStateOf(null) } + var initialSelectedIds by remember { mutableStateOf>(emptySet()) } + LazyVerticalGrid( + state = gridState, columns = GridCells.Adaptive(minSize = 100.dp), - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .pointerInput(records, selectionMode, selectedIds) { + detectDragGesturesAfterLongPress( + onDragStart = { startOffset -> + val index = findItemIndexAtOffset(gridState, startOffset) + if (index != null) { + initialDragIndex = index + initialSelectedIds = if (selectionMode) selectedIds else emptySet() + val startId = records[index].id + onSetSelectedIds(initialSelectedIds + startId) + } + }, + onDrag = { change, _ -> + change.consume() + val startIndex = initialDragIndex ?: return@detectDragGesturesAfterLongPress + val currentIndex = findItemIndexAtOffset(gridState, change.position) + ?: return@detectDragGesturesAfterLongPress + + val minIdx = minOf(startIndex, currentIndex) + val maxIdx = maxOf(startIndex, currentIndex) + val draggedIds = (minIdx..maxIdx).mapNotNull { records.getOrNull(it)?.id }.toSet() + onSetSelectedIds(initialSelectedIds + draggedIds) + + // 边界滑动自动滚屏 + val viewportHeight = gridState.layoutInfo.viewportSize.height + if (change.position.y < 120f) { + coroutineScope.launch { gridState.scrollBy(-25f) } + } else if (change.position.y > viewportHeight - 120f) { + coroutineScope.launch { gridState.scrollBy(25f) } + } + }, + onDragEnd = { + initialDragIndex = null + }, + onDragCancel = { + initialDragIndex = null + } + ) + }, contentPadding = PaddingValues(8.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp) @@ -384,6 +440,39 @@ private fun RecordGrid( } } +/** + * 根据触控点 Offset 命中测试 LazyVerticalGrid 中的 VisibleItem 索引。 + */ +private fun findItemIndexAtOffset(gridState: LazyGridState, offset: Offset): Int? { + val itemsInfo = gridState.layoutInfo.visibleItemsInfo + if (itemsInfo.isEmpty()) return null + + val hit = itemsInfo.firstOrNull { item -> + val x = item.offset.x + val y = item.offset.y + offset.x >= x && offset.x <= x + item.size.width && + offset.y >= y && offset.y <= y + item.size.height + } + if (hit != null) return hit.index + + val firstVisible = itemsInfo.first() + if (offset.y < firstVisible.offset.y) { + return firstVisible.index + } + + val lastVisible = itemsInfo.last() + val lastBottom = lastVisible.offset.y + lastVisible.size.height + if (offset.y > lastBottom) { + return lastVisible.index + } + + val rowMatch = itemsInfo.minByOrNull { item -> + val itemCenterY = item.offset.y + item.size.height / 2f + kotlin.math.abs(offset.y - itemCenterY) + } + return rowMatch?.index +} + @OptIn(ExperimentalFoundationApi::class) @Composable private fun RecordCard(