feat(v1.8.0): IMPL_023b Drag & Drop Flicker Fix - Straddle-Target-Center Detection

- 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)
This commit is contained in:
inventory69
2026-02-09 13:21:03 +01:00
parent 3462f93f25
commit 538a705def
3 changed files with 204 additions and 100 deletions

View File

@@ -9,6 +9,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
@@ -22,6 +23,8 @@ import kotlinx.coroutines.launch
* *
* Native Compose-Implementierung ohne externe Dependencies * Native Compose-Implementierung ohne externe Dependencies
* 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_023b - Flicker-Fix (Straddle-Target-Center-Erkennung statt Mittelpunkt)
*/ */
class DragDropListState( class DragDropListState(
private val state: LazyListState, private val state: LazyListState,
@@ -64,11 +67,17 @@ class DragDropListState(
val startOffset = draggingItem.offset + draggingItemOffset val startOffset = draggingItem.offset + draggingItemOffset
val endOffset = startOffset + draggingItem.size val endOffset = startOffset + draggingItem.size
val middleOffset = startOffset + (endOffset - startOffset) / 2f // 🆕 v1.8.0: IMPL_023b — Straddle-Target-Center + Adjazenz-Filter
// Statt den Mittelpunkt des gezogenen Items zu prüfen ("liegt mein Zentrum im Target?"),
val targetItem = state.layoutInfo.visibleItemsInfo.find { item -> // wird geprüft ob das gezogene Item den MITTELPUNKT des Targets überspannt.
middleOffset.toInt() in item.offset..item.offsetEnd && // Dies verhindert Oszillation bei Items unterschiedlicher Größe.
draggingItem.index != item.index // 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) { if (targetItem != null) {
@@ -84,12 +93,13 @@ class DragDropListState(
scope.launch { scope.launch {
state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset) state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset)
onMove(draggingItem.index, targetItem.index) onMove(draggingItem.index, targetItem.index)
// 🆕 v1.8.0: IMPL_023b — Index-Update NACH dem Move (verhindert Race-Condition)
draggingItemIndex = targetItem.index
} }
} else { } else {
onMove(draggingItem.index, targetItem.index) onMove(draggingItem.index, targetItem.index)
}
draggingItemIndex = targetItem.index draggingItemIndex = targetItem.index
}
} else { } else {
val overscroll = when { val overscroll = when {
draggingItemDraggedDelta > 0 -> draggingItemDraggedDelta > 0 ->
@@ -130,14 +140,16 @@ fun rememberDragDropListState(
} }
} }
@Composable
fun Modifier.dragContainer( fun Modifier.dragContainer(
dragDropState: DragDropListState, dragDropState: DragDropListState,
itemIndex: Int itemIndex: Int
): Modifier { ): 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( detectDragGesturesAfterLongPress(
onDragStart = { offset -> onDragStart = { offset ->
dragDropState.onDragStart(offset, itemIndex) dragDropState.onDragStart(offset, currentIndex.value) // Aktuellen Wert lesen
}, },
onDragEnd = { onDragEnd = {
dragDropState.onDragInterrupted() dragDropState.onDragInterrupted()

View File

@@ -50,6 +50,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.NoteType import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.ui.editor.components.ChecklistItemRow import dev.dettmer.simplenotes.ui.editor.components.ChecklistItemRow
@@ -351,14 +352,17 @@ private fun ChecklistEditor(
onDelete = { onDelete(item.id) }, onDelete = { onDelete(item.id) },
onAddNewItem = { onAddNewItemAfter(item.id) }, onAddNewItem = { onAddNewItemAfter(item.id) },
requestFocus = shouldFocus, 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 modifier = Modifier
.dragContainer(dragDropState, index)
.offset { .offset {
IntOffset( IntOffset(
0, 0,
if (isDragging) dragDropState.draggingItemOffset.roundToInt() else 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)) .shadow(elevation, shape = RoundedCornerShape(8.dp))
.background( .background(
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,

View File

@@ -4,12 +4,15 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.DragHandle 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.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange 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.input.TextFieldValue
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.ui.editor.ChecklistItemState 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.5.0: Jetpack Compose NoteEditor Redesign
* v1.8.0: Long text UX improvements (gradient fade, auto-expand on focus) * 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 @Composable
fun ChecklistItemRow( fun ChecklistItemRow(
@@ -57,12 +63,16 @@ fun ChecklistItemRow(
onDelete: () -> Unit, onDelete: () -> Unit,
onAddNewItem: () -> Unit, onAddNewItem: () -> Unit,
requestFocus: Boolean = false, 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 modifier: Modifier = Modifier
) { ) {
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
val density = LocalDensity.current
var textFieldValue by remember(item.id) { 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 // 🆕 v1.8.0: Focus-State tracken für Expand/Collapse
@@ -71,8 +81,19 @@ fun ChecklistItemRow(
// 🆕 v1.8.0: Overflow erkennen (Text länger als maxLines) // 🆕 v1.8.0: Overflow erkennen (Text länger als maxLines)
var hasOverflow by remember { mutableStateOf(false) } var hasOverflow by remember { mutableStateOf(false) }
// 🆕 v1.8.0: Dynamische maxLines basierend auf Focus // 🆕 v1.8.0: Höhe für collapsed-Ansicht (aus TextLayout berechnet)
val currentMaxLines = if (isFocused) Int.MAX_VALUE else COLLAPSED_MAX_LINES var collapsedHeightDp by remember { mutableStateOf<Dp?>(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) // v1.5.0: Auto-focus AND show keyboard when requestFocus is true (new items)
LaunchedEffect(requestFocus) { LaunchedEffect(requestFocus) {
@@ -82,12 +103,21 @@ fun ChecklistItemRow(
} }
} }
// 🆕 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 // Update text field when external state changes
LaunchedEffect(item.text) { LaunchedEffect(item.text) {
if (textFieldValue.text != item.text) { if (textFieldValue.text != item.text) {
textFieldValue = TextFieldValue( textFieldValue = TextFieldValue(
text = item.text, text = item.text,
selection = TextRange(item.text.length) selection = if (isFocused) TextRange(item.text.length) else TextRange(0)
) )
} }
} }
@@ -99,21 +129,27 @@ fun ChecklistItemRow(
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(end = 8.dp, top = 4.dp, bottom = 4.dp), // 🆕 v1.8.0: IMPL_023 - links kein Padding (Handle hat eigene Fläche)
verticalAlignment = Alignment.Top // 🆕 v1.8.0: Top statt CenterVertically für lange Texte verticalAlignment = if (hasOverflow) Alignment.Top else Alignment.CenterVertically // 🆕 v1.8.0: Dynamisch
) {
// 🆕 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
) { ) {
// Drag Handle
Icon( Icon(
imageVector = Icons.Default.DragHandle, imageVector = Icons.Default.DragHandle,
contentDescription = stringResource(R.string.drag_to_reorder), contentDescription = stringResource(R.string.drag_to_reorder),
modifier = Modifier modifier = Modifier.size(28.dp), // Icon größer als vorher (24dp → 28dp)
.size(24.dp) tint = if (isDragging) {
.padding(top = 12.dp) // 🆕 v1.8.0: Visuell am oberen Rand ausrichten MaterialTheme.colorScheme.primary // Primary color während Drag
.alpha(0.5f), } else {
tint = MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
}
) )
}
Spacer(modifier = Modifier.width(4.dp))
// Checkbox // Checkbox
Checkbox( Checkbox(
@@ -124,8 +160,18 @@ fun ChecklistItemRow(
Spacer(modifier = Modifier.width(4.dp)) 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)) { Box(modifier = Modifier.weight(1f)) {
// Scrollbarer Wrapper: begrenzt Höhe auf ~5 Zeilen wenn collapsed
Box(
modifier = if (!isFocused && useScrollClipping) {
Modifier
.heightIn(max = collapsedHeightDp!!)
.verticalScroll(scrollState)
} else {
Modifier
}
) {
BasicTextField( BasicTextField(
value = textFieldValue, value = textFieldValue,
onValueChange = { newValue -> onValueChange = { newValue ->
@@ -161,12 +207,21 @@ fun ChecklistItemRow(
onNext = { onAddNewItem() } onNext = { onAddNewItem() }
), ),
singleLine = false, singleLine = false,
maxLines = currentMaxLines, // 🆕 v1.8.0: Dynamisch // maxLines nur als Fallback bis collapsedHeight berechnet ist
maxLines = if (isFocused || useScrollClipping) Int.MAX_VALUE else COLLAPSED_MAX_LINES,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
onTextLayout = { textLayoutResult -> onTextLayout = { textLayoutResult ->
// 🆕 v1.8.0: Overflow erkennen // 🆕 v1.8.0: Overflow erkennen - ABER NUR wenn kein Drag aktiv ist
hasOverflow = textLayoutResult.hasVisualOverflow || if (!isAnyItemDragging) {
textLayoutResult.lineCount > COLLAPSED_MAX_LINES 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 -> decorationBox = { innerTextField ->
Box { Box {
@@ -182,16 +237,24 @@ fun ChecklistItemRow(
} }
} }
) )
}
// 🆕 v1.8.0: Gradient-Fade Overlay wenn Text überläuft // 🆕 v1.8.0: Dynamischer Gradient basierend auf Scroll-Position
// Zeige nur Gradient oben, da man am unteren Rand startet und nach oben scrollt // Oben: sichtbar wenn nach unten gescrollt (Text oberhalb versteckt)
if (hasOverflow && !isFocused) { if (showTopGradient) {
// Gradient oben (zeigt: es gibt Text oberhalb der sichtbaren Zeilen)
OverflowGradient( OverflowGradient(
modifier = Modifier.align(Alignment.TopCenter), modifier = Modifier.align(Alignment.TopCenter),
isTopGradient = true 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)) Spacer(modifier = Modifier.width(4.dp))
@@ -232,7 +295,9 @@ private fun ChecklistItemRowShortTextPreview() {
onTextChange = {}, onTextChange = {},
onCheckedChange = {}, onCheckedChange = {},
onDelete = {}, onDelete = {},
onAddNewItem = {} onAddNewItem = {},
isDragging = false,
dragModifier = Modifier
) )
} }
@@ -253,7 +318,9 @@ private fun ChecklistItemRowLongTextPreview() {
onTextChange = {}, onTextChange = {},
onCheckedChange = {}, onCheckedChange = {},
onDelete = {}, onDelete = {},
onAddNewItem = {} onAddNewItem = {},
isDragging = false,
dragModifier = Modifier
) )
} }
@@ -269,6 +336,27 @@ private fun ChecklistItemRowCheckedPreview() {
onTextChange = {}, onTextChange = {},
onCheckedChange = {}, onCheckedChange = {},
onDelete = {}, 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
) )
} }