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的。

效果

先上效果

Compose滑动删除

代码实现

/**
 * 使用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。

效果

先上效果

Compose滑动删除

代码实现

先引入依赖

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的实现。

效果

先上效果

Compose滑动删除

代码实现

在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)
                }
            }
        }
    }
}

© 版权声明

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
暂无评论...