Jetpack Composeを使ったSwipeableなPopup UIの実装

こんにちは。今年もこの季節がやってきました。株式会社ZOZOでAndroidエンジニアをしております@zzt-osamuhanzawaです。[ZOZO Advent Calendar 2023](https://qiita.com/advent-calendar/2023/zozo)の13日目シリーズ５の担当します。最近のAndroidのUI実装する際、Composeで実装するのが増えてきたかと思います。今回はComposeでポップアップのような画面上部にユーザーに通知し、左スワイプで消えるようなUIの実装について紹介したいと思います。

## はじめに
スワイプ操作はCompose Material API である[`swipeable`](https://developer.android.com/reference/kotlin/androidx/compose/material/SwipeableState)を利用します。ただし、いきなりですが`swipeable`は今後、[`AnchoredDraggable`](https://developer.android.com/jetpack/compose/touch-input/pointer-input/migrate-swipeable?hl=en)に置き換わるそうです。ただ、そちらは試験運用ということで今回は `swipeable`での方法で話していきたいと思います。画面上部にポップアップ表示し、左スワイプで消えるようなユースケースとしてはユーザーへの何らかの通知やワーニングを知らせるようなUIをイメージです。

## SwapeableなComposeの実装について
早速ですが実装を見ていきましょう。以下のコードが実現したいComposeのベースとなる部分です。
```kotlin
@Composable
fun SwipeablePopup(
    widthDp: Dp = 340.dp,
    onDismiss: () -> Unit,
    backgroundColor: Color = Color.White,
    content: @Composable BoxScope.() -> Unit,
) {
    val swipeableState = rememberSwipeableState(1)
    val screenWidth = LocalConfiguration.current.screenWidthDp
    val screenWidthPx = with(LocalDensity.current) { screenWidth.dp.toPx() }
    val sizePx = with(LocalDensity.current) { widthDp.toPx() }
    val centerOffset = screenWidthPx * 0.5f - sizePx * 0.5f
    val anchors = mapOf(-screenWidthPx to 0, centerOffset to 1)
    val rawOffsetX = swipeableState.offset.value.roundToInt()
    val offsetX = if (rawOffsetX > 0) 0 else rawOffsetX

    if (swipeableState.currentValue == 0) {
        onDismiss()　 // dismissを通知
    }
    Box(
        modifier = Modifier.fillMaxWidth()
    ) {
        Box(
            modifier = Modifier
                .align(Alignment.TopCenter)
                .offset { IntOffset(offsetX, 0) }
                .width(widthDp)
                .background(
                    color = backgroundColor,
                    shape = RoundedCornerShape(16.dp)
                )
                .swipeable(
                    state = swipeableState,
                    anchors = anchors,
                    thresholds = { _, _ -> FractionalThreshold(0.3f) },
                    orientation = Orientation.Horizontal,
                )

        ) {
            content()　 // 実際に表示したいCompose
        }
    }
}
```
重要なのは`.swipeable`の箇所ですが、パラメータに関しては
- `state`: スワイプの状態（今回は 1 or 0）
- `thresholds`: スワイプ状態を維持するかしないかの閾値
- `orientation`: 縦横どちらにスワイプするか、今回は横スワイプ

となります。`anchors`に関してはstateに対するpx値をMapで指定します。今回は左スワイプでPopupをdismissしたいため画面の横幅サイズのマイナス値を指定してます。右スワイプで消したい場合は横幅サイズを指定すれば良いと思います。
また、指の動きとPopUpを連動させたいため`swipeableState`からoffsetの値を受け取り、`Box`の`.offset`を以下のように指定しています。

```kotlin
.offset { IntOffset(offsetX, 0) }
```
以上が今回、実現したいUIのベースになるCompose実装になります。
あとは`SwipeablePopup`のパラメータである`content`に表示したいComposeを渡してあげるだけです。

```kotlin
fun PopupWarning(
    widthDp: Dp = 340.dp,
    onDismiss: () -> Unit,
) {
    SwipeablePopup(
        widthDp = widthDp,
        onDismiss = onDismiss,
    ) { 
        // 表示したいレイアウトを実装
    }
}
```

## 自動でDismissするようなPopup UI
ネタとして少し寂しいので、このSwipeablePopupを利用して勝手にdismissする実装についても少し書きたいと思います。画面上部にPopupが表示され続けるのもUX的には良くないこともあるので、例えば数秒後に消えるような実装についてです。これを実現する場合は、 `AnimatedVisibility`と`delay()`を使い表示状態を変化させます。以下が全体のコードとなります。

```kotlin
@Composable
fun SomeErrorPopup(
    title: String,
    paragraph: String,
    autoDismissMs: Long = 3000L, // 指定がなかったら3秒表示
) {
    val state = remember {
        MutableTransitionState(false).apply { targetState = true }
    }
    AnimatedVisibility(
        visibleState = state,
        enter = slideInVertically() + fadeIn(),
        exit = slideOutHorizontally() + fadeOut()
    ) {
        if (autoDismissMs > 0) {
            LaunchedEffect(Unit) {
                delay(autoDismissMs)
                state.targetState = false
            }
        }
        PopupWarning(
            onDismiss = {
                state.targetState = false
            },
            title = title,
            paragraph = paragraph,
        )
    }
}
```
特にこれといった難しく点もなく、`AnimatedVisibility`を利用し、指定した時間だけdelayさせ、時間が経過した場合、非表示、もしくはスワイプアウトした時点で表示状態を非表示にするといった処理を行います。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/218011/bf00c7fe-df86-9297-6344-77ae3917b607.gif" width="250">

### まとめ
簡単ですが今回は`swipeable`なPopup UIをComposeでどう実装するかを説明しました。何かの参考になれば嬉しいです。

## 参考資料
- 
https://developer.android.com/reference/kotlin/androidx/compose/material/SwipeableState
- https://developer.android.com/jetpack/compose/touch-input/pointer-input/migrate-swipeable?hl=en
- https://tech.gunosy.io/entry/android_compose_swipe_stop