Compose滑动删除

在使用原生开发的时候,Android为了仿照iOS的左滑删除菜单,有一些好用的三方库,比如SwipeRevealLayout,可以实现侧滑删除。当转向Compose开发,如何实现滑动删除功能呢?

找了一圈,找到了Material3自带方式和另外两个三方库,有各自不同的效果,可以根据需要的效果来选择使用哪种方式。

简单模拟一下列表数据模型:

kotlin 复制代码
data class DemoData(
    val id: Int,
    val title: String,
)
kotlin 复制代码
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,有些许不同,但大同小异。

声明

less 复制代码
@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的。

效果

先上效果

代码实现

scss 复制代码
/**
 * 使用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里定义滑动到什么位置会改变状态,即滑动阈值。

滑动状态有三种:

c 复制代码
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并未解决这个问题,不知道后续是否会解决这个问题。

通知参考以下资料,找到了一个解决办法

解决方法:添加一个Float变量记录当前的滑动进度,当前定的滑动阈值为总长度四分之一,因此滑动进度大于四分之一时才允许进行侧滑操作。

最终优化后的代码:

scss 复制代码
/**
 * 使用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库

github.com/saket/swipe

效果类似Material3自带的SwipeToDismissBox,也是滑动后放手松开将会立即执行操作,官方声明这是被设计用于非删除操作的侧滑动作。

声明

ini 复制代码
@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() }
    )
  }

  ...
}
less 复制代码
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。

效果

先上效果

代码实现

先引入依赖

arduino 复制代码
implementation "me.saket.swipe:swipe:1.3.0"
ini 复制代码
/**
 * 使用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库

github.com/linversion/...

技术探索:开源分享 - 在Jetpack Compose中实现iOS丝滑左滑菜单交互设计

该库的作者在me.saket.swipe:swipe开源库基础上进行修改,效果不再是滑动后放手松开将会立即执行操作,而是需要再次点击才会触发操作,效果仿照iOS左滑菜单交互。

在Box的左右两边分别用一个Row放置Action,通过offset,使得Row刚好不可见,滑动的时候改变offset,每个Action平分滑动的空间,直到Action完全展示后加一个阻尼的效果,完全仿照iOS的实现。

效果

先上效果

代码实现

在me.saket.swipe:swipe的代码实现上稍作修改,一些参数名的替换,其余都是一样的,就不多说了。

先添加仓库并引入依赖

scss 复制代码
// settings.gradle.kts
repositories {
  maven { setUrl("https://jitpack.io") }
}
scss 复制代码
// build.gradle.kts
implementation("com.github.linversion.swipe-like-ios:swipe-like-ios:1.0.1")
ini 复制代码
/**
 * 在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)
                }
            }
        }
    }
}
相关推荐
雨白2 小时前
Jetpack系列(二):Lifecycle与LiveData结合,打造响应式UI
android·android jetpack
kk爱闹4 小时前
【挑战14天学完python和pytorch】- day01
android·pytorch·python
每次的天空5 小时前
Android-自定义View的实战学习总结
android·学习·kotlin·音视频
恋猫de小郭6 小时前
Flutter Widget Preview 功能已合并到 master,提前在体验毛坯的预览支持
android·flutter·ios
断剑重铸之日7 小时前
Android自定义相机开发(类似OCR扫描相机)
android
随心最为安7 小时前
Android Library Maven 发布完整流程指南
android
岁月玲珑7 小时前
【使用Android Studio调试手机app时候手机老掉线问题】
android·ide·android studio
还鮟11 小时前
CTF Web的数组巧用
android
小蜜蜂嗡嗡12 小时前
Android Studio flutter项目运行、打包时间太长
android·flutter·android studio
aqi0012 小时前
FFmpeg开发笔记(七十一)使用国产的QPlayer2实现双播放器观看视频
android·ffmpeg·音视频·流媒体