Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion design-system/api/design-system.api
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]. */
Expand All @@ -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
}
}
}
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 }
Expand Down Expand Up @@ -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,
) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// SPDX-FileCopyrightText: 2026 Saalim Quadri <danascape@gmail.com>
// 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 <R> 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)
}
}
Loading