Skip to content

Swipe actions#425

Open
Davedeji wants to merge 20 commits into
skiptools:mainfrom
FourFourSoftware:swipe-actions
Open

Swipe actions#425
Davedeji wants to merge 20 commits into
skiptools:mainfrom
FourFourSoftware:swipe-actions

Conversation

@Davedeji
Copy link
Copy Markdown

@Davedeji Davedeji commented May 14, 2026

Thank you for contributing to the Skip project! Please review the contribution guide at https://skip.dev/docs/contributing/ for advice and guidance on making high-quality PRs.

Use this space to describe your change and add any labels (bug, enhancement, documentation, etc.) to help categorize your contribution.

Skip Pull Request Checklist:

  • REQUIRED: I have signed the Contributor Agreement
  • REQUIRED: I have tested my change locally with swift test
  • OPTIONAL: I have tested my change on an iOS simulator or device
  • OPTIONAL: I have tested my change on an Android emulator or device
  • REQUIRED: I have checked whether this change requires a corresponding update in the Skip Fuse UI repository (link related PR if applicable)
  • OPTIONAL: I have added an example of any UI changes in the Showcase sample app

  • AI was used to generate or assist with generating this PR. Please specify below how you used AI to help you, and what steps you have taken to manually verify the changes.

Ai helped reading through existing patterns in other skip libraries for preferred code style / comments. Ai was also used in the development and iteration of the animation. I have tested this package running the swift tests and using it as a forked package in our app in development.


Davedeji and others added 17 commits May 13, 2026 18:23
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>
@cla-bot cla-bot Bot added the cla-signed label May 14, 2026
@Davedeji
Copy link
Copy Markdown
Author

@marcprux
Copy link
Copy Markdown
Member

This will be a great addition! I've been hoping that Compose would provide some built-in feature for this beyond what SwipeToDismissBox provides, but it doesn't look like anything is on the horizon.

Did you consider using anchoredDraggable for this, which is how libraries like revealable accomplish the same effect? That could help simplify the feature implementation.

Also, while not strictly necessary, we like to have some demonstration of new features like this submitted as PRs to https://github.com/skiptools/skipapp-showcase (as discussed at https://skip.dev/docs/contributing/#skipui-and-skipfuseui), which eases the process of testing the new feature as well as ensuring that it continues working.

Combine several reveal-rendering improvements:

- Two layout modes for the buttons-row:
  * Natural: revealedPx ≤ naturalRow. Each button uses
    widthIn(min: minButtonWidthDp) and the Row wraps content, so a
    long label like "Add to favorites" stays on a single line. An
    onSizeChanged captures the resulting natural row width.
  * Stretch: revealedPx > naturalRow. Row width is set to revealedPx
    and each button is wrapped in Box(Modifier.weight(w).fillMaxHeight())
    so they share the row's extra space proportionally — buttons
    track the foreground's edge instead of leaving a gap.

- Full-swipe takeover (in stretch mode): when the user drags past
  the trigger fraction (75% of row width), the primary action — the
  destructive button if present, otherwise the edge-most button —
  absorbs all extra width and the others shrink to zero. Weights:
  primary = 1 + (count - 1) × takeoverAnim, others = 1 - takeoverAnim.
  animateFloatAsState smooths the transition; buttons with weight
  below 0.01 are skipped so the runtime never sees Modifier.weight(0).

- Open-anchor magnitudes now use the measured natural width with a
  count × minButtonWidth fallback before the first measurement
  lands. Fixes the first-frame race noted in the prior review where
  the snap solver could pick "closed" on the very first gesture.

- Trash-vector guard: stop force-unwrapping
  Image.composeImageVector(named: "trash") inside the implicit
  onDelete SwipeToDismissBox path; render the red background
  unconditionally and only draw the icon when the vector resolves
  so a missing asset can't crash inside a LazyColumn item composable.

- Centralized tuning knobs (swipeVelocityProjectionSeconds,
  swipeFullSwipeFraction, swipeScrimMaxAlpha, swipeButtonMinWidth)
  as private static let on List so the gesture feel can be tuned
  without spelunking through RenderSwipeableItem.

- Comments converted to /* */ block style for the multi-line
  blocks added on this branch, and the line-number reference in
  the destructive-color comment replaced with a semantic phrasing
  ("the implicit onDelete SwipeToDismissBox") that won't rot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Davedeji
Copy link
Copy Markdown
Author

This will be a great addition! I've been hoping that Compose would provide some built-in feature for this beyond what SwipeToDismissBox provides, but it doesn't look like anything is on the horizon.

Did you consider using anchoredDraggable for this, which is how libraries like revealable accomplish the same effect? That could help simplify the feature implementation.

Also, while not strictly necessary, we like to have some demonstration of new features like this submitted as PRs to https://github.com/skiptools/skipapp-showcase (as discussed at https://skip.dev/docs/contributing/#skipui-and-skipfuseui), which eases the process of testing the new feature as well as ensuring that it continues working.

@marcprux good point! refactored to use AnchorDraggable. Will take a look at the showcase app and add a quick sample now

@Davedeji
Copy link
Copy Markdown
Author

CleanShot.2026-05-14.at.18.34.31.mp4

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants