美文网首页
Compose滑动删除

Compose滑动删除

作者: 愿天深海 | 来源:发表于2024-04-08 15:09 被阅读0次

    在使用原生开发的时候,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并未解决这个问题,不知道后续是否会解决这个问题。

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

    解决方法:添加一个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)
                    }
                }
            }
        }
    }
    

    相关文章

      网友评论

          本文标题:Compose滑动删除

          本文链接:https://www.haomeiwen.com/subject/azkxxjtx.html