feat(editor): IMPL_14 - Separator drag cross-boundary with auto-toggle
Separator is now its own LazyColumn item instead of being rendered inline inside the first checked item's composable. This fixes: Bug A: Separator disappearing during drag (was hidden as workaround for height inflation). Now always visible with primary color highlight. Bug B: Cross-boundary move blocked (isChecked != toItem.isChecked returned early). Now auto-toggles isChecked when crossing boundary — like Google Tasks. Bug C: Drag flicker at separator boundary (draggingItemIndex updated even when onMove was a no-op → oscillation). Index remapping via visualToDataIndex()/dataToVisualIndex() ensures correct data indices. Architecture changes: - DragDropListState: separatorVisualIndex, index remapping functions, isAdjacentSkippingSeparator() skips separator in swap detection - NoteEditorScreen: Extracted DraggableChecklistItem composable, 3 LazyColumn blocks (unchecked items, separator, checked items), removed hardcoded AnimatedVisibility(visible=true) wrapper - NoteEditorViewModel: moveChecklistItem() allows cross-boundary moves with automatic isChecked toggle - CheckedItemsSeparator: isDragActive parameter for visual feedback Files changed: - DragDropListState.kt (+56 lines) - NoteEditorScreen.kt (refactored, net +84 lines) - NoteEditorViewModel.kt (simplified cross-boundary logic) - CheckedItemsSeparator.kt (drag-awareness parameter)
This commit is contained in:
@@ -25,6 +25,7 @@ import kotlinx.coroutines.launch
|
||||
* v1.5.0: NoteEditor Redesign
|
||||
* v1.8.0: IMPL_023 - Drag & Drop Fix (pointerInput key + Handle-only drag)
|
||||
* v1.8.0: IMPL_023b - Flicker-Fix (Straddle-Target-Center-Erkennung statt Mittelpunkt)
|
||||
* v1.8.1: IMPL_14 - Separator als eigenes Item, Cross-Boundary-Drag mit Auto-Toggle
|
||||
*/
|
||||
class DragDropListState(
|
||||
private val state: LazyListState,
|
||||
@@ -41,6 +42,9 @@ class DragDropListState(
|
||||
private var draggingItemSize by mutableStateOf(0)
|
||||
private var overscrollJob by mutableStateOf<Job?>(null)
|
||||
|
||||
// 🆕 v1.8.1 IMPL_14: Visual-Index des Separators (-1 = kein Separator)
|
||||
var separatorVisualIndex by mutableStateOf(-1)
|
||||
|
||||
val draggingItemOffset: Float
|
||||
get() = draggingItemLayoutInfo?.let { item ->
|
||||
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
|
||||
@@ -50,6 +54,23 @@ class DragDropListState(
|
||||
get() = state.layoutInfo.visibleItemsInfo
|
||||
.firstOrNull { it.index == draggingItemIndex }
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.1 IMPL_14: Visual-Index → Data-Index Konvertierung.
|
||||
* Wenn ein Separator existiert, sind alle Items nach dem Separator um 1 verschoben.
|
||||
*/
|
||||
fun visualToDataIndex(visualIndex: Int): Int {
|
||||
if (separatorVisualIndex < 0) return visualIndex
|
||||
return if (visualIndex > separatorVisualIndex) visualIndex - 1 else visualIndex
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.1 IMPL_14: Data-Index → Visual-Index Konvertierung.
|
||||
*/
|
||||
fun dataToVisualIndex(dataIndex: Int): Int {
|
||||
if (separatorVisualIndex < 0) return dataIndex
|
||||
return if (dataIndex >= separatorVisualIndex) dataIndex + 1 else dataIndex
|
||||
}
|
||||
|
||||
fun onDragStart(offset: Offset, itemIndex: Int) {
|
||||
draggingItemIndex = itemIndex
|
||||
val info = draggingItemLayoutInfo
|
||||
@@ -78,9 +99,12 @@ class DragDropListState(
|
||||
// Statt den Mittelpunkt des gezogenen Items zu prüfen ("liegt mein Zentrum im Target?"),
|
||||
// wird geprüft ob das gezogene Item den MITTELPUNKT des Targets überspannt.
|
||||
// Dies verhindert Oszillation bei Items unterschiedlicher Größe.
|
||||
// Zusätzlich: Nur adjazente Items (Index ± 1) als Swap-Kandidaten.
|
||||
// 🆕 v1.8.1 IMPL_14: Separator überspringen, Adjazenz berücksichtigt Separator-Lücke
|
||||
val targetItem = state.layoutInfo.visibleItemsInfo.firstOrNull { item ->
|
||||
(item.index == draggingItem.index - 1 || item.index == draggingItem.index + 1) &&
|
||||
// Separator überspringen
|
||||
item.index != separatorVisualIndex &&
|
||||
// Nur adjazente Items (Separator-Lücke wird übersprungen)
|
||||
isAdjacentSkippingSeparator(draggingItem.index, item.index) &&
|
||||
run {
|
||||
val targetCenter = item.offset + item.size / 2
|
||||
startOffset < targetCenter && endOffset > targetCenter
|
||||
@@ -96,15 +120,19 @@ class DragDropListState(
|
||||
null
|
||||
}
|
||||
|
||||
// 🆕 v1.8.1 IMPL_14: Visual-Indizes zu Data-Indizes konvertieren für onMove
|
||||
val fromDataIndex = visualToDataIndex(draggingItem.index)
|
||||
val toDataIndex = visualToDataIndex(targetItem.index)
|
||||
|
||||
if (scrollToIndex != null) {
|
||||
scope.launch {
|
||||
state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset)
|
||||
onMove(draggingItem.index, targetItem.index)
|
||||
onMove(fromDataIndex, toDataIndex)
|
||||
// 🆕 v1.8.0: IMPL_023b — Index-Update NACH dem Move (verhindert Race-Condition)
|
||||
draggingItemIndex = targetItem.index
|
||||
}
|
||||
} else {
|
||||
onMove(draggingItem.index, targetItem.index)
|
||||
onMove(fromDataIndex, toDataIndex)
|
||||
draggingItemIndex = targetItem.index
|
||||
}
|
||||
} else {
|
||||
@@ -128,6 +156,26 @@ class DragDropListState(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.1 IMPL_14: Prüft ob zwei Visual-Indizes adjazent sind,
|
||||
* wobei der Separator übersprungen wird.
|
||||
* Beispiel: Items bei Visual 1 und Visual 3 sind adjazent wenn Separator bei Visual 2 liegt.
|
||||
*/
|
||||
private fun isAdjacentSkippingSeparator(indexA: Int, indexB: Int): Boolean {
|
||||
val diff = kotlin.math.abs(indexA - indexB)
|
||||
if (diff == 1) {
|
||||
// Direkt benachbart — aber NICHT wenn der Separator dazwischen liegt
|
||||
val between = minOf(indexA, indexB) + 1
|
||||
return between != separatorVisualIndex || separatorVisualIndex < 0
|
||||
}
|
||||
if (diff == 2 && separatorVisualIndex >= 0) {
|
||||
// 2 Positionen entfernt — adjazent wenn Separator dazwischen
|
||||
val between = minOf(indexA, indexB) + 1
|
||||
return between == separatorVisualIndex
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@Suppress("UnusedPrivateProperty")
|
||||
private val LazyListItemInfo.offsetEnd: Int
|
||||
get() = this.offset + this.size
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
package dev.dettmer.simplenotes.ui.editor
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -19,6 +14,7 @@ import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -327,6 +323,63 @@ private fun TextNoteContent(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.1 IMPL_14: Extrahiertes Composable für ein einzelnes draggbares Checklist-Item.
|
||||
* Entkoppelt von der Separator-Logik — wiederverwendbar für unchecked und checked Items.
|
||||
*/
|
||||
@Composable
|
||||
private fun LazyItemScope.DraggableChecklistItem(
|
||||
item: ChecklistItemState,
|
||||
visualIndex: Int,
|
||||
dragDropState: DragDropListState,
|
||||
focusNewItemId: String?,
|
||||
onTextChange: (String, String) -> Unit,
|
||||
onCheckedChange: (String, Boolean) -> Unit,
|
||||
onDelete: (String) -> Unit,
|
||||
onAddNewItemAfter: (String) -> Unit,
|
||||
onFocusHandled: () -> Unit,
|
||||
) {
|
||||
val isDragging = dragDropState.draggingItemIndex == visualIndex
|
||||
val elevation by animateDpAsState(
|
||||
targetValue = if (isDragging) 8.dp else 0.dp,
|
||||
label = "elevation"
|
||||
)
|
||||
|
||||
val shouldFocus = item.id == focusNewItemId
|
||||
|
||||
LaunchedEffect(shouldFocus) {
|
||||
if (shouldFocus) {
|
||||
onFocusHandled()
|
||||
}
|
||||
}
|
||||
|
||||
ChecklistItemRow(
|
||||
item = item,
|
||||
onTextChange = { onTextChange(item.id, it) },
|
||||
onCheckedChange = { onCheckedChange(item.id, it) },
|
||||
onDelete = { onDelete(item.id) },
|
||||
onAddNewItem = { onAddNewItemAfter(item.id) },
|
||||
requestFocus = shouldFocus,
|
||||
isDragging = isDragging,
|
||||
isAnyItemDragging = dragDropState.draggingItemIndex != null,
|
||||
dragModifier = Modifier.dragContainer(dragDropState, visualIndex),
|
||||
modifier = Modifier
|
||||
.then(if (!isDragging) Modifier.animateItem() else Modifier)
|
||||
.offset {
|
||||
IntOffset(
|
||||
0,
|
||||
if (isDragging) dragDropState.draggingItemOffset.roundToInt() else 0
|
||||
)
|
||||
}
|
||||
.zIndex(if (isDragging) DRAGGING_ITEM_Z_INDEX else 0f)
|
||||
.shadow(elevation, shape = RoundedCornerShape(ITEM_CORNER_RADIUS_DP.dp))
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(ITEM_CORNER_RADIUS_DP.dp)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList") // Compose functions commonly have many callback parameters
|
||||
@Composable
|
||||
private fun ChecklistEditor(
|
||||
@@ -359,75 +412,61 @@ private fun ChecklistEditor(
|
||||
val showSeparator = shouldShowSeparator && uncheckedCount > 0 && checkedCount > 0
|
||||
|
||||
Column(modifier = modifier) {
|
||||
// 🆕 v1.8.1 IMPL_14: Separator-Position für DragDropState aktualisieren
|
||||
val separatorVisualIndex = if (showSeparator) uncheckedCount else -1
|
||||
LaunchedEffect(separatorVisualIndex) {
|
||||
dragDropState.separatorVisualIndex = separatorVisualIndex
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
// 🆕 v1.8.1 IMPL_14: Unchecked Items (Visual Index 0..uncheckedCount-1)
|
||||
itemsIndexed(
|
||||
items = items,
|
||||
items = if (showSeparator) items.subList(0, uncheckedCount) else items,
|
||||
key = { _, item -> item.id }
|
||||
) { index, item ->
|
||||
// 🆕 v1.8.0 (IMPL_017): Separator vor dem ersten Checked-Item
|
||||
// 🆕 v1.8.1: Separator während Drag ausblenden — verhindert Flächenveränderung
|
||||
// am Separator-Item die Swap-Erkennung destabilisiert
|
||||
if (showSeparator && index == uncheckedCount && dragDropState.draggingItemIndex == null) {
|
||||
CheckedItemsSeparator(checkedCount = checkedCount)
|
||||
}
|
||||
|
||||
val isDragging = dragDropState.draggingItemIndex == index
|
||||
val elevation by animateDpAsState(
|
||||
targetValue = if (isDragging) 8.dp else 0.dp,
|
||||
label = "elevation"
|
||||
)
|
||||
|
||||
val shouldFocus = item.id == focusNewItemId
|
||||
|
||||
// v1.5.0: Clear focus request after handling
|
||||
LaunchedEffect(shouldFocus) {
|
||||
if (shouldFocus) {
|
||||
onFocusHandled()
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 v1.8.0 (IMPL_017): AnimatedVisibility für sanfte Übergänge
|
||||
AnimatedVisibility(
|
||||
visible = true,
|
||||
enter = fadeIn() + slideInVertically(),
|
||||
exit = fadeOut() + slideOutVertically()
|
||||
) {
|
||||
ChecklistItemRow(
|
||||
DraggableChecklistItem(
|
||||
item = item,
|
||||
onTextChange = { onTextChange(item.id, it) },
|
||||
onCheckedChange = { onCheckedChange(item.id, it) },
|
||||
onDelete = { onDelete(item.id) },
|
||||
onAddNewItem = { onAddNewItemAfter(item.id) },
|
||||
requestFocus = shouldFocus,
|
||||
// 🆕 v1.8.0: IMPL_023 - Drag state übergeben
|
||||
isDragging = isDragging,
|
||||
// 🆕 v1.8.0: IMPL_023 - Gradient während Drag ausblenden
|
||||
isAnyItemDragging = dragDropState.draggingItemIndex != null,
|
||||
// 🆕 v1.8.0: IMPL_023 - Drag nur auf Handle
|
||||
dragModifier = Modifier.dragContainer(dragDropState, index),
|
||||
modifier = Modifier
|
||||
// 🆕 v1.8.1: animateItem NUR für nicht-gedraggte Items
|
||||
// Bei gedraggten Items kämpft animateItem (Layout-Animation)
|
||||
// gegen den manuellen offset (Finger-Position) → Flackern
|
||||
.then(if (!isDragging) Modifier.animateItem() else Modifier)
|
||||
.offset {
|
||||
IntOffset(
|
||||
0,
|
||||
if (isDragging) dragDropState.draggingItemOffset.roundToInt() else 0
|
||||
visualIndex = index,
|
||||
dragDropState = dragDropState,
|
||||
focusNewItemId = focusNewItemId,
|
||||
onTextChange = onTextChange,
|
||||
onCheckedChange = onCheckedChange,
|
||||
onDelete = onDelete,
|
||||
onAddNewItemAfter = onAddNewItemAfter,
|
||||
onFocusHandled = onFocusHandled
|
||||
)
|
||||
}
|
||||
// 🆕 v1.8.0: IMPL_023 - Gedraggtes Item liegt über anderen
|
||||
.zIndex(if (isDragging) DRAGGING_ITEM_Z_INDEX else 0f)
|
||||
.shadow(elevation, shape = RoundedCornerShape(ITEM_CORNER_RADIUS_DP.dp))
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(ITEM_CORNER_RADIUS_DP.dp)
|
||||
|
||||
// 🆕 v1.8.1 IMPL_14: Separator als eigenes LazyColumn-Item
|
||||
if (showSeparator) {
|
||||
item(key = "separator") {
|
||||
CheckedItemsSeparator(
|
||||
checkedCount = checkedCount,
|
||||
isDragActive = dragDropState.draggingItemIndex != null
|
||||
)
|
||||
}
|
||||
|
||||
// 🆕 v1.8.1 IMPL_14: Checked Items (Visual Index uncheckedCount+1..)
|
||||
itemsIndexed(
|
||||
items = items.subList(uncheckedCount, items.size),
|
||||
key = { _, item -> item.id }
|
||||
) { index, item ->
|
||||
val visualIndex = uncheckedCount + 1 + index // +1 für Separator
|
||||
DraggableChecklistItem(
|
||||
item = item,
|
||||
visualIndex = visualIndex,
|
||||
dragDropState = dragDropState,
|
||||
focusNewItemId = focusNewItemId,
|
||||
onTextChange = onTextChange,
|
||||
onCheckedChange = onCheckedChange,
|
||||
onDelete = onDelete,
|
||||
onAddNewItemAfter = onAddNewItemAfter,
|
||||
onFocusHandled = onFocusHandled
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,15 +238,18 @@ class NoteEditorViewModel(
|
||||
val fromItem = items.getOrNull(fromIndex) ?: return@update items
|
||||
val toItem = items.getOrNull(toIndex) ?: return@update items
|
||||
|
||||
// 🆕 v1.8.0 (IMPL_017): Drag nur innerhalb der gleichen Gruppe erlauben
|
||||
// (checked ↔ checked, unchecked ↔ unchecked)
|
||||
if (fromItem.isChecked != toItem.isChecked) {
|
||||
return@update items // Kein Move über Gruppen-Grenze
|
||||
}
|
||||
|
||||
val mutableList = items.toMutableList()
|
||||
val item = mutableList.removeAt(fromIndex)
|
||||
mutableList.add(toIndex, item)
|
||||
|
||||
// 🆕 v1.8.1 IMPL_14: Cross-Boundary Move mit Auto-Toggle
|
||||
// Wenn ein Item die Grenze überschreitet, wird es automatisch checked/unchecked.
|
||||
val movedItem = if (fromItem.isChecked != toItem.isChecked) {
|
||||
item.copy(isChecked = toItem.isChecked)
|
||||
} else {
|
||||
item
|
||||
}
|
||||
|
||||
mutableList.add(toIndex, movedItem)
|
||||
// Update order values
|
||||
mutableList.mapIndexed { index, i -> i.copy(order = index) }
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import dev.dettmer.simplenotes.R
|
||||
|
||||
/**
|
||||
* 🆕 v1.8.0 (IMPL_017): Visueller Separator zwischen unchecked und checked Items
|
||||
* 🆕 v1.8.1 (IMPL_14): Drag-Awareness — Primary-Farbe während Drag als visueller Hinweis
|
||||
*
|
||||
* Zeigt eine dezente Linie mit Anzahl der erledigten Items:
|
||||
* ── 3 completed ──
|
||||
@@ -22,7 +23,8 @@ import dev.dettmer.simplenotes.R
|
||||
@Composable
|
||||
fun CheckedItemsSeparator(
|
||||
checkedCount: Int,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
isDragActive: Boolean = false // 🆕 v1.8.1 IMPL_14
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
@@ -32,7 +34,10 @@ fun CheckedItemsSeparator(
|
||||
) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
color = if (isDragActive)
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
|
||||
else
|
||||
MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
|
||||
Text(
|
||||
@@ -42,13 +47,19 @@ fun CheckedItemsSeparator(
|
||||
checkedCount
|
||||
),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
color = if (isDragActive)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.outline,
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
color = if (isDragActive)
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
|
||||
else
|
||||
MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user