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并未解決這個問題,不知道后續是否會解決這個問題。

通知參考以下資料,找到了一個解決辦法

解決方法:添加一個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)
                }
            }
        }
    }
}
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容