From 538a705def64100326a97742d786c6c5899d2586 Mon Sep 17 00:00:00 2001 From: inventory69 Date: Mon, 9 Feb 2026 13:21:03 +0100 Subject: [PATCH] feat(v1.8.0): IMPL_023b Drag & Drop Flicker Fix - Straddle-Target-Center Detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Swap detection: Changed from midpoint check to straddle-target-center detection * Old: Checks if midpoint of dragged item lies within target * New: Checks if dragged item spans the midpoint of target * Prevents oscillation when items have different sizes - Adjacency filter: Only adjacent items (index ± 1) as swap candidates * Prevents item jumps during fast drag * Reduces recalculation of visibleItemsInfo - Race-condition fix for scroll + move * draggingItemIndex update moved after onMove() in coroutine block * Prevents inconsistent state between index update and layout change Affected files: - DragDropListState.kt: onDrag() method (~10 lines changed) --- .../ui/editor/DragDropListState.kt | 32 ++- .../simplenotes/ui/editor/NoteEditorScreen.kt | 6 +- .../ui/editor/components/ChecklistItemRow.kt | 266 ++++++++++++------ 3 files changed, 204 insertions(+), 100 deletions(-) diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/DragDropListState.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/DragDropListState.kt index 0889ac0..6804d1d 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/DragDropListState.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/DragDropListState.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -19,9 +20,11 @@ import kotlinx.coroutines.launch /** * FOSS Drag & Drop State für LazyList - * + * * Native Compose-Implementierung ohne externe Dependencies * 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) */ class DragDropListState( private val state: LazyListState, @@ -64,11 +67,17 @@ class DragDropListState( val startOffset = draggingItem.offset + draggingItemOffset val endOffset = startOffset + draggingItem.size - val middleOffset = startOffset + (endOffset - startOffset) / 2f - - val targetItem = state.layoutInfo.visibleItemsInfo.find { item -> - middleOffset.toInt() in item.offset..item.offsetEnd && - draggingItem.index != item.index + // 🆕 v1.8.0: IMPL_023b — Straddle-Target-Center + Adjazenz-Filter + // 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. + val targetItem = state.layoutInfo.visibleItemsInfo.firstOrNull { item -> + (item.index == draggingItem.index - 1 || item.index == draggingItem.index + 1) && + run { + val targetCenter = item.offset + item.size / 2 + startOffset < targetCenter && endOffset > targetCenter + } } if (targetItem != null) { @@ -84,12 +93,13 @@ class DragDropListState( scope.launch { state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset) onMove(draggingItem.index, targetItem.index) + // 🆕 v1.8.0: IMPL_023b — Index-Update NACH dem Move (verhindert Race-Condition) + draggingItemIndex = targetItem.index } } else { onMove(draggingItem.index, targetItem.index) + draggingItemIndex = targetItem.index } - - draggingItemIndex = targetItem.index } else { val overscroll = when { draggingItemDraggedDelta > 0 -> @@ -130,14 +140,16 @@ fun rememberDragDropListState( } } +@Composable fun Modifier.dragContainer( dragDropState: DragDropListState, itemIndex: Int ): Modifier { - return this.pointerInput(dragDropState) { + val currentIndex = rememberUpdatedState(itemIndex) // 🆕 v1.8.0: rememberUpdatedState statt Key + return this.pointerInput(dragDropState) { // Nur dragDropState als Key - verhindert Gesture-Restart detectDragGesturesAfterLongPress( onDragStart = { offset -> - dragDropState.onDragStart(offset, itemIndex) + dragDropState.onDragStart(offset, currentIndex.value) // Aktuellen Wert lesen }, onDragEnd = { dragDropState.onDragInterrupted() diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorScreen.kt index d5f53fa..8d18e28 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorScreen.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.models.NoteType import dev.dettmer.simplenotes.ui.editor.components.ChecklistItemRow @@ -351,14 +352,17 @@ private fun ChecklistEditor( onDelete = { onDelete(item.id) }, onAddNewItem = { onAddNewItemAfter(item.id) }, requestFocus = shouldFocus, + isDragging = isDragging, // 🆕 v1.8.0: IMPL_023 - Drag state übergeben + isAnyItemDragging = dragDropState.draggingItemIndex != null, // 🆕 v1.8.0: IMPL_023 - Gradient während Drag ausblenden + dragModifier = Modifier.dragContainer(dragDropState, index), // 🆕 v1.8.0: IMPL_023 - Drag nur auf Handle modifier = Modifier - .dragContainer(dragDropState, index) .offset { IntOffset( 0, if (isDragging) dragDropState.draggingItemOffset.roundToInt() else 0 ) } + .zIndex(if (isDragging) 10f else 0f) // 🆕 v1.8.0: IMPL_023 - Gedraggtes Item liegt über anderen .shadow(elevation, shape = RoundedCornerShape(8.dp)) .background( color = MaterialTheme.colorScheme.surface, diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/components/ChecklistItemRow.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/components/ChecklistItemRow.kt index fa56658..59eea25 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/components/ChecklistItemRow.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/components/ChecklistItemRow.kt @@ -4,12 +4,15 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.DragHandle @@ -32,6 +35,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange @@ -39,6 +43,7 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.ui.editor.ChecklistItemState @@ -48,6 +53,7 @@ import dev.dettmer.simplenotes.ui.editor.ChecklistItemState * * v1.5.0: Jetpack Compose NoteEditor Redesign * v1.8.0: Long text UX improvements (gradient fade, auto-expand on focus) + * v1.8.0: IMPL_023 - Enlarged drag handle (48dp touch target) + drag modifier */ @Composable fun ChecklistItemRow( @@ -57,12 +63,16 @@ fun ChecklistItemRow( onDelete: () -> Unit, onAddNewItem: () -> Unit, requestFocus: Boolean = false, + isDragging: Boolean = false, // 🆕 v1.8.0: IMPL_023 - Drag state + isAnyItemDragging: Boolean = false, // 🆕 v1.8.0: IMPL_023 - Hide gradient during any drag + dragModifier: Modifier = Modifier, // 🆕 v1.8.0: IMPL_023 - Drag modifier for handle modifier: Modifier = Modifier ) { val focusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current + val density = LocalDensity.current var textFieldValue by remember(item.id) { - mutableStateOf(TextFieldValue(text = item.text, selection = TextRange(item.text.length))) + mutableStateOf(TextFieldValue(text = item.text, selection = TextRange(0))) } // 🆕 v1.8.0: Focus-State tracken für Expand/Collapse @@ -71,9 +81,20 @@ fun ChecklistItemRow( // 🆕 v1.8.0: Overflow erkennen (Text länger als maxLines) var hasOverflow by remember { mutableStateOf(false) } - // 🆕 v1.8.0: Dynamische maxLines basierend auf Focus - val currentMaxLines = if (isFocused) Int.MAX_VALUE else COLLAPSED_MAX_LINES - + // 🆕 v1.8.0: Höhe für collapsed-Ansicht (aus TextLayout berechnet) + var collapsedHeightDp by remember { mutableStateOf(null) } + + // 🆕 v1.8.0: ScrollState für dynamischen Gradient + val scrollState = rememberScrollState() + + // 🆕 v1.8.0: Scroll-basierter Ansatz aktiv wenn Höhe berechnet wurde + val useScrollClipping = hasOverflow && collapsedHeightDp != null + + // 🆕 v1.8.0: Dynamische Gradient-Sichtbarkeit basierend auf Scroll-Position + val showGradient = useScrollClipping && !isFocused && !isAnyItemDragging + val showTopGradient = showGradient && scrollState.value > 0 + val showBottomGradient = showGradient && scrollState.value < scrollState.maxValue + // v1.5.0: Auto-focus AND show keyboard when requestFocus is true (new items) LaunchedEffect(requestFocus) { if (requestFocus) { @@ -81,121 +102,163 @@ fun ChecklistItemRow( keyboardController?.show() } } - + + // 🆕 v1.8.0: Cursor ans Ende setzen wenn fokussiert (für Bearbeitung) + LaunchedEffect(isFocused) { + if (isFocused && textFieldValue.selection.start == 0) { + textFieldValue = textFieldValue.copy( + selection = TextRange(textFieldValue.text.length) + ) + } + } + // Update text field when external state changes LaunchedEffect(item.text) { if (textFieldValue.text != item.text) { textFieldValue = TextFieldValue( text = item.text, - selection = TextRange(item.text.length) + selection = if (isFocused) TextRange(item.text.length) else TextRange(0) ) } } - + val alpha = if (item.isChecked) 0.6f else 1.0f val textDecoration = if (item.isChecked) TextDecoration.LineThrough else TextDecoration.None - + @Suppress("MagicNumber") // UI padding values are self-explanatory Row( modifier = modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.Top // 🆕 v1.8.0: Top statt CenterVertically für lange Texte + .padding(end = 8.dp, top = 4.dp, bottom = 4.dp), // 🆕 v1.8.0: IMPL_023 - links kein Padding (Handle hat eigene Fläche) + verticalAlignment = if (hasOverflow) Alignment.Top else Alignment.CenterVertically // 🆕 v1.8.0: Dynamisch ) { - // Drag Handle - Icon( - imageVector = Icons.Default.DragHandle, - contentDescription = stringResource(R.string.drag_to_reorder), - modifier = Modifier - .size(24.dp) - .padding(top = 12.dp) // 🆕 v1.8.0: Visuell am oberen Rand ausrichten - .alpha(0.5f), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Spacer(modifier = Modifier.width(4.dp)) - + // 🆕 v1.8.0: IMPL_023 - Vergrößerter Drag Handle (48dp Touch-Target) + Box( + modifier = dragModifier + .size(48.dp) // Material Design minimum touch target + .alpha(if (isDragging) 1.0f else 0.6f), // Visual feedback beim Drag + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.DragHandle, + contentDescription = stringResource(R.string.drag_to_reorder), + modifier = Modifier.size(28.dp), // Icon größer als vorher (24dp → 28dp) + tint = if (isDragging) { + MaterialTheme.colorScheme.primary // Primary color während Drag + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + // Checkbox Checkbox( checked = item.isChecked, onCheckedChange = onCheckedChange, modifier = Modifier.alpha(alpha) ) - + Spacer(modifier = Modifier.width(4.dp)) - // 🆕 v1.8.0: Text Input mit Overflow-Gradient + // 🆕 v1.8.0: Text Input mit dynamischem Overflow-Gradient Box(modifier = Modifier.weight(1f)) { - BasicTextField( - value = textFieldValue, - onValueChange = { newValue -> - // Check for newline (Enter key) - if (newValue.text.contains("\n")) { - val cleanText = newValue.text.replace("\n", "") - textFieldValue = TextFieldValue( - text = cleanText, - selection = TextRange(cleanText.length) - ) - onTextChange(cleanText) - onAddNewItem() - } else { - textFieldValue = newValue - onTextChange(newValue.text) - } - }, - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester) - .onFocusChanged { focusState -> - isFocused = focusState.isFocused - } - .alpha(alpha), - textStyle = LocalTextStyle.current.copy( - color = MaterialTheme.colorScheme.onSurface, - textDecoration = textDecoration - ), - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Next - ), - keyboardActions = KeyboardActions( - onNext = { onAddNewItem() } - ), - singleLine = false, - maxLines = currentMaxLines, // 🆕 v1.8.0: Dynamisch - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - onTextLayout = { textLayoutResult -> - // 🆕 v1.8.0: Overflow erkennen - hasOverflow = textLayoutResult.hasVisualOverflow || - textLayoutResult.lineCount > COLLAPSED_MAX_LINES - }, - decorationBox = { innerTextField -> - Box { - if (textFieldValue.text.isEmpty()) { - Text( - text = stringResource(R.string.item_placeholder), - style = LocalTextStyle.current.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - ) - ) - } - innerTextField() - } + // Scrollbarer Wrapper: begrenzt Höhe auf ~5 Zeilen wenn collapsed + Box( + modifier = if (!isFocused && useScrollClipping) { + Modifier + .heightIn(max = collapsedHeightDp!!) + .verticalScroll(scrollState) + } else { + Modifier } - ) + ) { + BasicTextField( + value = textFieldValue, + onValueChange = { newValue -> + // Check for newline (Enter key) + if (newValue.text.contains("\n")) { + val cleanText = newValue.text.replace("\n", "") + textFieldValue = TextFieldValue( + text = cleanText, + selection = TextRange(cleanText.length) + ) + onTextChange(cleanText) + onAddNewItem() + } else { + textFieldValue = newValue + onTextChange(newValue.text) + } + }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + .onFocusChanged { focusState -> + isFocused = focusState.isFocused + } + .alpha(alpha), + textStyle = LocalTextStyle.current.copy( + color = MaterialTheme.colorScheme.onSurface, + textDecoration = textDecoration + ), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next + ), + keyboardActions = KeyboardActions( + onNext = { onAddNewItem() } + ), + singleLine = false, + // maxLines nur als Fallback bis collapsedHeight berechnet ist + maxLines = if (isFocused || useScrollClipping) Int.MAX_VALUE else COLLAPSED_MAX_LINES, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + onTextLayout = { textLayoutResult -> + // 🆕 v1.8.0: Overflow erkennen - ABER NUR wenn kein Drag aktiv ist + if (!isAnyItemDragging) { + val overflow = textLayoutResult.lineCount > COLLAPSED_MAX_LINES + hasOverflow = overflow + // Höhe der ersten 5 Zeilen berechnen (einmalig) + if (overflow && collapsedHeightDp == null) { + collapsedHeightDp = with(density) { + textLayoutResult.getLineBottom(COLLAPSED_MAX_LINES - 1).toDp() + } + } + } + }, + decorationBox = { innerTextField -> + Box { + if (textFieldValue.text.isEmpty()) { + Text( + text = stringResource(R.string.item_placeholder), + style = LocalTextStyle.current.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + ) + } + innerTextField() + } + } + ) + } - // 🆕 v1.8.0: Gradient-Fade Overlay wenn Text überläuft - // Zeige nur Gradient oben, da man am unteren Rand startet und nach oben scrollt - if (hasOverflow && !isFocused) { - // Gradient oben (zeigt: es gibt Text oberhalb der sichtbaren Zeilen) + // 🆕 v1.8.0: Dynamischer Gradient basierend auf Scroll-Position + // Oben: sichtbar wenn nach unten gescrollt (Text oberhalb versteckt) + if (showTopGradient) { OverflowGradient( modifier = Modifier.align(Alignment.TopCenter), isTopGradient = true ) } + + // Unten: sichtbar wenn noch Text unterhalb vorhanden + if (showBottomGradient) { + OverflowGradient( + modifier = Modifier.align(Alignment.BottomCenter), + isTopGradient = false + ) + } } - + Spacer(modifier = Modifier.width(4.dp)) - + // Delete Button IconButton( onClick = onDelete, @@ -232,7 +295,9 @@ private fun ChecklistItemRowShortTextPreview() { onTextChange = {}, onCheckedChange = {}, onDelete = {}, - onAddNewItem = {} + onAddNewItem = {}, + isDragging = false, + dragModifier = Modifier ) } @@ -253,7 +318,9 @@ private fun ChecklistItemRowLongTextPreview() { onTextChange = {}, onCheckedChange = {}, onDelete = {}, - onAddNewItem = {} + onAddNewItem = {}, + isDragging = false, + dragModifier = Modifier ) } @@ -269,6 +336,27 @@ private fun ChecklistItemRowCheckedPreview() { onTextChange = {}, onCheckedChange = {}, onDelete = {}, - onAddNewItem = {} + onAddNewItem = {}, + isDragging = false, + dragModifier = Modifier + ) +} + +// 🆕 v1.8.0: IMPL_023 - Preview for dragging state +@Preview(showBackground = true) +@Composable +private fun ChecklistItemRowDraggingPreview() { + ChecklistItemRow( + item = ChecklistItemState( + id = "preview-4", + text = "Wird gerade verschoben - Handle ist highlighted", + isChecked = false + ), + onTextChange = {}, + onCheckedChange = {}, + onDelete = {}, + onAddNewItem = {}, + isDragging = true, + dragModifier = Modifier ) }