在使用原生開發的時候,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,滑動后放手松開立即執行
* 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。
效果
先上效果
代碼實現
先引入依賴
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的實現。
效果
先上效果
代碼實現
在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)
}
}
}
}
}