From eab343cce4815715892a3552dc522413fa034df8 Mon Sep 17 00:00:00 2001 From: madhurmehta007 <12madhurmehta@gmail.com> Date: Mon, 2 Nov 2026 23:11:43 +0530 Subject: [PATCH 1/2] fix: Keep PvotSwipeRow's action color out of its corners Assisted-By: LLM Signed-off-by: madhurmehta007 <12madhurmehta@gmail.com> --- CHANGELOG.md | 3 ++ .../components/list/PvotSwipeRow.kt | 29 +++++++++-- .../components/list/PvotSwipeRowStateTest.kt | 52 +++++++++++++++++++ 3 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 design-system/src/test/java/com/prauga/pvot/designsystem/components/list/PvotSwipeRowStateTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index eef7c57..10c7b11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,9 @@ together under one version. ### 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/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..2a6c3ba 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 @@ -105,17 +108,18 @@ public class PvotSwipeRowState { } internal suspend fun settleTo(target: Float, velocity: Float) { + // 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 } } } @@ -229,7 +233,10 @@ public fun PvotSwipeRow( 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 +303,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..b1f5902 --- /dev/null +++ b/design-system/src/test/java/com/prauga/pvot/designsystem/components/list/PvotSwipeRowStateTest.kt @@ -0,0 +1,52 @@ +// 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 aDragStopsAtTheRowsEdge() { + state.draggableState.dispatchRawDelta(5000f) + assertEquals(1000f, state.offset, 0f) + } + + @Test + fun aSettleLandsExactlyOnItsTarget() = runBlocking { + state.draggableState.dispatchRawDelta(123.4567f) + 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) + } +} From 45e8a43c6f78ec361d5e095c0394015b563dae6d Mon Sep 17 00:00:00 2001 From: madhurmehta007 <12madhurmehta@gmail.com> Date: Mon, 2 Nov 2026 23:11:59 +0530 Subject: [PATCH 2/2] feat: Let PvotSwipeRow take a firmer swipe Assisted-By: LLM Signed-off-by: madhurmehta007 <12madhurmehta@gmail.com> --- CHANGELOG.md | 3 +++ design-system/api/design-system.api | 3 ++- .../components/list/PvotSwipeRow.kt | 22 +++++++++++++++---- .../components/list/PvotSwipeRowStateTest.kt | 13 ++++++++++- 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10c7b11..a1c1677 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ 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. 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 2a6c3ba..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 @@ -92,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]. */ @@ -108,8 +111,9 @@ public class PvotSwipeRowState { } internal suspend fun settleTo(target: Float, velocity: Float) { - // Lands exactly on the target: a row left a fraction of a pixel off would keep drawing its - // action's fill. + // 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 { animate( initialValue = offset, @@ -137,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 @@ -209,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 @@ -220,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 @@ -228,8 +241,9 @@ 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 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 index b1f5902..ace619d 100644 --- 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 @@ -29,6 +29,16 @@ class PvotSwipeRowStateTest { } } + @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) @@ -36,8 +46,9 @@ class PvotSwipeRowStateTest { } @Test - fun aSettleLandsExactlyOnItsTarget() = runBlocking { + fun aSettleLandsExactlyOnItsTargetWhateverTheResistance() = runBlocking { state.draggableState.dispatchRawDelta(123.4567f) + state.dragResistance = 0.3f withContext(clock) { state.settleTo(0f, velocity = -800f) } assertEquals(0f, state.offset, 0f) }