lamintra/ui

Swipe row

Wraps any row. The actions slide in behind it, and a long swipe arms delete.

$ java -jar lamintra-0.8.0.jar add swipe-row

Preview

Rendered headlessly from LamintraSwipeRow.kt. Switch the scheme in the header and this changes with it: both are real renders of the same source rather than one filtered to look like the other.

The Lamintra swipe row in its states, rendered from the registry source.The Lamintra swipe row in its states, rendered from the registry source.

Parameters

Read out of LamintraSwipeRow.kt when this page was built, not written by hand, so it cannot drift from the file the CLI installs. 2 required, 11 with defaults.

NameTypeDefault
actionsrequiredrevealed right to left. The last one is outermost, and is the one a full swipe commits when it is LamintraSwipeAction.destructiveList<LamintraSwipeAction>-
modifierModifierModifier
revealedopens or closes the row from outside the gesture. Exists because a list almost always wants at most one row open at a time, which the row itself cannot know: hold the open row's key in the list and pass key == openKey. Changing it animates; the FIRST value is applied without animation, so a row that starts open starts open rather than opening at the readerBooleanfalse
onRevealedChangereports the settled state after a gesture, so the list's idea of which row is open follows the finger((Boolean) -> Unit)?null
colorssee LamintraSwipeRowColors; defaults to following the systemLamintraSwipeRowColorsLamintraSwipeRowColors.auto()
actionWidthwidth of each action cellDp88.dp
snapFractionhow far across the revealed width a release must be to settle open rather than closedFloat0.5f
flingVelocityrelease speed that decides the direction regardless of distance, in dp per secondDp350.dp
fullSwipeFractionhow far across the ROW's own width a swipe must go to arm the destructive action. A fraction of the row rather than of the actions, because it is a deliberately long gestureFloat0.62f
parallaxhow far out the actions start, as a fraction of their width. 0f pins them still under the contentFloat0.35f
overshootResistancehow much of a drag past either end is followedFloat0.3f
actionTextStyleTextStyleTextStyle( fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = (-0.1).sp )
contentrequired@Composable () -> Unit-

What lands in your project

2 files, written under your own package. The internals are namespaced per component (internal/swipe_row/), which is what lets two components ship same-named helpers without colliding.

  • LamintraSwipeRow.kt
  • LamintraTheme.kt

Source

The whole file, verbatim. This is the component: there is nothing behind it, no artifact to depend on, and deleting Lamintra afterwards leaves it compiling.

package com.lamintra.swipe_row

import androidx.compose.animation.core.animate
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.lamintra.theme.LamintraColors
import com.lamintra.theme.LamintraTheme
import com.lamintra.theme.lamintraDarkColors
import com.lamintra.theme.lamintraLightColors
import kotlinx.coroutines.launch
import kotlin.math.roundToInt

/**
 * One action revealed by swiping a row.
 *
 * [destructive] is not styling sugar. It picks the danger colours, and it marks
 * which action a full swipe commits to - a full swipe is a shortcut for the
 * thing the gesture is usually FOR, and shortcutting a non-destructive action
 * while a destructive one sits beside it is how a list loses data quietly.
 */
data class LamintraSwipeAction(
    val label: String,
    val onClick: () -> Unit,
    val destructive: Boolean = false
)

/**
 * Colours for [LamintraSwipeRow].
 *
 * The three factory names - `dark()`, `light()`, `auto()` - are identical on
 * every Lamintra component on purpose. Do not rename them.
 */
data class LamintraSwipeRowColors(
    /**
     * Painted behind the sliding content.
     *
     * Not optional and not decorative: the actions sit UNDER the content, so a
     * transparent slider shows them through the row at rest and the component
     * reads as broken before it is ever touched. It matches the container the
     * row is expected to live in.
     */
    val surface: Color,
    val actionBackground: Color,
    val actionContent: Color,
    val dangerBackground: Color,
    val dangerContent: Color
) {
    companion object {
        /**
         * `inkMuted` as a FILL, which is the one mapping here worth explaining.
         *
         * The obvious choice was `containerSunk`, and it is wrong: on the light
         * scheme `container` and `containerSunk` are both `Neutral100`, so a
         * non-destructive action was painted in exactly the colour of the row
         * sliding over it and disappeared. It was invisible in the render and
         * would have been invisible in every light-themed app.
         *
         * A swipe action is not a recessed surface, it is a filled control that
         * happens to live behind one, so it needs a colour with contrast rather
         * than a colour with depth. `inkMuted` is the mid-neutral both schemes
         * already carry - the same grey iOS fills Archive with - and `onInk`
         * rides it at 5.7:1 on dark and 4.8:1 on light.
         *
         * A sixteenth token would have been the alternative, and the theme says
         * a sixteenth needs an argument. This did not have one: nothing about
         * this fill is unavailable from the fifteen.
         */
        fun from(colors: LamintraColors): LamintraSwipeRowColors = LamintraSwipeRowColors(
            surface = colors.container,
            actionBackground = colors.inkMuted,
            actionContent = colors.onInk,
            dangerBackground = colors.danger,
            dangerContent = colors.onDanger
        )

        fun dark(): LamintraSwipeRowColors = from(lamintraDarkColors())

        fun light(): LamintraSwipeRowColors = from(lamintraLightColors())

        /**
         * Follows the device's colour scheme. Reads the *device* setting, not
         * your app's theme - an app that forces its own scheme should pass
         * [dark] or [light] explicitly.
         */
        @Composable
        fun auto(): LamintraSwipeRowColors = from(LamintraTheme.colors)
    }
}

/**
 * A row that reveals actions when swiped left, wrapping any content.
 *
 * It takes [content] rather than being a row itself, so the thing that slides
 * can be a list row, a card, or anything else. Two components composed beats a
 * second row implementation that has to be kept in step with the first.
 *
 * **Four details separate this from a row that translates with a finger**, and
 * every one of them is about the gesture reading as physical:
 *
 * 1. **The snap is a spring seeded with the release velocity**, so letting go
 *    continues at the speed your thumb left at. A tween restarts from zero and
 *    a fast flick visibly hesitates before completing.
 * 2. **The actions parallax.** They sit still under the content in the naive
 *    version, which makes the row look like a lid sliding off a drawer. Here
 *    they start [parallax] of the way out and catch up as you drag, so they
 *    read as arriving rather than as having been there all along.
 * 3. **Both ends resist instead of stopping.** Dragging right past closed, or
 *    left past open with full swipe off, follows at [overshootResistance].
 * 4. **A long swipe arms the destructive action**, which then fills the whole
 *    revealed strip and commits on release. Distance is the signal, so the
 *    common case - delete - needs one gesture rather than a swipe and a tap.
 *
 * **The gesture is not the only way in.** Swipe actions are invisible to a
 * screen reader and unreachable by keyboard, so every action is also published
 * as a semantics custom action. A component whose only affordance is a gesture
 * is a component some people cannot use at all.
 *
 * **This does not remove anything.** It calls [LamintraSwipeAction.onClick] and
 * springs closed; the list owns its own contents. Remove the item in response
 * and the row is gone before the spring finishes, which is the intended look.
 *
 * @param actions revealed right to left. The last one is outermost, and is the
 *        one a full swipe commits when it is [LamintraSwipeAction.destructive]
 * @param revealed opens or closes the row from outside the gesture. Exists
 *        because a list almost always wants at most one row open at a time,
 *        which the row itself cannot know: hold the open row's key in the list
 *        and pass `key == openKey`. Changing it animates; the FIRST value is
 *        applied without animation, so a row that starts open starts open
 *        rather than opening at the reader
 * @param onRevealedChange reports the settled state after a gesture, so the
 *        list's idea of which row is open follows the finger
 * @param colors see [LamintraSwipeRowColors]; defaults to following the system
 * @param actionWidth width of each action cell
 * @param snapFraction how far across the revealed width a release must be to
 *        settle open rather than closed
 * @param flingVelocity release speed that decides the direction regardless of
 *        distance, in dp per second
 * @param fullSwipeFraction how far across the ROW's own width a swipe must go
 *        to arm the destructive action. A fraction of the row rather than of
 *        the actions, because it is a deliberately long gesture
 * @param parallax how far out the actions start, as a fraction of their width.
 *        0f pins them still under the content
 * @param overshootResistance how much of a drag past either end is followed
 */
@Composable
fun LamintraSwipeRow(
    actions: List<LamintraSwipeAction>,
    modifier: Modifier = Modifier,
    revealed: Boolean = false,
    onRevealedChange: ((Boolean) -> Unit)? = null,
    colors: LamintraSwipeRowColors = LamintraSwipeRowColors.auto(),
    actionWidth: Dp = 88.dp,
    snapFraction: Float = 0.5f,
    flingVelocity: Dp = 350.dp,
    fullSwipeFraction: Float = 0.62f,
    parallax: Float = 0.35f,
    overshootResistance: Float = 0.3f,
    actionTextStyle: TextStyle = TextStyle(
        fontSize = 14.sp,
        lineHeight = 20.sp,
        letterSpacing = (-0.1).sp
    ),
    content: @Composable () -> Unit
) {
    val density = LocalDensity.current
    val scope = rememberCoroutineScope()

    // Converted here rather than in the drag callbacks: onDragStopped runs in a
    // coroutine scope, which is not a density scope, and Dp.toPx() there does
    // not compile.
    val flingPx = with(density) { flingVelocity.toPx() }
    val revealPx = with(density) { actionWidth.toPx() } * actions.size

    val settleSpec = spring<Float>(dampingRatio = 0.85f, stiffness = 520f)

    // Seeded from [revealed] rather than always 0, so a row that starts open is
    // open on its first frame. Animating into position from a LaunchedEffect
    // instead would open it AT the reader on arrival, and would render closed in
    // any single-frame capture - a screenshot test, or the harness that draws
    // this component's own documentation.
    var offsetX by remember { mutableFloatStateOf(if (revealed) -revealPx else 0f) }
    var rowWidthPx by remember { mutableFloatStateOf(0f) }

    // Subsequent changes animate. skipFirst guards the initial composition,
    // which is already in position.
    var seenFirst by remember { mutableStateOf(false) }
    LaunchedEffect(revealed) {
        if (!seenFirst) {
            seenFirst = true
            return@LaunchedEffect
        }
        animate(offsetX, if (revealed) -revealPx else 0f, animationSpec = settleSpec) { v, _ ->
            offsetX = v
        }
    }

    // Full swipe commits the outermost action, and only when it is destructive.
    // Arming a non-destructive action while a destructive one is present would
    // let a long swipe do the safe thing while the user expected the dangerous
    // one, or the reverse - both are worse than not offering the shortcut.
    val fullSwipeAction = actions.lastOrNull()?.takeIf { it.destructive }
    val armed = fullSwipeAction != null &&
        rowWidthPx > 0f &&
        -offsetX > rowWidthPx * fullSwipeFraction

    val dragState = rememberDraggableState { delta ->
        val next = offsetX + delta
        offsetX = when {
            // Past closed, to the right.
            next > 0f -> next * overshootResistance
            // Past open, with no full swipe to travel towards.
            fullSwipeAction == null && next < -revealPx ->
                -revealPx + (next + revealPx) * overshootResistance
            else -> next
        }
    }

    Box(
        modifier = modifier
            // Not optional, and it was not obvious until it was rendered. Two
            // things escape these bounds without it: the content slides left
            // past the row's own left edge, and the parallaxed actions sit to
            // the RIGHT of it at rest, so the outermost one - usually the red
            // destructive cell - leaks a sliver into whatever is beside the row.
            // Both draw outside the component and over its neighbours.
            .clipToBounds()
            .onSizeChanged { rowWidthPx = it.width.toFloat() }
            .semantics {
                customActions = actions.map { action ->
                    CustomAccessibilityAction(action.label) { action.onClick(); true }
                }
            }
    ) {
        // The actions, under the content. matchParentSize keeps them out of the
        // size calculation, so the row is exactly as tall as what it wraps.
        Row(
            modifier = Modifier
                .matchParentSize()
                .graphicsLayer {
                    // Parallax. At rest they sit `revealPx * parallax` to the
                    // right of their resting place and catch up as the content
                    // moves, so they arrive rather than being uncovered.
                    translationX = ((revealPx + offsetX) * parallax).coerceAtLeast(0f)
                },
            horizontalArrangement = Arrangement.End
        ) {
            actions.forEachIndexed { index, action ->
                val isLast = index == actions.lastIndex
                val background = if (action.destructive) {
                    colors.dangerBackground
                } else {
                    colors.actionBackground
                }
                Box(
                    modifier = Modifier
                        .width(actionWidth)
                        .fillMaxHeight()
                        .background(background)
                        // When armed, every action except the one that will fire
                        // fades out. Leaving them visible under an expanded
                        // destructive background reads as three buttons on one
                        // red field, which is exactly the wrong signal.
                        .graphicsLayer { alpha = if (armed && !isLast) 0f else 1f }
                        // indication = null explicitly. Left to the default this
                        // resolves LocalIndication, which is a ripple in any host
                        // that has Material on the classpath - and a Material
                        // ripple is the one thing every component here is written
                        // to avoid.
                        .clickable(
                            interactionSource = remember { MutableInteractionSource() },
                            indication = null
                        ) {
                            action.onClick()
                            onRevealedChange?.invoke(false)
                            scope.launch {
                                animate(offsetX, 0f, animationSpec = settleSpec) { v, _ ->
                                    offsetX = v
                                }
                            }
                        },
                    contentAlignment = Alignment.Center
                ) {
                    BasicText(
                        text = action.label,
                        style = actionTextStyle.copy(
                            color = if (action.destructive) {
                                colors.dangerContent
                            } else {
                                colors.actionContent
                            },
                            textAlign = TextAlign.Center
                        )
                    )
                }
            }
        }

        // The expanded destructive field. Drawn over the action cells and under
        // nothing, sized to the whole revealed strip, so the row commits to one
        // colour before the finger lifts.
        if (armed && fullSwipeAction != null) {
            // Two levels, and it is not redundant. matchParentSize resolves
            // against the row's MEASURED size; fillMaxHeight alone resolves
            // against the incoming constraints, which in a Box are the whole
            // available height rather than the height of the content beside it.
            // The inner strip would run the height of the screen.
            Box(Modifier.matchParentSize()) {
                Box(
                    modifier = Modifier
                        .fillMaxHeight()
                        .width(with(density) { (-offsetX).toDp() })
                        .align(Alignment.CenterEnd)
                        .background(colors.dangerBackground),
                    contentAlignment = Alignment.Center
                ) {
                    BasicText(
                        text = fullSwipeAction.label,
                        style = actionTextStyle.copy(
                            color = colors.dangerContent,
                            textAlign = TextAlign.Center
                        )
                    )
                }
            }
        }

        Box(
            modifier = Modifier
                .offset { IntOffset(offsetX.roundToInt(), 0) }
                .background(colors.surface)
                .draggable(
                    state = dragState,
                    orientation = Orientation.Horizontal,
                    onDragStopped = { velocity ->
                        if (armed && fullSwipeAction != null) {
                            fullSwipeAction.onClick()
                            onRevealedChange?.invoke(false)
                            animate(offsetX, 0f, velocity, settleSpec) { v, _ -> offsetX = v }
                        } else {
                            val open = -offsetX > revealPx * snapFraction || velocity < -flingPx
                            val target = if (open && actions.isNotEmpty()) -revealPx else 0f
                            onRevealedChange?.invoke(target != 0f)
                            // Seeded with the release velocity, which is the
                            // whole point: the row carries on at the speed the
                            // thumb left it at instead of restarting from rest.
                            animate(offsetX, target, velocity, settleSpec) { v, _ -> offsetX = v }
                        }
                    }
                )
        ) {
            content()
        }
    }
}

Next

The command above assumes the project has been set up once already. If it has not, the install page covers what init detects and writes, and what to do when it guesses wrong.

The other components: sheet, button, card, text field, list row, switch, segmented.