fix(ui): keep drag alive across swaps + equalize Add/Added pill height

- Drag reorder: key each preview tab by its stable identity. Without a key the
  tabs were position-identified, so when a swap reordered the list, the slot under
  the finger recomposed with a different entry — restarting its pointerInput (keyed
  on the entry) and cancelling the in-flight gesture, so dragging stopped on the
  first swap. Keying moves the dragged composable (and its live gesture) instead.
- Add/Added button: both states now share one Surface + Row body (only color,
  border and tint differ) instead of an OutlinedButton vs a Surface. The
  OutlinedButton's ~40dp min height made "Add" taller than "Added" and broke row
  alignment; a single body keeps the pill height constant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mxdSFQcarsKaL179tSub1
This commit is contained in:
Claude
2026-07-15 18:12:19 +00:00
parent 8212e52a50
commit e1a9a5cd7e
@@ -34,7 +34,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
@@ -43,13 +42,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -57,6 +54,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
@@ -285,52 +283,57 @@ private fun EditableBar(
verticalAlignment = Alignment.CenterVertically,
) {
pinned.forEachIndexed { index, entry ->
val dragging = draggedIndex == index
PreviewTab(
entry = entry,
// The first tab is where the bar lands on open, so preview it as selected.
selected = index == 0,
dragging = dragging,
dragOffsetX = if (dragging) dragOffsetX else 0f,
accountViewModel = accountViewModel,
onRemove = { state.togglePin(entry) },
onMeasured = { widths[index] = it },
onDragStart = {
draggedIndex = index
dragOffsetX = 0f
},
onDrag = { dx ->
dragOffsetX += dx
val current = draggedIndex
if (current < 0) return@PreviewTab
// Key by identity so a swap MOVES the dragged tab (and its live gesture) instead of
// recomposing a different entry into this slot — which would restart its pointerInput
// (keyed on entry) and cancel the drag mid-swap.
key(entry.stableKey) {
val dragging = draggedIndex == index
PreviewTab(
entry = entry,
// The first tab is where the bar lands on open, so preview it as selected.
selected = index == 0,
dragging = dragging,
dragOffsetX = if (dragging) dragOffsetX else 0f,
accountViewModel = accountViewModel,
onRemove = { state.togglePin(entry) },
onMeasured = { widths[index] = it },
onDragStart = {
draggedIndex = index
dragOffsetX = 0f
},
onDrag = { dx ->
dragOffsetX += dx
val current = draggedIndex
if (current < 0) return@PreviewTab
if (dragOffsetX < 0 && current > 0) {
val leftW = widths[current - 1] ?: 0f
if (-dragOffsetX > leftW / 2f) {
state.moveTransient(current, current - 1)
dragOffsetX += leftW
draggedIndex = current - 1
if (dragOffsetX < 0 && current > 0) {
val leftW = widths[current - 1] ?: 0f
if (-dragOffsetX > leftW / 2f) {
state.moveTransient(current, current - 1)
dragOffsetX += leftW
draggedIndex = current - 1
}
}
}
if (dragOffsetX > 0 && current < pinned.lastIndex) {
val rightW = widths[current + 1] ?: 0f
if (dragOffsetX > rightW / 2f) {
state.moveTransient(current, current + 1)
dragOffsetX -= rightW
draggedIndex = current + 1
if (dragOffsetX > 0 && current < pinned.lastIndex) {
val rightW = widths[current + 1] ?: 0f
if (dragOffsetX > rightW / 2f) {
state.moveTransient(current, current + 1)
dragOffsetX -= rightW
draggedIndex = current + 1
}
}
}
},
onDragEnd = {
draggedIndex = -1
dragOffsetX = 0f
state.commit()
},
onDragCancel = {
draggedIndex = -1
dragOffsetX = 0f
},
)
},
onDragEnd = {
draggedIndex = -1
dragOffsetX = 0f
state.commit()
},
onDragCancel = {
draggedIndex = -1
dragOffsetX = 0f
},
)
}
}
}
}
@@ -686,38 +689,39 @@ private fun ExpandableAvailableRow(
}
}
/** Outlined "Add" that fills to "Added" once pinned — states the action and its result. */
/**
* Outlined "Add" that fills to "Added" once pinned — states the action and its result. Both states
* share one Row body (only color/border/tint differ) so the pill keeps a constant height and the rows
* stay aligned whether an item is added or not.
*/
@Composable
private fun AddPill(
added: Boolean,
onClick: () -> Unit,
) {
val accent = MaterialTheme.colorScheme.primary
if (added) {
Surface(shape = CircleShape, color = accent) {
Row(
modifier = Modifier.clickable(onClick = onClick).padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
symbol = MaterialSymbols.Check,
contentDescription = null,
modifier = Modifier.size(15.dp),
tint = MaterialTheme.colorScheme.onPrimary,
)
Text(stringRes(R.string.bottom_bar_settings_added), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onPrimary)
}
}
} else {
OutlinedButton(
onClick = onClick,
border = BorderStroke(1.dp, accent),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
val content = if (added) MaterialTheme.colorScheme.onPrimary else accent
Surface(
shape = CircleShape,
color = if (added) accent else Color.Transparent,
border = if (added) null else BorderStroke(1.dp, accent),
) {
Row(
modifier = Modifier.clickable(onClick = onClick).padding(horizontal = 14.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(symbol = MaterialSymbols.Add, contentDescription = null, modifier = Modifier.size(15.dp), tint = accent)
Spacer(Modifier.width(4.dp))
Text(stringRes(R.string.bottom_bar_settings_add), style = MaterialTheme.typography.labelLarge, color = accent)
Icon(
symbol = if (added) MaterialSymbols.Check else MaterialSymbols.Add,
contentDescription = null,
modifier = Modifier.size(15.dp),
tint = content,
)
Text(
text = stringRes(if (added) R.string.bottom_bar_settings_added else R.string.bottom_bar_settings_add),
style = MaterialTheme.typography.labelLarge,
color = content,
)
}
}
}