Compose滑动删除
在使用原生开发的时候,Android为了仿照iOS的左滑删除菜单,有一些好用的三方库,比如SwipeRevealLayout,可以实现侧滑删除。当转向Compose开发,如何实现滑动删除功能呢?
找了一圈,找到了Material3自带方式和另外两个三方库,有各自不同的效果,可以根据需要的效果来选择使用哪种方式。
简单模拟一下列表数据模型:
data class DemoData(
val id: Int,
val title: String,
)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val data = mutableListOf<DemoData>()
repeat(10) {
data.add(it, DemoData(it, "Item: $it"))
}
setContent {
ComposeSwipeDemoTheme {
SwipeToDismissBoxDemo(data)
}
}
}
}
Material3自带的SwipeToDismissBox(Material自带的SwipeToDismiss)
目前androidx.compose.material3: 1.2.1版本,自带的SwipeToDismissBox,可以实现侧滑后立即删除的效果。滑动后放手松开将会立即执行操作。Material自带的叫SwipeToDismiss,有些许不同,但大同小异。
声明
@Composable
@ExperimentalMaterial3Api
fun SwipeToDismissBox(
state: SwipeToDismissBoxState,
backgroundContent: @Composable RowScope.() -> Unit,
modifier: Modifier = Modifier,
enableDismissFromStartToEnd: Boolean = true,
enableDismissFromEndToStart: Boolean = true,
content: @Composable RowScope.() -> Unit,
) {
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
Box(
modifier
.anchoredDraggable(
state = state.anchoredDraggableState,
orientation = Orientation.Horizontal,
enabled = state.currentValue == SwipeToDismissBoxValue.Settled,
reverseDirection = isRtl,
),
propagateMinConstraints = true
) {
Row(
content = backgroundContent,
modifier = Modifier.matchParentSize()
)
Row(
content = content,
modifier = Modifier.swipeToDismissBoxAnchors(
state,
enableDismissFromStartToEnd,
enableDismissFromEndToStart
)
)
}
}
- state为滑动状态,SwipeToDismissBoxState,根据滑动状态可以定义滑动之后的操作。
- backgroundContent为显示在底下的内容,即侧滑之后被展示出来的内容。
- content为显示在上面的内容。
- 默认支持允许FromStartToEnd和FromEndToStart的侧滑。
可以看到内部实现是Box里面两层Row,当上面一层Row被滑动移走时,下面那层Row就会展示出来,两层Row布局都是全部充满Box的。
效果
先上效果
Material3自带的SwipeToDismissBox.gif代码实现
/**
* 使用material3自带的SwipeToDismissBox,滑动后放手松开立即执行
* Box里面嵌套两层Row,当上面一层Row被滑动移走时,下面那层Row就会展示出来,两层Row布局都是全部充满Box的。
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SwipeToDismissBoxDemo(list: MutableList<DemoData>) {
val data = remember {
mutableStateListOf<DemoData>()
}
data.addAll(list)
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(top = 50.dp),
) {
//items务必添加key,否则会造成显示错乱
itemsIndexed(data, key = { index, item -> item.id }) { index, item ->
//index和item都是最原始的数据,一旦onDelete和onChange过,index和item就都不准了,因此根据item的id作为唯一标识查找
SwipeToDismiss(
modifier = Modifier.animateItemPlacement(), //添加移除时的动画
content = { Text(item.title) },
onDelete = { data.remove(data.find { it.id == item.id }) },
onChange = {
data[data.indexOf(data.find { it.id == item.id })] =
item.copy(title = "Item has change: ${item.id}")
}
)
}
}
}
//使用material3自带的SwipeToDismissBox,滑动后放手松开立即执行
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SwipeToDismiss(
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit,
onDelete: () -> Unit,
onChange: () -> Unit,
) {
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = {
if (it == SwipeToDismissBoxValue.EndToStart) { //滑动后放手会执行
onDelete()
return@rememberSwipeToDismissBoxState true
}
if (it == SwipeToDismissBoxValue.StartToEnd) { //滑动后放手会执行
onChange()
}
return@rememberSwipeToDismissBoxState false
}, positionalThreshold = { //滑动到什么位置会改变状态,滑动阈值
it / 4
})
SwipeToDismissBox(
state = dismissState,
modifier = modifier
.padding(4.dp)
.fillMaxWidth()
.height(50.dp),
backgroundContent = {
val color by animateColorAsState(
when (dismissState.targetValue) {
SwipeToDismissBoxValue.StartToEnd -> Color.Green
SwipeToDismissBoxValue.EndToStart -> Color.Red
else -> Color.LightGray
}, label = ""
)
Box(
Modifier
.fillMaxSize()
.background(color),
contentAlignment = if (dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd) Alignment.CenterStart else Alignment.CenterEnd
) {
if (dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd)
Icon(
Icons.Default.Add,
contentDescription = "",
modifier = Modifier
)
else
Icon(
Icons.Default.Delete,
contentDescription = "",
modifier = Modifier
)
}
},
content = {
Box(
Modifier
.fillMaxSize()
.background(Color.White),
contentAlignment = Alignment.Center,
content = content
)
})
}
创建rememberSwipeToDismissBoxState,confirmValueChange里定义滑动放手后执行的内容,positionalThreshold里定义滑动到什么位置会改变状态,即滑动阈值。
滑动状态有三种:
enum class SwipeToDismissBoxValue {
/**
* Can be dismissed by swiping in the reading direction.
*/
StartToEnd,
/**
* Can be dismissed by swiping in the reverse of the reading direction.
*/
EndToStart,
/**
* Cannot currently be dismissed.
*/
Settled
}
当滑动距离未超过positionalThreshold定义的滑动阈值,状态就是Settled,超过滑动阈值后,根据滑动的方向,状态变为StartToEnd/EndToStart。
在上面的代码中,positionalThreshold滑动阈值定为总长度的四分之一,confirmValueChange里定义当滑动放手后状态,左滑为删除操作,将删除当前item,右滑为改变操作,将改变当前item的展示内容,返回false,放手后item将恢复原位,返回true,放手后item的上层展示内容将被移除可视区域,因此左滑触发删除之后返回true,而右滑触发改变操作之后仍然返回false。
backgroundContent中根据不同滑动状态定义了不同的背景色,可以在效果图中更好地感知到滑动状态的改变,右滑展示的是一个Add icon,左滑展示的是一个Delete icon。
解决轻扫(小范围快速滑动)触发侧滑操作问题
当轻扫item时,即使滑动距离并未超过positionalThreshold定义的滑动阈值,滑动状态也会变为StartToEnd/EndToStart,这就会触发侧滑操作,目前版本的SwipeToDismissBox并未解决这个问题,不知道后续是否会解决这个问题。
通知参考以下资料,找到了一个解决办法
- https://stackoverflow.com/questions/72676541/compose-swipetodismiss-confirmstatechange-applies-only-threshold
- https://issuetracker.google.com/issues/252334353
- https://juejin.cn/post/7273830778648297511
解决方法:添加一个Float变量记录当前的滑动进度,当前定的滑动阈值为总长度四分之一,因此滑动进度大于四分之一时才允许进行侧滑操作。
最终优化后的代码:
/**
* 使用material3自带的SwipeToDismissBox,滑动后放手松开立即执行
* Box里面嵌套两层Row,所以底下那层Row布局是全部充满的
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SwipeToDismissBoxDemo(list: MutableList<DemoData>) {
val data = remember {
mutableStateListOf<DemoData>()
}
data.addAll(list)
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(top = 50.dp),
) {
//items务必添加key,否则会造成显示错乱
itemsIndexed(data, key = { index, item -> item.id }) { index, item ->
//index和item都是最原始的数据,一旦onDelete和onChange过,index和item就都不准了,因此根据item的id作为唯一标识查找
SwipeToDismiss(
modifier = Modifier.animateItemPlacement(), //添加移除时的动画
content = { Text(item.title) },
onDelete = { data.remove(data.find { it.id == item.id }) },
onChange = {
data[data.indexOf(data.find { it.id == item.id })] =
item.copy(title = "Item has change: ${item.id}")
}
)
}
}
}
//使用material3自带的SwipeToDismissBox,滑动后放手松开立即执行
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SwipeToDismiss(
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit,
onDelete: () -> Unit,
onChange: () -> Unit,
) {
var currentProgress by remember {
mutableFloatStateOf(0f)
}
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = {
if (it == SwipeToDismissBoxValue.EndToStart) { //滑动后放手会执行
//注意是<1,回到末尾的时候,因为重新构建的关系,进度为变为1.0
if (currentProgress >= 0.25f && currentProgress < 1.0f) {
onDelete()
return@rememberSwipeToDismissBoxState true
}
}
if (it == SwipeToDismissBoxValue.StartToEnd) { //滑动后放手会执行
if (currentProgress >= 0.25f && currentProgress < 1.0f) {
onChange()
}
}
return@rememberSwipeToDismissBoxState false
}, positionalThreshold = { //滑动到什么位置会改变状态,滑动阈值
it / 4
})
//如果在这里使用LaunchedEffect,会造成当前组件频繁重组
ForUpdateData {/*缩小重组范围,减少重组*/
currentProgress = dismissState.progress
}
SwipeToDismissBox(
state = dismissState,
modifier = modifier
.padding(4.dp)
.fillMaxWidth()
.height(50.dp),
backgroundContent = {
val color by animateColorAsState(
when (dismissState.targetValue) {
SwipeToDismissBoxValue.StartToEnd -> Color.Green
SwipeToDismissBoxValue.EndToStart -> Color.Red
else -> Color.LightGray
}, label = ""
)
Box(
Modifier
.fillMaxSize()
.background(color),
contentAlignment = if (dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd) Alignment.CenterStart else Alignment.CenterEnd
) {
if (dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd)
Icon(
Icons.Default.Add,
contentDescription = "",
modifier = Modifier
)
else
Icon(
Icons.Default.Delete,
contentDescription = "",
modifier = Modifier
)
}
},
content = {
Box(
Modifier
.fillMaxSize()
.background(Color.White),
contentAlignment = Alignment.Center,
content = content
)
})
}
@Composable
private fun ForUpdateData(onUpdate: () -> Unit) {
onUpdate()
}
me.saket.swipe的swipe库
https://github.com/saket/swipe
效果类似Material3自带的SwipeToDismissBox,也是滑动后放手松开将会立即执行操作,官方声明这是被设计用于非删除操作的侧滑动作。
声明
@Composable
fun SwipeableActionsBox(
modifier: Modifier = Modifier,
state: SwipeableActionsState = rememberSwipeableActionsState(),
startActions: List<SwipeAction> = emptyList(),
endActions: List<SwipeAction> = emptyList(),
swipeThreshold: Dp = 40.dp,
backgroundUntilSwipeThreshold: Color = Color.DarkGray,
content: @Composable BoxScope.() -> Unit
) = Box(modifier) {
state.also {
it.swipeThresholdPx = LocalDensity.current.run { swipeThreshold.toPx() }
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
it.actions = remember(endActions, startActions, isRtl) {
ActionFinder(
left = if (isRtl) endActions else startActions,
right = if (isRtl) startActions else endActions,
)
}
}
...
val scope = rememberCoroutineScope()
Box(
modifier = Modifier
.onSizeChanged { state.layoutWidth = it.width }
.absoluteOffset { IntOffset(x = state.offset.value.roundToInt(), y = 0) }
.drawOverContent { state.ripple.draw(scope = this) }
.horizontalDraggable(
enabled = !state.isResettingOnRelease,
onDragStopped = {
scope.launch {
state.handleOnDragStopped()
}
},
state = state.draggableState,
),
content = content
)
(state.swipedAction ?: state.visibleAction)?.let { action ->
ActionIconBox(
modifier = Modifier.matchParentSize(),
action = action,
offset = state.offset.value,
backgroundColor = animatedBackgroundColor,
content = { action.value.icon() }
)
}
...
}
class SwipeAction(
val onSwipe: () -> Unit,
val icon: @Composable () -> Unit,
val background: Color,
val weight: Double = 1.0,
val isUndo: Boolean = false
)
- state滑动状态,默认不需要我们去创建和控制。
- 侧滑之后要展示的内容和操作,都被封装在了SwipeAction里,并通过startActions和endActions传入,可传入多个SwipeAction,在ActionIconBox里内部实现是一个Row,所有的SwipeAction将根据weight填满Row。
- swipeThreshold滑动阈值,只支持Dp类型。
- backgroundUntilSwipeThreshold当滑动距离未超过滑动阈值时展示的背景色。等同于SwipeToDismissBox中滑动状态为Settled时的背景色。
- content为显示在上面的内容。
可以看到内部实现是一个Box里面一个Box和Row(ActionIconBox),不同于SwipeToDismissBox是将两层显示内容叠在一块,SwipeableActionsBox是通过offset将Row置于Box两侧,滑动时改变offset,Row就被显示出来。Row布局是全部充满的,多个Actions会根据weight填满Row,例如给左滑设置了两个Action且默认weight都是1,那么只有当滑动距离超过一半时,才会显示出第2个Action并触发第2个Action。
效果
先上效果
me.saket.swipe的swipe库.gif代码实现
先引入依赖
implementation "me.saket.swipe:swipe:1.3.0"
/**
* 使用swipe库,滑动后放手松开立即执行
* Box里面Box和Row,通过offset,Row在Box两侧,滑动时Row被显示出来
* Row布局是全部充满的,多个actions根据weight填满Row
*/
@Composable
fun SwipeDemo(list: MutableList<DemoData>) {
val data = remember {
mutableStateListOf<DemoData>()
}
data.addAll(list)
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(top = 50.dp),
) {
//items务必添加key,否则会造成显示错乱
itemsIndexed(data, key = { index, item -> item.id }) { index, item ->
//index和item都是最原始的数据,一旦onDelete和onChange过,index和item就都不准了,因此根据item的id作为唯一标识查找
val delete = SwipeAction(
icon = {
Icon(
Icons.Default.Delete,
contentDescription = "",
modifier = Modifier
)
},
background = Color.Red,
onSwipe = { data.remove(data.find { it.id == item.id }) }
)
val change = SwipeAction(
icon = { Text("add") },
background = Color.Green,
isUndo = true,
onSwipe = {
data[data.indexOf(data.find { it.id == item.id })] =
item.copy(title = "Item has change: ${item.id}")
},
)
val change2 = SwipeAction(
icon = {
Icon(
Icons.Default.Add,
contentDescription = "",
modifier = Modifier
)
},
background = Color.Blue,
isUndo = true,
onSwipe = {
data[data.indexOf(data.find { it.id == item.id })] =
item.copy(title = "Item has change: ${item.id}")
},
)
SwipeableActionsBox(
startActions = listOf(change),
endActions = listOf(delete, change2),
swipeThreshold = 80.dp,
backgroundUntilSwipeThreshold = Color.LightGray,
) {
Box(
Modifier
.padding(4.dp)
.fillMaxWidth()
.height(50.dp)
.background(Color.White),
contentAlignment = Alignment.Center,
) {
Text(item.title)
}
}
}
}
}
在上面的代码中,swipeThreshold滑动阈值定为80.dp,backgroundUntilSwipeThreshold滑动距离未超过滑动阈值时为亮灰色。右滑为改变操作,展示内容是一个Text文本,背景绿色,将改变当前item的展示内容,左滑两个Action,先展示删除Action,背景红色,后展示改变Action,背景蓝色。
linversion的swipe-like-ios库
https://github.com/linversion/swipe-like-ios
技术探索:开源分享 - 在Jetpack Compose中实现iOS丝滑左滑菜单交互设计
该库的作者在me.saket.swipe:swipe开源库基础上进行修改,效果不再是滑动后放手松开将会立即执行操作,而是需要再次点击才会触发操作,效果仿照iOS左滑菜单交互。
在Box的左右两边分别用一个Row放置Action,通过offset,使得Row刚好不可见,滑动的时候改变offset,每个Action平分滑动的空间,直到Action完全展示后加一个阻尼的效果,完全仿照iOS的实现。
效果
先上效果
linversion的swipe-like-ios库.gif代码实现
在me.saket.swipe:swipe的代码实现上稍作修改,一些参数名的替换,其余都是一样的,就不多说了。
先添加仓库并引入依赖
// settings.gradle.kts
repositories {
maven { setUrl("https://jitpack.io") }
}
// build.gradle.kts
implementation("com.github.linversion.swipe-like-ios:swipe-like-ios:1.0.1")
/**
* 在me.saket.swipe:swipe开源库基础上进行修改,效果不再是滑动后放手松开将会立即执行操作,而是需要再次点击才会触发操作,效果仿照iOS左滑菜单交互。
*/
@Composable
fun SwipeLikeiOSDemo(list: MutableList<DemoData>) {
val data = remember {
mutableStateListOf<DemoData>()
}
data.addAll(list)
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(top = 50.dp),
) {
//items务必添加key,否则会造成显示错乱
itemsIndexed(data, key = { index, item -> item.id }) { index, item ->
//index和item都是最原始的数据,一旦onDelete和onChange过,index和item就都不准了,因此根据item的id作为唯一标识查找
val delete = SwipeAction(
icon = rememberVectorPainter(Icons.Default.Delete),
background = Color.Red,
onClick = { data.remove(data.find { it.id == item.id }) },
)
val change = SwipeAction(
icon = { Text("add") },
background = Color.Green,
onClick = {
data[data.indexOf(data.find { it.id == item.id })] =
item.copy(title = "Item has change: ${item.id}")
},
resetAfterClick = true,
iconSize = 20.dp
)
val change2 = SwipeAction(
icon = rememberVectorPainter(Icons.Default.Add),
background = Color.Blue,
onClick = {
data[data.indexOf(data.find { it.id == item.id })] =
item.copy(title = "Item has change: ${item.id}")
},
)
SwipeableActionsBox(
startActions = listOf(change),
endActions = listOf(delete, change2),
swipeThreshold = 80.dp
) {
Box(
Modifier
.padding(4.dp)
.fillMaxWidth()
.height(50.dp)
.background(Color.White),
contentAlignment = Alignment.Center,
) {
Text(item.title)
}
}
}
}
}