Swipe actions#425
Conversation
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>
|
This will be a great addition! I've been hoping that Compose would provide some built-in feature for this beyond what 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>
@marcprux good point! refactored to use AnchorDraggable. Will take a look at the showcase app and add a quick sample now |
CleanShot.2026-05-14.at.18.34.31.mp4 |
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:
swift testAi 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.