diff --git a/CHANGELOG.md b/CHANGELOG.md index eef7c57..a1c1677 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,9 +27,15 @@ together under one version. - The toolchain moves to Android Gradle Plugin 9.5.0, Kotlin 2.4.21 and Java 21 (library bytecode and CI), so apps can adopt libraries that require them, such as SceneView for AR. Kotlin 2.4 adds a no-argument constructor to `PvotGlassStyle` and `PvotNavBarSizes` in the recorded API. +- `PvotSwipeRow` takes `thresholdFraction` and `dragResistance` + (`PvotSwipeRowDefaults.DragResistance`), so a list can make its swipes heavier and harder to set + off by accident; the defaults keep today's feel. Source-compatible, binary-incompatible. ### Fixed - `PvotSwipeRow`: no slivers of the action's color at the row's corners while it slides. +- `PvotSwipeRow` no longer leaves a thin arc of the action's color at the row's corners after a + short swipe springs back: the row settles exactly in place, and the action's color fills only + the gap the row leaves, never under it. - The glass lens shader declared a variable named `smooth`, a reserved word in GLSL. Devices that compile AGSL through a GLSL front end (many Android 15 builds, and the emulator) rejected the whole shader, so the glass drew nothing and the content behind showed through sharp. diff --git a/design-system/api/design-system.api b/design-system/api/design-system.api index 99e5f87..922e27e 100644 --- a/design-system/api/design-system.api +++ b/design-system/api/design-system.api @@ -789,6 +789,7 @@ public final class com/prauga/pvot/designsystem/components/list/PvotSwipeAction public final class com/prauga/pvot/designsystem/components/list/PvotSwipeRowDefaults { public static final field $stable I public static final field ArmedIconScale F + public static final field DragResistance F public static final field INSTANCE Lcom/prauga/pvot/designsystem/components/list/PvotSwipeRowDefaults; public static final field ThresholdFraction F public final fun action-jB83MbM (Ljava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;Lkotlin/jvm/functions/Function0;JJZLandroidx/compose/runtime/Composer;II)Lcom/prauga/pvot/designsystem/components/list/PvotSwipeAction; @@ -799,7 +800,7 @@ public final class com/prauga/pvot/designsystem/components/list/PvotSwipeRowDefa } public final class com/prauga/pvot/designsystem/components/list/PvotSwipeRowKt { - public static final fun PvotSwipeRow-3csKH6Y (Landroidx/compose/ui/Modifier;Lcom/prauga/pvot/designsystem/components/list/PvotSwipeAction;Lcom/prauga/pvot/designsystem/components/list/PvotSwipeAction;Lcom/prauga/pvot/designsystem/components/list/PvotSwipeRowState;ZLandroidx/compose/ui/graphics/Shape;JLkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun PvotSwipeRow-ueL0Wzs (Landroidx/compose/ui/Modifier;Lcom/prauga/pvot/designsystem/components/list/PvotSwipeAction;Lcom/prauga/pvot/designsystem/components/list/PvotSwipeAction;Lcom/prauga/pvot/designsystem/components/list/PvotSwipeRowState;ZLandroidx/compose/ui/graphics/Shape;JFFLkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V public static final fun rememberPvotSwipeRowState (Landroidx/compose/runtime/Composer;I)Lcom/prauga/pvot/designsystem/components/list/PvotSwipeRowState; } diff --git a/design-system/src/main/java/com/prauga/pvot/designsystem/components/list/PvotSwipeRow.kt b/design-system/src/main/java/com/prauga/pvot/designsystem/components/list/PvotSwipeRow.kt index b31a191..7028305 100644 --- a/design-system/src/main/java/com/prauga/pvot/designsystem/components/list/PvotSwipeRow.kt +++ b/design-system/src/main/java/com/prauga/pvot/designsystem/components/list/PvotSwipeRow.kt @@ -29,6 +29,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.graphicsLayer @@ -89,9 +92,12 @@ public class PvotSwipeRowState { internal var minOffset: Float = 0f internal var maxOffset: Float = 0f + /** How far the row moves per pixel the finger drags. */ + internal var dragResistance: Float = 1f + /** Runs drags and settles; a new drag cancels a settle in flight. */ internal val draggableState: DraggableState = DraggableState { delta -> - offset = (offset + delta).coerceIn(minOffset, maxOffset) + offset = (offset + delta * dragResistance).coerceIn(minOffset, maxOffset) } /** Whether the row has slid away by an action that [removes it][PvotSwipeAction.removesRow]. */ @@ -105,17 +111,19 @@ public class PvotSwipeRowState { } internal suspend fun settleTo(target: Float, velocity: Float) { + // Sets the offset directly rather than dragging, so the drag resistance doesn't slow the + // settle, and lands exactly on the target: a row left a fraction of a pixel off would keep + // drawing its action's fill. draggableState.drag { - var previous = offset animate( initialValue = offset, targetValue = target, initialVelocity = velocity, animationSpec = tween(PvotDurations.Medium, easing = PvotMotion.Settle), ) { value, _ -> - dragBy(value - previous) - previous = value + offset = value } + offset = target } } } @@ -133,6 +141,9 @@ public object PvotSwipeRowDefaults { /** How far across the row, as a fraction of its width, a swipe must go to count. */ public const val ThresholdFraction: Float = 0.35f + /** How far the row moves per pixel the finger drags; 1 follows the finger. */ + public const val DragResistance: Float = 1f + /** A fling at least this fast, per second, counts from half the threshold. */ public val FlingVelocity: Dp = 600.dp @@ -205,6 +216,10 @@ public object PvotSwipeRowDefaults { * @param enabled Whether the row can be swiped * @param shape Shape of the row and the fill behind it * @param containerColor Fill of the row, opaque so the action only shows in the gap + * @param thresholdFraction How far across the row, as a fraction of its width, a swipe must go to + * count + * @param dragResistance How far the row moves per pixel the finger drags, from 0 to 1; lower makes + * the swipe feel heavier and harder to set off by accident * @param content The row, typically a [PvotListRow] */ @Composable @@ -216,6 +231,8 @@ public fun PvotSwipeRow( enabled: Boolean = true, shape: Shape = PvotSwipeRowDefaults.Shape, containerColor: Color = PvotSwipeRowDefaults.containerColor, + thresholdFraction: Float = PvotSwipeRowDefaults.ThresholdFraction, + dragResistance: Float = PvotSwipeRowDefaults.DragResistance, content: @Composable () -> Unit, ) { val haptics = LocalHapticFeedback.current @@ -224,12 +241,16 @@ public fun PvotSwipeRow( val start by rememberUpdatedState(startAction) val end by rememberUpdatedState(endAction) var width by remember { mutableIntStateOf(0) } - val threshold = width * PvotSwipeRowDefaults.ThresholdFraction + val threshold = width * thresholdFraction + state.dragResistance = dragResistance state.maxOffset = if (startAction != null) width.toFloat() else 0f state.minOffset = if (endAction != null) -width.toFloat() else 0f - val direction by remember(state) { derivedStateOf { sign(state.offset) } } + val direction by + remember(state) { + derivedStateOf { if (abs(state.offset) < 0.5f) 0f else sign(state.offset) } + } val armed by remember(state, threshold) { derivedStateOf { threshold > 0f && abs(state.offset) >= threshold } @@ -296,8 +317,20 @@ public fun PvotSwipeRow( ) ) { if (revealed != null) { + // Fills only the gap the row leaves, never under the row: a fill under it would bleed + // through the anti-aliased edge where the clip rounds the row's corners. + val fillsFromLeft = (direction > 0f) != rtl Box( - modifier = Modifier.matchParentSize().background(revealed.containerColor), + modifier = + Modifier.matchParentSize().drawBehind { + val gap = + (abs(state.offset).roundToInt() + 1).toFloat().coerceAtMost(size.width) + drawRect( + color = revealed.containerColor, + topLeft = Offset(if (fillsFromLeft) 0f else size.width - gap, 0f), + size = Size(gap, size.height), + ) + }, contentAlignment = if (direction > 0f) Alignment.CenterStart else Alignment.CenterEnd, ) { diff --git a/design-system/src/test/java/com/prauga/pvot/designsystem/components/list/PvotSwipeRowStateTest.kt b/design-system/src/test/java/com/prauga/pvot/designsystem/components/list/PvotSwipeRowStateTest.kt new file mode 100644 index 0000000..ace619d --- /dev/null +++ b/design-system/src/test/java/com/prauga/pvot/designsystem/components/list/PvotSwipeRowStateTest.kt @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: 2026 Saalim Quadri +// SPDX-License-Identifier: Apache-2.0 + +package com.prauga.pvot.designsystem.components.list + +import androidx.compose.runtime.MonotonicFrameClock +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.junit.Assert.assertEquals +import org.junit.Test + +/** How a swipe row's position follows drags and settles. The row is 1000 wide both ways. */ +class PvotSwipeRowStateTest { + + private val state = + PvotSwipeRowState().apply { + minOffset = -1000f + maxOffset = 1000f + } + + /** Runs frames 16 ms apart as fast as they are asked for. */ + private val clock = + object : MonotonicFrameClock { + private var nanos = 0L + + override suspend fun withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R { + nanos += 16_000_000L + return onFrame(nanos) + } + } + + @Test + fun aDragMovesTheRowByItsResistance() { + state.draggableState.dispatchRawDelta(100f) + assertEquals(100f, state.offset, 0f) + + state.dragResistance = 0.5f + state.draggableState.dispatchRawDelta(-300f) + assertEquals(-50f, state.offset, 0f) + } + + @Test + fun aDragStopsAtTheRowsEdge() { + state.draggableState.dispatchRawDelta(5000f) + assertEquals(1000f, state.offset, 0f) + } + + @Test + fun aSettleLandsExactlyOnItsTargetWhateverTheResistance() = runBlocking { + state.draggableState.dispatchRawDelta(123.4567f) + state.dragResistance = 0.3f + withContext(clock) { state.settleTo(0f, velocity = -800f) } + assertEquals(0f, state.offset, 0f) + } + + @Test + fun aResetPutsADismissedRowBack() = runBlocking { + state.draggableState.dispatchRawDelta(-1000f) + withContext(clock) { state.reset() } + assertEquals(0f, state.offset, 0f) + assertEquals(false, state.isDismissed) + } +}