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:
inventory69
2026-02-11 08:56:48 +01:00
parent 24fe32a973
commit 7b558113cf
4 changed files with 176 additions and 75 deletions

View File

@@ -25,6 +25,7 @@ import kotlinx.coroutines.launch
* v1.5.0: NoteEditor Redesign * v1.5.0: NoteEditor Redesign
* v1.8.0: IMPL_023 - Drag & Drop Fix (pointerInput key + Handle-only drag) * 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.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( class DragDropListState(
private val state: LazyListState, private val state: LazyListState,
@@ -41,6 +42,9 @@ class DragDropListState(
private var draggingItemSize by mutableStateOf(0) private var draggingItemSize by mutableStateOf(0)
private var overscrollJob by mutableStateOf<Job?>(null) 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 val draggingItemOffset: Float
get() = draggingItemLayoutInfo?.let { item -> get() = draggingItemLayoutInfo?.let { item ->
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
@@ -50,6 +54,23 @@ class DragDropListState(
get() = state.layoutInfo.visibleItemsInfo get() = state.layoutInfo.visibleItemsInfo
.firstOrNull { it.index == draggingItemIndex } .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) { fun onDragStart(offset: Offset, itemIndex: Int) {
draggingItemIndex = itemIndex draggingItemIndex = itemIndex
val info = draggingItemLayoutInfo val info = draggingItemLayoutInfo
@@ -78,9 +99,12 @@ class DragDropListState(
// Statt den Mittelpunkt des gezogenen Items zu prüfen ("liegt mein Zentrum im Target?"), // 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. // wird geprüft ob das gezogene Item den MITTELPUNKT des Targets überspannt.
// Dies verhindert Oszillation bei Items unterschiedlicher Größe. // 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 -> 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 { run {
val targetCenter = item.offset + item.size / 2 val targetCenter = item.offset + item.size / 2
startOffset < targetCenter && endOffset > targetCenter startOffset < targetCenter && endOffset > targetCenter
@@ -95,16 +119,20 @@ class DragDropListState(
} else { } else {
null 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) { if (scrollToIndex != null) {
scope.launch { scope.launch {
state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset) 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) // 🆕 v1.8.0: IMPL_023b — Index-Update NACH dem Move (verhindert Race-Condition)
draggingItemIndex = targetItem.index draggingItemIndex = targetItem.index
} }
} else { } else {
onMove(draggingItem.index, targetItem.index) onMove(fromDataIndex, toDataIndex)
draggingItemIndex = targetItem.index draggingItemIndex = targetItem.index
} }
} else { } 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") @Suppress("UnusedPrivateProperty")
private val LazyListItemInfo.offsetEnd: Int private val LazyListItemInfo.offsetEnd: Int
get() = this.offset + this.size get() = this.offset + this.size

View File

@@ -1,11 +1,6 @@
package dev.dettmer.simplenotes.ui.editor package dev.dettmer.simplenotes.ui.editor
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState 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.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape 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 @Suppress("LongParameterList") // Compose functions commonly have many callback parameters
@Composable @Composable
private fun ChecklistEditor( private fun ChecklistEditor(
@@ -359,75 +412,61 @@ private fun ChecklistEditor(
val showSeparator = shouldShowSeparator && uncheckedCount > 0 && checkedCount > 0 val showSeparator = shouldShowSeparator && uncheckedCount > 0 && checkedCount > 0
Column(modifier = modifier) { 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( LazyColumn(
state = listState, state = listState,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
contentPadding = PaddingValues(vertical = 8.dp), contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
// 🆕 v1.8.1 IMPL_14: Unchecked Items (Visual Index 0..uncheckedCount-1)
itemsIndexed( itemsIndexed(
items = items, items = if (showSeparator) items.subList(0, uncheckedCount) else items,
key = { _, item -> item.id } key = { _, item -> item.id }
) { index, item -> ) { index, item ->
// 🆕 v1.8.0 (IMPL_017): Separator vor dem ersten Checked-Item DraggableChecklistItem(
// 🆕 v1.8.1: Separator während Drag ausblenden — verhindert Flächenveränderung item = item,
// am Separator-Item die Swap-Erkennung destabilisiert visualIndex = index,
if (showSeparator && index == uncheckedCount && dragDropState.draggingItemIndex == null) { dragDropState = dragDropState,
CheckedItemsSeparator(checkedCount = checkedCount) focusNewItemId = focusNewItemId,
} onTextChange = onTextChange,
onCheckedChange = onCheckedChange,
val isDragging = dragDropState.draggingItemIndex == index onDelete = onDelete,
val elevation by animateDpAsState( onAddNewItemAfter = onAddNewItemAfter,
targetValue = if (isDragging) 8.dp else 0.dp, onFocusHandled = onFocusHandled
label = "elevation"
) )
}
val shouldFocus = item.id == focusNewItemId // 🆕 v1.8.1 IMPL_14: Separator als eigenes LazyColumn-Item
if (showSeparator) {
// v1.5.0: Clear focus request after handling item(key = "separator") {
LaunchedEffect(shouldFocus) { CheckedItemsSeparator(
if (shouldFocus) { checkedCount = checkedCount,
onFocusHandled() isDragActive = dragDropState.draggingItemIndex != null
} )
} }
// 🆕 v1.8.0 (IMPL_017): AnimatedVisibility für sanfte Übergänge // 🆕 v1.8.1 IMPL_14: Checked Items (Visual Index uncheckedCount+1..)
AnimatedVisibility( itemsIndexed(
visible = true, items = items.subList(uncheckedCount, items.size),
enter = fadeIn() + slideInVertically(), key = { _, item -> item.id }
exit = fadeOut() + slideOutVertically() ) { index, item ->
) { val visualIndex = uncheckedCount + 1 + index // +1 für Separator
ChecklistItemRow( DraggableChecklistItem(
item = item, item = item,
onTextChange = { onTextChange(item.id, it) }, visualIndex = visualIndex,
onCheckedChange = { onCheckedChange(item.id, it) }, dragDropState = dragDropState,
onDelete = { onDelete(item.id) }, focusNewItemId = focusNewItemId,
onAddNewItem = { onAddNewItemAfter(item.id) }, onTextChange = onTextChange,
requestFocus = shouldFocus, onCheckedChange = onCheckedChange,
// 🆕 v1.8.0: IMPL_023 - Drag state übergeben onDelete = onDelete,
isDragging = isDragging, onAddNewItemAfter = onAddNewItemAfter,
// 🆕 v1.8.0: IMPL_023 - Gradient während Drag ausblenden onFocusHandled = onFocusHandled
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
)
}
// 🆕 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)
)
) )
} }
} }

View File

@@ -238,15 +238,18 @@ class NoteEditorViewModel(
val fromItem = items.getOrNull(fromIndex) ?: return@update items val fromItem = items.getOrNull(fromIndex) ?: return@update items
val toItem = items.getOrNull(toIndex) ?: 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 mutableList = items.toMutableList()
val item = mutableList.removeAt(fromIndex) 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 // Update order values
mutableList.mapIndexed { index, i -> i.copy(order = index) } mutableList.mapIndexed { index, i -> i.copy(order = index) }
} }

View File

@@ -15,6 +15,7 @@ import dev.dettmer.simplenotes.R
/** /**
* 🆕 v1.8.0 (IMPL_017): Visueller Separator zwischen unchecked und checked Items * 🆕 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: * Zeigt eine dezente Linie mit Anzahl der erledigten Items:
* ── 3 completed ── * ── 3 completed ──
@@ -22,7 +23,8 @@ import dev.dettmer.simplenotes.R
@Composable @Composable
fun CheckedItemsSeparator( fun CheckedItemsSeparator(
checkedCount: Int, checkedCount: Int,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
isDragActive: Boolean = false // 🆕 v1.8.1 IMPL_14
) { ) {
Row( Row(
modifier = modifier modifier = modifier
@@ -32,7 +34,10 @@ fun CheckedItemsSeparator(
) { ) {
HorizontalDivider( HorizontalDivider(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.outlineVariant color = if (isDragActive)
MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
else
MaterialTheme.colorScheme.outlineVariant
) )
Text( Text(
@@ -42,13 +47,19 @@ fun CheckedItemsSeparator(
checkedCount checkedCount
), ),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline, color = if (isDragActive)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.outline,
modifier = Modifier.padding(horizontal = 12.dp) modifier = Modifier.padding(horizontal = 12.dp)
) )
HorizontalDivider( HorizontalDivider(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.outlineVariant color = if (isDragActive)
MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
else
MaterialTheme.colorScheme.outlineVariant
) )
} }
} }