【Jetpack Compose】SwipeToDismissを使うときにぶち当たった壁2つ
Jetpack Composeで、一覧で表示した要素を、スワイプで削除するUIをつくりたい時に便利なコンポーザブル「SwipeToDissmiss」。
しかし、自分が使おうとしたら結構骨が折れたので、起きた2つの問題とその解決法について備忘録も兼ねて共有させていただきます。
動作イメージ
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. スワイプして削除したはずなのに背景が残る
画像のように、なぜか背景が残ってしまいます。
背景が残ってしまう
原因
この現象は、要素を識別するためのキーを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を持つデータクラス
data class Item(
val id: UUID = UUID.randomUUID(),
val text: String = ""
)
スワイプさせたい要素
@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の場合
@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の場合
@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で再現しようとすると微妙な感じなのですが、スマホの実機だと顕著でした。
閾値より小さい幅で消えてしまう
同じ現象に対する質問がStackOverflowでされていましたが、回答がついておらず...。
原因
ソースをなんとか解読した結果、この現象はSwipeToDismissが使っているSwipeable修飾子の、velocityThresholdというプロパティがデフォルト値のままになってしまっていることが原因なようでした。velocityの判定は微小時間で行うため、少しでも指が高速で動く瞬間があると、あっけなく閾値を超えてしまいます。
解決
SwipeToDismissには、velocityThresholdをいじれるインターフェースがどこにもありませんでした。
そのため自分の場合は、SwipeToDismissのコードをコピーしたCustomSwipeToDismissを作成し、その中でSwipeableを呼び出している部分に、velocityThresholdを追加し、その値を非常に大きな値にしました。(雑すぎ?)
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 // <- 大きな値にする(適当)
)
) {...
コードの全体はこちら
@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