🧼

【Jetpack Compose】SwipeToDismissを使うときにぶち当たった壁2つ

2022/10/06に公開

Jetpack Composeで、一覧で表示した要素を、スワイプで削除するUIをつくりたい時に便利なコンポーザブル「SwipeToDissmiss」。

しかし、自分が使おうとしたら結構骨が折れたので、起きた2つの問題とその解決法について備忘録も兼ねて共有させていただきます。

swipetodismiss
動作イメージ

SwipeToDismissの基本的な使い方

以下の実装は、ColumnやLazyColumnで並べる要素の一つ一つに対して行います。(forEachやitemsの中で行う)

まず、dismissの状態を管理する変数を用意します。

val dismissState = rememberDismissState(
    confirmStateChange = {
        if (items.size > 1 && it == DismissValue.DismissedToStart) {
            // dismiss判定時に行いたい処理
            true // スワイプ方向に要素が消える(この場合はendからstartにスワイプしたとき起きる)
        } else {
            false // スワイプしても元に戻る
        }
    }
)

続いて、スワイプさせたい部品本体を、SwipeToDismissで囲います。

SwipeToDismiss(
    state = dismissState,
    modifier = Modifier,
    directions = setOf(DismissDirection.EndToStart), // スワイプの向き: StartToEndもある
    dismissThresholds = { FractionalThreshold(0.6f) },
    background = {
        // 背景(アイコンや色など)
    },
    dismissContent = {
        // スワイプさせたい部品本体
    }
)

Case 1. スワイプして削除したはずなのに背景が残る

画像のように、なぜか背景が残ってしまいます。
swipetodismiss_problem1
背景が残ってしまう

原因

この現象は、要素を識別するためのキーをColumn(LazyColumn)に渡していないと起きるようです。
-> 参考のStackOverflow1
-> 参考のStackOverflow2

解決

単なるStringのリストでは、keyにidを渡せません。自分の場合は、UUIDとStringをプロパティにもつデータクラスをつくりました。
LazyColumnのkeyにidを渡すには、

+items(items, {item:Item -> item.id}) { item ->
-items(items) { item ->
    // 繰り返す要素
}

Columnのkeyにidを渡すには、

items.forEach() { item ->
+    key(item.id) {
        // 繰り返す要素
+   }
}

とすればできるようです。

具体的な実装は以下の通りです。

idを持つデータクラス
Item.kt
data class Item(
    val id: UUID = UUID.randomUUID(),
    val text: String = ""
)
スワイプさせたい要素
OverviewItem.kt
@Composable
fun OverviewItem(
    modifier: Modifier = Modifier,
    text: String,
) {
    Box(
        modifier = modifier
            .padding(horizontal = 30.dp)
            .clip(RoundedCornerShape(8.dp))
            .fillMaxWidth()
            .height(40.dp)
            .background(Color.White),
        contentAlignment = Alignment.CenterStart,
    ) {
        Text(
            text = text,
            style = MaterialTheme.typography.body1,
            color = Color.Black,
            modifier = Modifier.padding(horizontal = 15.dp)
        )
    }
}
LazyColumnの場合
OverviewLazyColumnScreen.kt
@ExperimentalMaterialApi
@Composable
fun OverviewLazyColumnScreen(
    modifier: Modifier = Modifier,
    items: MutableList<Item>,
    removeItem: (Item) -> Unit,
) {
    Box(modifier = modifier.background(Color.LightGray)) {
        LazyColumn(
            modifier = Modifier
                .padding(top = 30.dp)
                .fillMaxSize(),
            verticalArrangement = Arrangement.spacedBy(10.dp),
        ) {
            items(items, {item:Item -> item.id}) { item -> // ここがポイント!
                val dismissState = rememberDismissState(
                    confirmStateChange = {
                        if (items.size > 1 && it == DismissValue.DismissedToStart) {
                            removeItem(item)
                            true
                        } else {
                            false
                        }
                    }
                )

                SwipeToDismiss(
                    state = dismissState,
                    modifier = Modifier,
                    directions = setOf(DismissDirection.EndToStart),
                    background = {
                        Box(
                            modifier = Modifier
                                .padding(horizontal = 30.dp)
                                .clip(RoundedCornerShape(8.dp))
                                .fillMaxWidth()
                                .height(40.dp)
                                .background(Color.Red),
                            contentAlignment = Alignment.CenterEnd
                        ) {
                            Icon(
                                imageVector = Icons.Default.Delete,
                                contentDescription = null,
                                tint = Color.White,
                                modifier = Modifier.padding(end = 20.dp)
                            )
                        }
                    },
                    dismissContent = {
                        OverviewItem(text = item.text)
                    }
                )
            }
        }
    }
}
Columnの場合
OverviewColumnScreen.kt
@ExperimentalMaterialApi
@Composable
fun OverviewColumnScreen(
    modifier: Modifier = Modifier,
    items: MutableList<Item>,
    removeItem: (Item) -> Unit,
) {
    Box(modifier = modifier.background(Color.LightGray)) {
        Column(
            modifier = modifier
                .fillMaxSize()
                .background(Color.LightGray),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(10.dp)
        ) {
            items.forEach() { item ->
                key(item.id) { // ここがポイント!
                    // dismissの状態を管理する
                    val dismissState = rememberDismissState(
                        confirmStateChange = {
                            if (items.size > 1 && it == DismissValue.DismissedToStart) {
                                removeItem(item)
                                true
                            } else {
                                false
                            }
                        }
                    )

                    SwipeToDismiss(
                        state = dismissState,
                        modifier = Modifier,
                        directions = setOf(DismissDirection.EndToStart),
                        background = {
                            Box(
                                modifier = modifier
                                    .padding(horizontal = 30.dp)
                                    .clip(RoundedCornerShape(8.dp))
                                    .fillMaxWidth()
                                    .height(40.dp)
                                    .background(Color.Red),
                                contentAlignment = Alignment.CenterEnd
                            ) {
                                Icon(
                                    imageVector = Icons.Default.Delete,
                                    contentDescription = null,
                                    tint = Color.White,
                                    modifier = Modifier.padding(end = 20.dp)
                                )
                            }
                        },
                        dismissContent = {
                            OverviewItem(text = item.text)
                        }
                    )
                }
            }
        }
    }
}

Case 2. 指を速く動かすと、わずかなスワイプで削除判定される

ちょっとPCで再現しようとすると微妙な感じなのですが、スマホの実機だと顕著でした。
swipetodesimiss_problem2
閾値より小さい幅で消えてしまう
同じ現象に対する質問がStackOverflowでされていましたが、回答がついておらず...。

原因

ソースをなんとか解読した結果、この現象はSwipeToDismissが使っているSwipeable修飾子の、velocityThresholdというプロパティがデフォルト値のままになってしまっていることが原因なようでした。velocityの判定は微小時間で行うため、少しでも指が高速で動く瞬間があると、あっけなく閾値を超えてしまいます。

解決

SwipeToDismissには、velocityThresholdをいじれるインターフェースがどこにもありませんでした。
そのため自分の場合は、SwipeToDismissのコードをコピーしたCustomSwipeToDismissを作成し、その中でSwipeableを呼び出している部分に、velocityThresholdを追加し、その値を非常に大きな値にしました。(雑すぎ?)

CustomSwipeToDismiss.kt(抜粋)
Box(
        Modifier.swipeable(
            state = state,
            anchors = anchors,
            thresholds = thresholds,
            orientation = Orientation.Horizontal,
            enabled = state.currentValue == DismissValue.Default,
            reverseDirection = isRtl,
            resistance = ResistanceConfig(
                basis = width,
                factorAtMin = minFactor,
                factorAtMax = maxFactor
            ),
+           velocityThreshold = 100000.dp // <- 大きな値にする(適当)
        )
    ) {...
コードの全体はこちら
CustomSwipeToDismiss.kt
@ExperimentalMaterialApi
@Composable
fun CustomSwipeToDismiss(
    state: DismissState,
    modifier: Modifier = Modifier,
    directions: Set<DismissDirection> = setOf(
        DismissDirection.EndToStart,
        DismissDirection.StartToEnd
    ),
    dismissThresholds: (DismissDirection) -> ThresholdConfig = { FractionalThreshold(0.5f) },
    background: @Composable RowScope.() -> Unit,
    dismissContent: @Composable RowScope.() -> Unit
) = BoxWithConstraints(modifier) {
    val width = constraints.maxWidth.toFloat()
    val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl

    val anchors = mutableMapOf(0f to DismissValue.Default)
    if (DismissDirection.StartToEnd in directions) anchors += width to DismissValue.DismissedToEnd
    if (DismissDirection.EndToStart in directions) anchors += -width to DismissValue.DismissedToStart

    val thresholds = { from: DismissValue, to: DismissValue ->
        dismissThresholds(getDismissDirection(from, to)!!)
    }
    val minFactor =
        if (DismissDirection.EndToStart in directions) SwipeableDefaults.StandardResistanceFactor else SwipeableDefaults.StiffResistanceFactor
    val maxFactor =
        if (DismissDirection.StartToEnd in directions) SwipeableDefaults.StandardResistanceFactor else SwipeableDefaults.StiffResistanceFactor
    Box(
        Modifier.swipeable(
            state = state,
            anchors = anchors,
            thresholds = thresholds,
            orientation = Orientation.Horizontal,
            enabled = state.currentValue == DismissValue.Default,
            reverseDirection = isRtl,
            resistance = ResistanceConfig(
                basis = width,
                factorAtMin = minFactor,
                factorAtMax = maxFactor
            ),
            velocityThreshold = 100000.dp // <- 大きな値にする(適当)
        )
    ) {
        Row(
            content = background,
            modifier = Modifier.matchParentSize()
        )
        Row(
            content = dismissContent,
            modifier = Modifier.offset { IntOffset(state.offset.value.roundToInt(), 0) }
        )
    }
}

private fun getDismissDirection(from: DismissValue, to: DismissValue): DismissDirection? {
    return when {
        // settled at the default state
        from == to && from == DismissValue.Default -> null
        // has been dismissed to the end
        from == to && from == DismissValue.DismissedToEnd -> DismissDirection.StartToEnd
        // has been dismissed to the start
        from == to && from == DismissValue.DismissedToStart -> DismissDirection.EndToStart
        // is currently being dismissed to the end
        from == DismissValue.Default && to == DismissValue.DismissedToEnd -> DismissDirection.StartToEnd
        // is currently being dismissed to the start
        from == DismissValue.Default && to == DismissValue.DismissedToStart -> DismissDirection.EndToStart
        // has been dismissed to the end but is now animated back to default
        from == DismissValue.DismissedToEnd && to == DismissValue.Default -> DismissDirection.StartToEnd
        // has been dismissed to the start but is now animated back to default
        from == DismissValue.DismissedToStart && to == DismissValue.Default -> DismissDirection.EndToStart
        else -> null
    }
}

まとめ

コンポーザブルとして用意されているのはありがたいですが、意外とトリッキーな仕様でした。
より良い実装や解決策をご存知の方がいらっしゃいましたら、ご教示いただけますと嬉しいです。

参考

SwipeToDismissの基本的な使い方
ComposeでスワイプできるLazyColumnを作成する [quesera2さんの記事]
Swipe to Dismiss — Jetpack Compose [Pankaj Raiさんの記事]
Extending ‘SwipeToDismiss’ in Jetpack Compose [sinasamakiさんの記事]
Swipe to delete in Jetpack Compose Lazy Column Android
[Muhammad Danishさんの記事]

スワイプして削除したはずなのに背景が残る
Material Swipe To Dismiss in Jetpack Compose with a Column instead of a LazyColumn [StackOverflow]
LazyColumn with SwipeToDismiss [StackOverflow]

指を速く動かすと、わずかなスワイプで削除判定される
Compose SwipeToDismiss confirmStateChange applies only >= threshold [StackOverflow]

Discussion