Skip to content

Instantly share code, notes, and snippets.

@KlassenKonstantin
Created March 3, 2026 06:42
Show Gist options
  • Select an option

  • Save KlassenKonstantin/d16e9771517fa830e1dc792509a6c90d to your computer and use it in GitHub Desktop.

Select an option

Save KlassenKonstantin/d16e9771517fa830e1dc792509a6c90d to your computer and use it in GitHub Desktop.
@file:OptIn(ExperimentalFoundationApi::class)
package de.kuno.snappyswipe.snappyswipe
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlin.math.absoluteValue
import kotlin.math.max
import kotlin.math.min
@OptIn(ExperimentalCoroutinesApi::class)
class DragCoordinatorState<T : DraggedItemInfo> internal constructor() {
var dragInfo by mutableStateOf<T?>(null)
val itemOffsets = mutableStateMapOf<Any?, Float>()
val itemInfos = mutableStateOf<List<ItemInfo>>(listOf())
private val itemLookup by derivedStateOf {
itemInfos.value.associateBy { it.key }
}
fun getItemState(key: Any?): ItemState<T>? {
val index = itemLookup[key]?.index ?: return null
val itemInfos = itemInfos.value
val offsets = Triple(
itemOffsets[key],
itemOffsets[itemInfos.getOrNull(index - 1)?.key],
itemOffsets[itemInfos.getOrNull(index + 1)?.key]
)
val currentItem = itemInfos.first { it.key == key }.copy(
offset = offsets.first ?: 0f
)
val topItem = itemInfos.getOrNull(currentItem.index - 1)?.copy(
offset = offsets.second ?: 0f
)
val bottomItem = itemInfos.getOrNull(currentItem.index + 1)?.copy(
offset = offsets.third ?: 0f
)
val draggedItemRelation = dragInfo?.let { dragInfo ->
val draggedItemIndex = itemInfos.indexOfFirst { it.key == dragInfo.key }
if (index < 0 || draggedItemIndex < 0) return@let null
val draggedItemInfo = itemInfos[draggedItemIndex]
val distanceToDraggedItem = index - draggedItemIndex
val sameSegmentAsDraggedItem = itemInfos.subList(
min(index, draggedItemIndex),
max(index, draggedItemIndex)
).all { it.segmentType == draggedItemInfo.segmentType }
DraggedItemRelation(
draggedItemInfo = dragInfo,
indexDelta = distanceToDraggedItem,
sameSegmentAsDraggedItem = sameSegmentAsDraggedItem,
)
}
return ItemState(
draggedItemRelation = draggedItemRelation,
itemInfo = currentItem,
topItemInfo = topItem,
bottomItemInfo = bottomItem,
)
}
fun updateOffset(key: Any?, offset: Float) {
itemOffsets[key] = offset
}
}
@Composable
fun <D : DraggedItemInfo, T> rememberDragCoordinatorState(
items: List<T>,
key: (T) -> Any?,
segmentType: (T) -> Any? = { null },
): DragCoordinatorState<D> {
return remember {
DragCoordinatorState<D>()
}.apply {
itemInfos.value = items.mapIndexed { index, item ->
val key = key(item)
ItemInfo(
key = key,
index = index,
segmentType = segmentType(item),
offset = 0f
)
}
}
}
interface DraggedItemInfo {
val key: Any?
val dragOffset: Float
}
data class ItemState<T : DraggedItemInfo>(
val draggedItemRelation: DraggedItemRelation<T>?,
val itemInfo: ItemInfo,
val topItemInfo: ItemInfo?,
val bottomItemInfo: ItemInfo?,
) {
val sameSegmentAsTopNeighbor: Boolean
get() = itemInfo.segmentType == topItemInfo?.segmentType
val sameSegmentAsBottomNeighbor: Boolean
get() = itemInfo.segmentType == bottomItemInfo?.segmentType
val offsetDeltaTop: Float
get() = (itemInfo.offset - (topItemInfo?.offset ?: 0f)).absoluteValue
val offsetDeltaBottom: Float
get() = (itemInfo.offset - (bottomItemInfo?.offset ?: 0f)).absoluteValue
val isDraggedItem: Boolean
get() = itemInfo.key == draggedItemRelation?.draggedItemInfo?.key
}
data class DraggedItemRelation<T : DraggedItemInfo>(
/**
* Information about the item that is being dragged.
*/
val draggedItemInfo: T,
/**
* The distance between this item and the item that is being dragged.
* A positive value means this item is further down than the item that is being dragged and vice versa.
* An indexDelta of 0 means this item is the one being dragged.
*/
val indexDelta: Int,
/**
* Whether the dragged item is in the same segment as the item that is being dragged.
*/
val sameSegmentAsDraggedItem: Boolean,
)
data class ItemInfo(
val key: Any?,
val index: Int,
val segmentType: Any?,
val offset: Float,
)
package de.kuno.snappyswipe.snappyswipe
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.VectorConverter
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.min
import co.touchlab.kermit.Logger.Companion.d
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
import kotlin.experimental.ExperimentalTypeInference
@OptIn(ExperimentalTypeInference::class)
class ShapeHelper(
minCornerRadius: () -> Dp,
maxCornerRadius: () -> Dp,
animationSpec: () -> AnimationSpec<Dp>,
private val maxOffsetDelta: Float,
private val itemState: () -> ItemState<SnappyDraggedItemInfo>?,
) {
private val topCornerRadiusAnimator = Animatable(calcTopCornerRadius(itemState()!!, minCornerRadius(), maxCornerRadius()), Dp.VectorConverter)
private val bottomCornerRadiusAnimator = Animatable(calcBottomCornerRadius(itemState()!!, minCornerRadius(), maxCornerRadius()), Dp.VectorConverter)
var minCornerRadius by mutableStateOf(minCornerRadius)
var maxCornerRadius by mutableStateOf(maxCornerRadius)
var animationSpec by mutableStateOf(animationSpec)
val shape: Shape
get() {
val minCornerRadius = minCornerRadius()
val maxCornerRadius = maxCornerRadius()
val top = topCornerRadiusAnimator.value.coerceIn(minCornerRadius, maxCornerRadius)
val bottom = bottomCornerRadiusAnimator.value.coerceIn(minCornerRadius, maxCornerRadius)
return RoundedCornerShape(
topStart = top,
topEnd = top,
bottomStart = bottom,
bottomEnd = bottom,
)
}
suspend fun observeChanges() = coroutineScope {
combine(
snapshotFlow { itemState() }.filterNotNull(),
snapshotFlow { minCornerRadius() },
snapshotFlow { maxCornerRadius() },
snapshotFlow { animationSpec() },
) { itemState, minCornerRadius, maxCornerRadius, animationSpec ->
launch { topCornerRadiusAnimator.animateTo(calcTopCornerRadius(itemState, minCornerRadius, maxCornerRadius), animationSpec) }
launch { bottomCornerRadiusAnimator.animateTo(calcBottomCornerRadius(itemState, minCornerRadius, maxCornerRadius), animationSpec) }
}.collect()
}
private fun calcRadius(offsetDelta: Float, minCornerRadius: Dp, maxCornerRadius: Dp): Dp {
val progress = (offsetDelta / maxOffsetDelta).coerceIn(0f, 1f)
return (minCornerRadius + (maxCornerRadius - minCornerRadius) * progress)
}
private fun calcTopCornerRadius(itemState: ItemState<SnappyDraggedItemInfo>, minCornerRadius: Dp, maxCornerRadius: Dp): Dp {
return when {
!itemState.sameSegmentAsTopNeighbor || (itemState.isDraggedItem || itemState.draggedItemRelation?.indexDelta == 1) && !itemState.draggedItemRelation!!.draggedItemInfo.stuck -> maxCornerRadius
else -> calcRadius(itemState.offsetDeltaTop, minCornerRadius, maxCornerRadius)
}
}
private fun calcBottomCornerRadius(itemState: ItemState<SnappyDraggedItemInfo>, minCornerRadius: Dp, maxCornerRadius: Dp): Dp {
return when {
!itemState.sameSegmentAsBottomNeighbor || (itemState.isDraggedItem || itemState.draggedItemRelation?.indexDelta == -1) && !itemState.draggedItemRelation!!.draggedItemInfo.stuck -> maxCornerRadius
else -> calcRadius(itemState.offsetDeltaBottom, minCornerRadius, maxCornerRadius)
}
}
}
@Composable
fun rememberShapeHelper(
minCornerRadius: () -> Dp,
maxCornerRadius: () -> Dp,
maxAtOffsetDelta: Dp,
itemState: () -> ItemState<SnappyDraggedItemInfo>?,
animationSpec: () -> AnimationSpec<Dp>,
): ShapeHelper {
val density = LocalDensity.current
val helper = remember {
ShapeHelper(
minCornerRadius = minCornerRadius,
maxCornerRadius = maxCornerRadius,
maxOffsetDelta = with(density) { maxAtOffsetDelta.toPx() },
itemState = itemState,
animationSpec = animationSpec
)
}.apply {
this.minCornerRadius = minCornerRadius
this.maxCornerRadius = maxCornerRadius
this.animationSpec = animationSpec
}
LaunchedEffect(Unit) {
helper.observeChanges()
}
DisposableEffect(Unit) {
onDispose {
d {"BYE"}
}
}
return helper
}
package de.kuno.snappyswipe.snappyswipe
import kotlin.math.absoluteValue
class SnappyDragHelper(
private val key: Any?,
private val unstickDistance: Float,
private val restickDistance: Float,
private val onStuck: () -> Unit = { },
private val onUnstuck: () -> Unit = { },
) {
var dragInfo: SnappyDraggedItemInfo? = null
fun onDragStarted(
initialOffset: Float,
) = SnappyDraggedItemInfo(
key = key,
dragOffset = initialOffset,
stuck = initialOffset.absoluteValue < unstickDistance,
unstuckProgress = (initialOffset.absoluteValue / unstickDistance).coerceAtMost(1f)
).also {
dragInfo = it
}
fun updateDragInfo(dragDelta: Float): SnappyDraggedItemInfo {
val currentDragInfo = requireNotNull(dragInfo)
val newStuck = if (currentDragInfo.stuck) {
currentDragInfo.dragOffset.absoluteValue < unstickDistance
} else {
currentDragInfo.dragOffset.absoluteValue < restickDistance
}
if (newStuck != currentDragInfo.stuck) {
if (newStuck) {
onStuck()
} else {
onUnstuck()
}
}
val newDragOffset = currentDragInfo.dragOffset + dragDelta
val newUnstuckProgress = if (newStuck) {
(newDragOffset / unstickDistance).coerceIn(0f, 1f)
} else {
(newDragOffset / restickDistance).coerceIn(0f, 1f)
}
return currentDragInfo.copy(
dragOffset = newDragOffset,
stuck = newStuck,
unstuckProgress = newUnstuckProgress
).also {
dragInfo = it
}
}
fun reset() {
dragInfo = null
}
}
package de.kuno.snappyswipe.snappyswipe
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.spring
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
import kotlin.math.absoluteValue
@Composable
fun SnappyItem(
key: Any,
modifier: Modifier = Modifier,
dragCoordinatorState: DragCoordinatorState<SnappyDraggedItemInfo>,
onDismissed: () -> Unit,
settings: SnappyDragSettings,
content: @Composable BoxScope.(() -> ItemState<SnappyDraggedItemInfo>?) -> Unit,
) {
var dismissing by remember(key) { mutableStateOf(false) }
val offsetAnimatable = remember(key) { Animatable(0f) }
var width by remember { mutableStateOf(0) }
val density = LocalDensity.current
val haptics = LocalHapticFeedback.current
val snappyDragHelper = remember(
key,
settings.unstickDistance,
settings.restickDistance
) {
density.run {
SnappyDragHelper(
key = key,
unstickDistance = settings.unstickDistance.toPx(),
restickDistance = settings.restickDistance.toPx(),
onStuck = {
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
},
onUnstuck = {
haptics.performHapticFeedback(HapticFeedbackType.Confirm)
}
)
}
}
LaunchedEffect(key) {
snapshotFlow { offsetAnimatable.value }.collect {
launch {
dragCoordinatorState.updateOffset(key, it)
}
}
}
val itemState = remember(key) {
{ dragCoordinatorState.getItemState(key) }
}
val shapeHelper = rememberShapeHelper(
minCornerRadius = { settings.minCornerRadius },
maxCornerRadius = { settings.maxCornerRadius },
maxAtOffsetDelta = settings.unstickDistance,
animationSpec = { settings.cornerRadiusAnimationSpec },
itemState = itemState,
)
LaunchedEffect(settings.holdDrag) {
if (!settings.holdDrag && dragCoordinatorState.dragInfo?.key == key) {
dragCoordinatorState.dragInfo = null
}
}
LaunchedEffect(key) {
combine(
snapshotFlow { itemState() }.filterNotNull(),
snapshotFlow { settings.affectedNeighbours },
) { itemState, affectedNeighbours ->
if (dismissing) return@combine
launch(Dispatchers.Main.immediate) {
if (itemState.draggedItemRelation == null) {
snappyDragHelper.reset()
offsetAnimatable.animateTo(
0f,
settings.offsetAnimationSpec
)
} else {
val draggedItemRelation = itemState.draggedItemRelation
val dragOffset = draggedItemRelation.draggedItemInfo.dragOffset
val isAffected = draggedItemRelation.sameSegmentAsDraggedItem && draggedItemRelation.indexDelta.absoluteValue <= affectedNeighbours
val offset = when {
// Follow the drag offset. Add friction if stuck
itemState.isDraggedItem -> dragOffset / if (draggedItemRelation.draggedItemInfo.stuck) settings.friction else 1f
// Is one of the affected neighbours. The higher the distance to the dragged item, the less the offset
draggedItemRelation.draggedItemInfo.stuck && isAffected -> dragOffset / (affectedNeighbours + 1) * ((affectedNeighbours + 1) - draggedItemRelation.indexDelta.absoluteValue) / settings.friction
// Reset
else -> 0f
}
if (itemState.isDraggedItem) {
offsetAnimatable.animateTo(
offset,
spring()
)
} else {
offsetAnimatable.animateTo(
offset,
settings.offsetAnimationSpec
)
}
}
}
}.collect()
}
val draggedKey = {
itemState()?.draggedItemRelation?.draggedItemInfo?.key
}
DisposableEffect(key) {
onDispose {
if (draggedKey() == key) {
snappyDragHelper.reset()
dragCoordinatorState.dragInfo = null
}
}
}
Box(
modifier = modifier
.onSizeChanged {
width = it.width
}.draggable(
state = rememberDraggableState {
if (draggedKey() == key) {
snappyDragHelper.updateDragInfo(it)
dragCoordinatorState.dragInfo = snappyDragHelper.dragInfo
}
},
orientation = Orientation.Horizontal,
onDragStarted = {
if (draggedKey() == null) {
dismissing = false
snappyDragHelper.onDragStarted(offsetAnimatable.value)
dragCoordinatorState.dragInfo = snappyDragHelper.dragInfo
}
},
onDragStopped = { velocity ->
if (draggedKey() == key) {
if (settings.holdDrag) {
return@draggable
}
val dragInfo = requireNotNull(snappyDragHelper.dragInfo)
dragCoordinatorState.dragInfo = null
val dismissRight =
velocity >= DISMISS_MIN_VELOCITY || velocity >= 0f && !dragInfo.stuck && dragInfo.dragOffset >= 0f
val dismissLeft =
velocity <= -DISMISS_MIN_VELOCITY || velocity <= 0f && !dragInfo.stuck && dragInfo.dragOffset <= 0f
if (dismissRight || dismissLeft) {
if (dragInfo.stuck) {
haptics.performHapticFeedback(HapticFeedbackType.Confirm)
}
dismissing = true
offsetAnimatable.animateTo(
targetValue = (if (dismissRight) width else -width).toFloat(),
initialVelocity = velocity,
)
onDismissed()
}
}
}
),
) {
Box(
modifier = Modifier.padding(horizontal = 16.dp).fillMaxWidth().offset {
IntOffset(
offsetAnimatable.value.toInt(),
0
)
}.graphicsLayer {
shape = shapeHelper.shape
clip = true
},
) {
content(itemState)
}
}
}
class SnappyDragSettings(
unstickDistance: Dp = 100.dp,
restickDistance: Dp = 50.dp,
minCornerRadius: Dp = 0.dp,
maxCornerRadius: Dp = 24.dp,
affectedNeighbours: Int = 1,
offsetAnimationSpec: FiniteAnimationSpec<Float> = spring(),
cornerRadiusAnimationSpec: FiniteAnimationSpec<Dp> = spring(),
friction: Float = 2f,
holdDrag: Boolean = false,
) {
var unstickDistance by mutableStateOf(unstickDistance)
var restickDistance by mutableStateOf(restickDistance)
var minCornerRadius by mutableStateOf(minCornerRadius)
var maxCornerRadius by mutableStateOf(maxCornerRadius)
var affectedNeighbours by mutableIntStateOf(affectedNeighbours)
var friction by mutableFloatStateOf(friction)
var holdDrag by mutableStateOf(holdDrag)
var offsetAnimationSpec by mutableStateOf(offsetAnimationSpec)
var cornerRadiusAnimationSpec by mutableStateOf(cornerRadiusAnimationSpec)
}
@Composable
fun rememberSnappyDragSettings(): SnappyDragSettings {
return remember { SnappyDragSettings() }
}
@Composable
fun <T> rememberSnappyDragCoordinatorState(
items: List<T>,
key: (T) -> Any?,
segmentType: (T) -> Any? = { null }
) = rememberDragCoordinatorState<SnappyDraggedItemInfo, T>(
items = items,
key = key,
segmentType = segmentType,
)
data class SnappyDraggedItemInfo(
override val key: Any?,
override val dragOffset: Float,
val unstuckProgress: Float,
val stuck: Boolean,
) : DraggedItemInfo
private const val DISMISS_MIN_VELOCITY = 4000f
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment