From 89e3deb9f6a182d101fdbf9e69bd036154a5cc5c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 21:11:25 +0200 Subject: [PATCH] components: extract ReorderableColumn + DebugRibbon Family-generic widgets upstreamed from Calendula: ReorderableColumn (a dependency-free drag-to-reorder for the short grouped-card Settings lists; pairs with GroupedRow(gapBelow=false)) and DebugRibbon (a stark un-themed DEBUG corner ribbon). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../floret/components/DebugRibbon.kt | 50 +++++ .../floret/components/ReorderableColumn.kt | 183 ++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 components/src/main/kotlin/de/jeanlucmakiola/floret/components/DebugRibbon.kt create mode 100644 components/src/main/kotlin/de/jeanlucmakiola/floret/components/ReorderableColumn.kt diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/DebugRibbon.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/DebugRibbon.kt new file mode 100644 index 0000000..029c9c7 --- /dev/null +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/DebugRibbon.kt @@ -0,0 +1,50 @@ +package de.jeanlucmakiola.floret.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex + +/** + * A Flutter-style "DEBUG" corner ribbon, drawn across the top-right corner of + * the app. Deliberately a stark, un-themed marker (not a product component) so a + * debug build is unmistakable at a glance — gate it on `BuildConfig.DEBUG` at the + * call site so it never reaches a release build. Non-interactive: it's a plain + * label with no pointer handler, so taps fall through to whatever is beneath it. + * + * Drop it in as the last child of a full-screen [androidx.compose.foundation.layout.Box] + * so it overlays the UI. + */ +@Composable +fun BoxScope.DebugRibbon() { + Text( + text = "DEBUG", + color = Color.White, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.TopEnd) + .zIndex(1f) + // Push the band out so its midline crosses the very corner, then + // rotate it to the classic 45° ribbon. + .offset(x = 36.dp, y = 24.dp) + .rotate(45f) + .background(Color(0xFFB23B00)) + .width(140.dp) + .padding(vertical = 2.dp), + ) +} diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/ReorderableColumn.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/ReorderableColumn.kt new file mode 100644 index 0000000..d63ff1f --- /dev/null +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/ReorderableColumn.kt @@ -0,0 +1,183 @@ +package de.jeanlucmakiola.floret.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +/** Uniform row height for [ReorderableColumn]; a fixed pitch keeps drag maths exact. */ +val ReorderableRowHeight: Dp = 64.dp +private val RowGap: Dp = 2.dp + +/** + * A vertical list whose rows can be dragged into a new order by their handle. + * + * Built for the short, fixed grouped-card lists in Settings — no external + * dependency and no [androidx.compose.foundation.lazy.LazyColumn] (the settings + * screens are a single [androidx.compose.foundation.verticalScroll] column, which + * can't nest a scrolling list). Rows are a fixed [ReorderableRowHeight] with a + * uniform gap, so a row's target slot is simply how many whole pitches it has + * been dragged. The held row follows the finger while the others slide out of + * its way (animated); on release the order is committed immediately with a + * single [onReorder] call, then the held row eases into its new slot as a + * purely visual settle — safe to interrupt with another drag, since the + * commit itself never waits on it. + * + * [rowContent] receives the [Position] for the row's place in the order (to reuse + * [GroupedRow]'s card shaping — pass `gapBelow = false` there, this owns spacing) + * and a `dragHandle` [Modifier] to attach to the element that starts a drag. + */ +@Composable +fun ReorderableColumn( + items: List, + keyOf: (T) -> Any, + onReorder: (List) -> Unit, + modifier: Modifier = Modifier, + rowContent: @Composable (item: T, position: Position, dragHandle: Modifier, isDragging: Boolean) -> Unit, +) { + val pitchPx = with(LocalDensity.current) { (ReorderableRowHeight + RowGap).toPx() } + val scope = rememberCoroutineScope() + + // Local working copy; re-seeded when the incoming list changes (including the + // echo of our own committed order). + var order by remember(items) { mutableStateOf(items) } + var draggedKey by remember { mutableStateOf(null) } + // Live translation of the held row from its slot (px); also drives the + // release settle — re-based onto the row's new slot once the order commits + // (see onDragEnd), then eased down to zero. + var dragOffset by remember { mutableFloatStateOf(0f) } + // The running release/cancel settle, cancelled if a new drag pre-empts it. + // Purely visual — the order commit (see onDragEnd) never depends on it. + var settleJob by remember { mutableStateOf(null) } + + val draggedIndex = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } } + // Whole slots dragged → the slot the held row currently hovers over. Derived + // so recomposition only fires when the *target slot* actually changes, not on + // every dragged pixel: dragOffset moves every frame during a drag, but the + // held row's own translation is already applied in the draw phase via + // graphicsLayer below, so only the neighbours' shift (which depends on this) + // needs to recompose, and only when a slot boundary is actually crossed. + // (Read as a plain val, not `by`, below: a delegated property has a custom + // getter and Kotlin won't smart-cast it in the `when` over `targetIndex` in + // the loop, so the derived value is captured into a real local instead.) + val targetIndex = remember(items, pitchPx) { + derivedStateOf { + val idx = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } } + idx?.let { (it + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex) } + } + }.value + + Column(modifier, verticalArrangement = Arrangement.spacedBy(RowGap)) { + order.forEachIndexed { index, item -> + val key = keyOf(item) + val isDragged = key == draggedKey + + // Slide neighbours by one pitch to open the gap the held row will drop + // into. Snap (not animate) once idle, so committing the new order — which + // moves each row's slot — doesn't visibly fight a lingering animation. + val shift = when { + draggedIndex == null || targetIndex == null || isDragged -> 0f + index in (draggedIndex + 1)..targetIndex -> -pitchPx + index in targetIndex until draggedIndex -> pitchPx + else -> 0f + } + val animatedShift by animateFloatAsState( + targetValue = shift, + animationSpec = if (draggedKey != null) spring(stiffness = Spring.StiffnessMediumLow) else snap(), + label = "reorderShift", + ) + + val dragHandle = Modifier.pointerInput(key) { + detectDragGestures( + onDragStart = { + settleJob?.cancel() + draggedKey = key + dragOffset = 0f + }, + onDrag = { change, amount -> + change.consume() + dragOffset += amount.y + }, + onDragEnd = { + val from = order.indexOfFirst { keyOf(it) == key } + if (from < 0) return@detectDragGestures + val to = (from + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex) + if (to != from) { + // Commit synchronously and unconditionally, before the settle + // animation below runs — the commit must not depend on that + // coroutine reaching its end, or a new drag starting within the + // ~160ms settle window would cancel it and silently revert an + // already-finished reorder. + order = order.toMutableList().apply { add(to, removeAt(from)) } + onReorder(order) + // The row now lays out at slot `to` instead of `from`; re-base + // the live offset onto that new slot (same visual position, + // expressed relative to the new one) so the settle below eases + // it the rest of the way instead of jumping. + dragOffset -= (to - from) * pitchPx + } + settleJob = scope.launch { + // Purely visual from here: ease the held row onto its slot, then + // release the drag state. Safe to cancel — the order was already + // committed above. + Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value } + draggedKey = null + dragOffset = 0f + } + }, + onDragCancel = { + settleJob = scope.launch { + Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value } + draggedKey = null + dragOffset = 0f + } + }, + ) + } + + Box( + Modifier + .height(ReorderableRowHeight) + .zIndex(if (isDragged) 1f else 0f) + .graphicsLayer { + translationY = if (isDragged) dragOffset else animatedShift + if (isDragged) { + scaleX = 1.02f + scaleY = 1.02f + shadowElevation = 8.dp.toPx() + shape = RoundedCornerShape(20.dp) + clip = false + } + }, + ) { + rowContent(item, positionOf(index, order.size), dragHandle, isDragged) + } + } + } +}