こんにちは。Android アプリ開発担当の nagayama(@nagayan_dev)です。
今回は下のようなスワイプが途中で止まるレイアウトを Compose で作っていきたいと思います。
2024年10月3日追記
本記事で使用している Modifier.swipeable
の API は deprecated になりました。本記事の内容を新しい Modifier.anchoredDraggable
に置き換える記事を公開していますので、こちらも併せてご参照ください。
背景
リスト表示されている項目をスワイプして削除ができる UI を新規に作成することになりました。またスワイプの途中で背後にある「削除」ボタンが表示されるよう、スワイプ途中で止める要件がありました。これは iOS ではデフォルトで用意されている API になります
実現したい UI の仕様
仕様としては下記になります。
- 左方向に大きくスワイプした時、その項目をリストから削除する
- スワイプは途中で止めることができ、背後から「削除」ボタンが表示される
すでに用意されているAPI
Jetpack Compose ではスワイプ削除の SwipeToDismiss API が用意されています。
しかし、スワイプの状態として下記が定義されており、「デフォルト状態」か「スワイプ削除されている状態」かのどちらかしかなく、途中で止めることができませんでした。
enum class DismissValue { /** * Indicates the component has not been dismissed yet. */ Default, /** * Indicates the component has been dismissed in the reading direction. */ DismissedToEnd, /** * Indicates the component has been dismissed in the reverse of the reading direction. */ DismissedToStart }
今回はこちらの API を利用せず、swipeable を用いて自作をしていきたいと思います。
実装
完成したコードは下記のようになります。
private enum class OpenedSwipeableState { INITIAL, OPENED, OVER_SWIPED } @OptIn(ExperimentalMaterialApi::class) @Composable private fun SwipeableRow( onSwipe: () -> Unit, content: @Composable () -> Unit ) { val swipeableState = rememberSwipeableState( initialValue = OpenedSwipeableState.INITIAL, confirmStateChange = { when (it) { OpenedSwipeableState.INITIAL -> { // Initial Event } OpenedSwipeableState.OPENED -> { // Opened Event } OpenedSwipeableState.OVER_SWIPED -> { // Over Swipe Event // todo delete onSwipe.invoke() } } true } ) BoxWithConstraints { val constraintsScope = this val maxWidthPx = with(LocalDensity.current) { constraintsScope.maxWidth.toPx() } val deleteButtonWidth = 64.dp val deleteButtonWidthPx = with(LocalDensity.current) { deleteButtonWidth.toPx() } Box( Modifier .background(Color.Black) .swipeable( state = swipeableState, orientation = Orientation.Horizontal, reverseDirection = true, anchors = mapOf( 0f to OpenedSwipeableState.INITIAL, deleteButtonWidthPx to OpenedSwipeableState.OPENED, maxWidthPx to OpenedSwipeableState.OVER_SWIPED ) ) ) { DeleteButtonLayout() // 後から出てくる削除ボタン Box( modifier = Modifier .fillMaxWidth() .wrapContentHeight() .offset { IntOffset(-swipeableState.offset.value.roundToInt(), 0) } ) { content() //表側に表示するレイアウト } } } }
こちらを元に解説をしていきます。
SwipeableState
まずは状態を定義します。
- 初期状態(INITIAL)
- スワイプが途中で止まり、開いている状態(OPENED)
- スワイプされた状態(OVER_SWIPED)
の 3 種類を用意しました。
private enum class OpenedSwipeableState { INITIAL, OPENED, OVER_SWIPED }
次に Composable で SwipeableState の作成をします。confirmStateChange は State の変更が通知されます。OVER_SWIPED の時にスワイプされたものをリストから削除をするため、onSwipe で通知し、通知先で削除しています。
val swipeableState = rememberSwipeableState( initialValue = OpenedSwipeableState.INITIAL, confirmStateChange = { when (it) { OpenedSwipeableState.INITIAL -> { // Initial Event } OpenedSwipeableState.OPENED -> { // Opened Event } OpenedSwipeableState.OVER_SWIPED -> { // Over Swipe Event // todo delete onSwipe.invoke() } } true } )
swipeable
続いてスワイプ部分についてです。
Box( Modifier .background(Color.Black) .swipeable( state = swipeableState, orientation = Orientation.Horizontal, reverseDirection = true, anchors = mapOf( 0f to OpenedSwipeableState.INITIAL, deleteButtonWidthPx to OpenedSwipeableState.OPENED, maxWidthPx to OpenedSwipeableState.OVER_SWIPED ) ) ) {
今回は Modifier の swipeable でレイアウトをスワイプできるようにします。パラメータは下記です。
- state:スワイプ状態(先ほど作成したもの)
- orientation:スワイプできる方向。今回は横方向のスワイプなので Orientation.Horizontal を指定します。
- reverseDirection:デフォルトは右方向にスワイプできます。ここを true にすることで左方向にスワイプできるようになります。
- anchors:状態に対する px 値を Map 形式で指定します。次で解説します。
anchors
anchors に指定するパラメータについてです。スワイプをした時のアンカーポイントを State とセットにして指定します。 INITIAL 状態の時は 0 、OPENED は削除ボタン分を指定したかったので 64dp 、OVER_SWIPED は完全にスワイプされるため、画面の横幅サイズを指定したいと考えました。
この「64dp」や「画面の横幅サイズ」を取得するため、BoxWithConstraints を用いて現在の density からそれぞれの px 値を取得するようにしました。
BoxWithConstraints { val constraintsScope = this val maxWidthPx = with(LocalDensity.current) { constraintsScope.maxWidth.toPx() } val deleteButtonWidth = 64.dp val deleteButtonWidthPx = with(LocalDensity.current) { deleteButtonWidth.toPx() }
こちらで用意した px 値を anchors に設定しています。
また、anchors に指定する以外に、実際のレイアウトを横に移動させる指定が必要になります。スワイプ対象の offset を指定します。state から offset を取得することができ、これを Int に変換します。offset は右方向が正の値になるため、今回は「マイナス値」で指定をします。
Box( modifier = Modifier .fillMaxWidth() .wrapContentHeight() .offset { IntOffset(-swipeableState.offset.value.roundToInt(), 0) } ) { content() //表側に表示するレイアウト }
以上により、スワイプ削除と途中でスワイプが止まる Swipeable レイアウトが完成しました。
まとめ
途中で止まる Swipeable レイアウトを作成してみました。Compose はその自由な設計思想により、実装の工夫により様々なものが作成できると実感しました。これからも色んなレイアウトに挑戦していきたいと思います。