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.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
@@ -22,6 +23,8 @@ import kotlinx.coroutines.launch
*
* 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()

View File

@@ -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,

View File

@@ -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,8 +81,19 @@ 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<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)
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
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)
)
}
}
@@ -99,21 +129,27 @@ fun ChecklistItemRow(
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(
@@ -124,74 +160,101 @@ fun ChecklistItemRow(
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))
@@ -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
)
}