Davedeji · GitHub

Replace the View.swipeActions(...) unavailable stub with a working
implementation backed by AnchoredDraggable-style horizontal drag in
Compose. Buttons declared inside swipeActions(content:) are extracted
via ComposeBuilder.Evaluate, rendered as Material reveal cells pinned
to the swiped edge, and tinted by ButtonRole (.destructive uses
MaterialTheme.colorScheme.error).
Adds SwipeActionsModifier (RenderModifier) under Commands/, mirrors
the EditActionsModifier collector pattern, and wires combined(for:)
into RenderEditableItem so user-provided .swipeActions takes precedence
over the implicit onDelete trash-swipe. Falling back to the existing
SwipeToDismissBox when only onDelete is set keeps the prior behavior.
Supports leading + trailing edges, allowsFullSwipe (full-swipe fires
the first declared action), and per-button tap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns with how alert(), deleteDisabled() etc. expose themselves
across the Skip Fuse JNI bridge: a public @ViewBuilder-taking entry
point that returns any View, plus a bridged twin that takes a
pre-built any View and an Int rawValue for HorizontalEdge (the enum
itself is gated behind #if !SKIP_BRIDGE so it can't cross the bridge
directly).
Required so SkipFuseUI's SkipSwiftUI.View can call into SkipUI's
swipeActions from the SKIP_BRIDGE-mode Android Swift compilation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs that cause "Vertically scrollable component was measured
with an infinity maximum height constraints" when a swipeable row
is placed inside List's LazyColumn:
1. RenderItem was being called with Modifier.fillMaxSize(), which
   tells the inner Column to fill height — but a LazyColumn item
   is measured with Constraints(maxHeight = Infinity), so this
   propagates an infinite request upward and crashes. Drop the
   modifier so the row sizes itself to its content (matching the
   existing onDelete SwipeToDismissBox path one branch over).
2. BoxWithConstraints + matchParentSize for backgrounds combined
   with Modifier.fillMaxSize() on the foreground formed a circular
   sizing constraint with no concrete child. Replace with a regular
   Box that captures its width via onSizeChanged for the full-swipe
   anchor. Foreground is fillMaxWidth (no height fill); background
   reveal Rows use matchParentSize() so they adopt the foreground's
   measured size without forcing the parent to grow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tighten the gesture so any meaningful swipe carries the row to the
open anchor instead of demanding the user drag past 50% of the
buttons-row width. New thresholds:
- 15% of buttons-row width snaps open (was 50%)
- 50% of full row width fires the destructive full-swipe action
- A flick past 600 dp/s opens regardless of position
- A flick past 1200 dp/s triggers the full-swipe action
- onDragStopped's velocity is forwarded to Animatable.animateTo's
  initialVelocity so the snap animation continues the gesture's
  momentum instead of starting from rest
Matches iOS feel: small intentional swipe → opens; tiny accidental
wiggle → snaps back; fast flick across the row → destructive action.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the directional ("if cur < 0 then snap to trailing-open")
release logic with a generic nearest-anchor solver:
  let projected = cur + velocity * 0.15s
  target = anchors.minBy { abs(it - projected) }
This guarantees the row always lands on a defined anchor (closed,
trailing-open, leading-open, or full-swipe) and never stops mid-track.
It also fixes the open→close direction: dragging back from the open
anchor projects past 0 with the closing velocity and snaps shut, even
if the finger lifted while the row was still half-open. The previous
logic kept saying "still in trailing region, snap to trailing-open"
and the row would re-open.
Full-swipe escalation only kicks in when the projected position has
crossed half the row width past the open anchor — preserves the
destructive-action gesture.
abs() inlined as a ternary because Skip's transpilation didn't
resolve abs(Float) against any of skip.lib / kotlin.math overloads.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause of the snap animation freezing partway: the drag callback
was launching coroutineScope { offsetState.snapTo(target) } per delta,
and onDragStopped was calling offsetState.animateTo(target) on the
same Animatable. The launched snapTo coroutines run on Dispatchers.Main
but not necessarily synchronously with the suspend onDragStopped, so
one final queued snapTo would land *after* animateTo started, cancel
it (snapTo always cancels the current animation), and leave the row
frozen at whatever offset the snap landed on.
Fix: split the two responsibilities.
- Drag-time tracking uses a plain mutableFloatStateOf, updated
  synchronously inside the drag callback. No coroutines, no race.
- Snap-back uses Compose's top-level animate(initialValue, targetValue,
  initialVelocity) suspend, called directly inside onDragStopped's
  own suspend coroutine. The trailing block writes each frame's value
  back to the mutableFloatStateOf. There is no shared Animatable for
  pending snapTo calls to step on. If the user starts a new gesture
  mid-snap, Compose cancels the onDragStopped coroutine cleanly and
  the next drag picks up wherever the offset state currently is.
Tap-to-fire on a revealed action button now uses the same animate(...)
suspend instead of Animatable.animateTo, so dismissing via tap is also
guaranteed to complete.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Velocity alone could fire the destructive full-swipe action because
the threshold was checked against the velocity-projected position. A
quick flick that ended at, say, -200px on a 1080px row would project
to roughly -650px and cross the 50% mark, triggering the destructive
action even though the user had only dragged a fifth of the way.
Two changes:
- Check the full-swipe threshold against the *actual* drag position
  (cur), not the projected position. Velocity still influences which
  open/closed anchor we snap to via the projection, but it can no
  longer single-handedly fire the destructive action.
- Raise the threshold from 50% of row width to 75%. The user has to
  physically commit to a long swipe.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Match iOS list behavior — opening another row's swipe actions closes
the previously-open one.
Implementation: a List-scoped MutableState<String?> (activeSwipeKey)
remembered alongside the LazyColumn captures whichever row currently
has its swipe panel revealed. RenderEditableItem threads it through
to RenderSwipeableItem, which:
- Sets activeSwipeKey.value = rowKey when its drag commits to an
  open anchor; clears it when the row settles back to closed or
  fires a full-swipe destructive action.
- Observes activeSwipeKey via LaunchedEffect; if the active row
  becomes anyone other than this row and this row is still open,
  it animates closed in parallel.
Tap-to-fire on a revealed action also clears the key so the next
row to be swiped doesn't see this one as the contender.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a row had buttons on both edges, a full swipe in one direction
moved the foreground entirely off-screen and uncovered the opposite
edge's button cluster (clustered there by Arrangement.Start /
Arrangement.End). Visually it looked like the wrong action was
about to be triggered.
Gate each reveal Row on the current offset's sign: leading buttons
only render while offset >= 0 (the swipe hasn't gone trailing-ward),
trailing only while offset <= 0. At rest both render but neither is
visible because the foreground covers them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two visual touch-ups:
1. Clip the swipeable container to its bounds via Modifier.clipToBounds()
   so the foreground row can't visually escape the cell rectangle while
   sliding. Previously a hard swipe (especially full-swipe in either
   direction) could draw the row past its original border and bleed
   into the next cell or list edge.
2. Stack a light-gray scrim Box on top of the foreground content with
   alpha proportional to swipe progress (offset distance / open-anchor
   distance), capped at 0.18. The cell appears to "recede" as the
   actions emerge, with no scrim at rest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
If a row was already open with trailing actions visible, a continuous
right-ward drag past zero would carry the row into leading territory
and reveal the leading actions instead of just closing. Same in the
mirror direction.
Capture the offset at onDragStarted; while the gesture is in flight,
clamp the allowable range:
- Started open trailing (start < 0)  → drag bounded [trailingFullPx, 0]
- Started open leading  (start > 0)  → drag bounded [0, leadingFullPx]
- Started closed       (start == 0) → full range, can swipe either way
The release-time nearest-anchor logic doesn't need changes; with the
offset clamped, the projected position can only land between closed
and the same-side open anchor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The drag-time clamp prevented the offset from crossing zero into the
opposite-edge range, but the release-time projection still used the
full [minOffsetPx, maxOffsetPx]. A hard rightward flick from an open
trailing state would produce cur=0 (clamped), velocity>0, projected =
positive number coerced into the leading half — and the nearest-anchor
solver picked leadingOpenPx.
Apply the same start-side constraint to the projection range, and
filter the candidate anchor set to exclude the opposite-side open
anchor entirely. A reverse-direction flick now snaps to closed even
when it would have projected into the opposite half.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two iOS-parity behaviors:
1. Destructive button moves to the swipe-from edge regardless of the
   order it was declared in the @ViewBuilder. Trailing-edge: pushed
   to the rightmost position (last in the laid-out Row). Leading-edge:
   pushed to the leftmost position (first in the Row). The full-swipe
   gesture also targets the destructive button, not the first declared.
2. When a destructive full-swipe fires AND the List has either an
   onDelete handler or a deletable objectsBinding wired up, we
   invoke that deletion. LazyColumn's animateItem then collapses the
   row visually — matching the "destructive full-swipe deletes the
   row" feel from iOS. If no deletion plumbing exists, the row just
   bounces back as before; the user's destructive Button is still
   responsible for any side effects (toast, undo, etc.).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Full-swipe through a destructive action already routed through
onDestructiveDelete, but a *tap* on the destructive button only ran
the user's Button.action and animated closed — leaving the row in
place even when the surrounding ForEach had an .onDelete handler.
Have the per-button onTap closures consult button.role and invoke
the captured onDestructiveDelete?() right after the user's action,
matching iOS behavior where tapping a .destructive list-row action
removes the row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pull together several reveal-button rendering improvements:
- Use Color.Red / Color.White for destructive buttons so the bright
  red matches the implicit onDelete trash-swipe a few lines above
  in this same file (was MaterialTheme.colorScheme.error, a deeper
  theme-driven shade that looked mismatched in the same list).
- Lay out the label inside a centered Row instead of a Box so multi-
  view labels (Image + Text, Label, etc.) flow horizontally instead
  of stacking on top of each other at the Box center.
- Compose the user's label view directly via .Compose(context:),
  routing the font through .font(Font.subheadline) view modifier
  (Skip's transpilation doesn't expose EnvironmentValues.font as
  a Kotlin setter, so the env-set form fails to compile).
- Add interior padding (12dp horizontal, 8dp vertical) around the
  label so icons/text don't touch the cell edges.
- Make button width dynamic: widthIn(min: 96.dp) + content sizing
  so labels (Pin, Delete, longer custom strings) fit on a single
  line. 96dp is Material's comfortable touch target with breathing
  room — closes the gap that appeared when text content was narrow.
- Capture the actual buttons-row width per edge via Modifier.
  onSizeChanged on a wrapping Box(matchParentSize, contentAlignment:
  CenterStart/CenterEnd) → Row(wrapContent), and use that measured
  width for the open-anchor distance. The row now opens to exactly
  the space the buttons need, regardless of how many buttons or how
  wide each one is.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@Davedeji

@Davedeji

Read the original on github.com ↗