GestureDetectorのonFlingのような動作をComposeでやる

## 目的
AndroidView時代(xmlレイアウト)のころ、`GestureDetector`の`onFling`で操作を検出して、画面を閉じるというのを実装していましたが、Compose化するときにFling操作を検出する簡単な方法がなくて困りました。
`GestureDetector`のコードを深掘りして、あとはGeminiも聞いたりして類似動作を実現しました。

## 元々のコード

`GestureDetector#onFling`でコールバックしてやっているだけです。

```kotlin
import android.content.Context
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.widget.FrameLayout
import androidx.core.view.GestureDetectorCompat
import kotlin.math.abs

class FlingDownView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0,
    defStyleRes: Int = 0
) : FrameLayout(context, attrs, defStyleAttr, defStyleRes) {

    interface OnFlingListener {
        fun onFlingDown()
    }

    private var onFlingListener: OnFlingListener? = null

    fun enableFling() {
        val gestureDetector = GestureDetectorCompat(
            context,
            object : GestureDetector.SimpleOnGestureListener() {

                override fun onDown(e: MotionEvent): Boolean {
                    return true
                }

                override fun onFling(
                    ev1: MotionEvent?,
                    ev2: MotionEvent,
                    velocityX: Float,
                    velocityY: Float
                ): Boolean {
                    // 方向と速度によって出し分ける
                    val y0 = ev1?.y ?: return false
                    val x0 = ev1.x
                    val dy = ev2.y - y0
                    val dx = ev2.x - x0
                    if (
                        abs(velocityY) > abs(velocityX) && // Y軸速度の方が早い
                        abs(dy) > abs(dx) && // Y軸移動距離の方が大きい
                        dy > 0 // 下向きの場合
                    ) {
                        onFlingListener?.onFlingDown()
                        return true
                    }
                    return false
                }
            }
        )
        setOnTouchListener { view, motionEvent ->
            val result = gestureDetector.onTouchEvent(motionEvent)
            view.performClick()
            result
        }
    }

    fun setOnFlingListener(listener: OnFlingListener?) {
        onFlingListener = listener
    }
}
```

## Geminiからのヒント

だいたい以下のように質問しました。

```
画面のFling操作で閉じるBoxを作りたい
```

結果は、以下のページのサンプルをほぼそのまま提示されました。

https://developer.android.com/develop/ui/compose/touch-input/pointer-input/drag-swipe-fling

このページは、スワイプ操作の例しか載っていないので(タイトル詐欺ですねw)、あまり参考になりません。また、ページにある`anchoredDraggable`を使って、というような指令も出してみましたが、あまり芳しい結果にはなりませんでした。

そんなこんなで質問しているとき、参考リンクで提示されたのが以下のページでした。

https://developer.android.com/develop/ui/compose/animation/advanced

このページの、`swipeToDismiss`を参考に試行錯誤しましたが、

スワイプじゃなくて、**フリング**で閉じたいんだよ！`GestureDetector`では簡単にフリング検知できたのに、なんで標準で用意されてないんだ！と自力検索の海へ・・・

## GestureDetectorのコードを深掘る

難しいのは、スワイプ操作のうち、一定の速度以上をフリング判定することでした。
そこで、「GestureDetectorのonFlingが呼ばれる条件を調べれば良いのでは？」と思いつきました。

というわけで、元々のコードの`onFling`がコールバックされたところにブレークポイントを貼って、止まったときにスタックトレースを遡っていく形でAndroid SDKのコードを覗いていきました。

[GestureDetectorのコード](https://android.googlesource.com/platform/frameworks/base/+/refs/heads/master/core/java/android/view/GestureDetector.java)

こいつの`onTouchEvent`内の、以下がコールバックされている箇所です。

```java
if ((Math.abs(velocityY) > mMinimumFlingVelocity)
      || (Math.abs(velocityX) > mMinimumFlingVelocity)) {
    handled = mListener.onFling(mCurrentDownEvent, ev, velocityX, velocityY);
}
```

`mMinimumFlingVelocity`の値が分かれば良さそうだ！と追っていくと、次のように代入されています。

```java
mMinimumFlingVelocity = ViewConfiguration.getMinimumFlingVelocity();
```

となれば、この値を受け取ってFlingと判定するModifierが書ければ良さそうです。

## 結論

最終的に以下のようなコードでとりあえず動いているようです。

```kotlin

import android.annotation.SuppressLint
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.verticalDrag
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.util.VelocityTracker
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlin.math.abs

@SuppressLint("UnnecessaryComposedModifier")
@Composable
fun Modifier.flingDownToDismiss(
    minimumFlingVelocity: Int,
    onDismissed: () -> Unit
): Modifier = composed {
    pointerInput(Unit) {
        // Use suspend functions for touch events.
        coroutineScope {
            while (true) {
                val velocityTracker = VelocityTracker()
                awaitPointerEventScope {
                    // Detect a touch down event.
                    val pointerId = awaitFirstDown().id

                    verticalDrag(pointerId) { change ->
                        velocityTracker.addPosition(
                            change.uptimeMillis,
                            change.position
                        )
                    }
                }
                // No longer receiving touch events.
                val velocity = velocityTracker.calculateVelocity()

                launch {
                    if (abs(velocity.y) > abs(velocity.x) && // Y軸速度の方が早い
                        velocity.y > minimumFlingVelocity // 指定速度以上
                    ) {
                        onDismissed()
                    }
                }
            }
        }
    }
}
```

[このサンプル](https://developer.android.com/develop/ui/compose/animation/advanced)から、ビューを移動させるアニメーション部分を削除しています。
なので、当然、操作中にビューは動きません。

これを使う側で、`minimumFlingVelocity`の値を次のように取得して設定してやれば良いです。

```kotlin
val context = LocalContext.current
// GestureDetectorでFling判定に使われていたのと同じにする
val scaledMinimumFlingVelocity = android.view.ViewConfiguration.get(context).scaledMinimumFlingVelocity

modifier = modifier.flingDownToDismiss(
   minimumFlingVelocity = scaledMinimumFlingVelocity,
)
```

毎回`scaledMinimumFlingVelocity`を取得するのは面倒なのでラップしたBoxにしちゃえばよいですね。

```kotlin
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext

@Composable
fun FlingDownBox(
    modifier: Modifier,
    onFlingDown: () -> Unit,
    content: @Composable BoxScope.() -> Unit,
) {
    val context = LocalContext.current
    // GestureDetectorでFling判定に使われていたのと同じにする
    val scaledMinimumFlingVelocity = android.view.ViewConfiguration.get(context).scaledMinimumFlingVelocity

    Box(
        modifier = modifier.flingDownToDismiss(
            minimumFlingVelocity = scaledMinimumFlingVelocity,
            onDismissed = onFlingDown,
        ),
    ) {
        content()
    }
}
```

`ViewConfiguration`は、composeのパッケージにもあるのですが、`scaledMinimumFlingVelocity`のようなプロパティがなく、viewのパッケージのものを使っています。将来にわたってこれで取得できるかは保証できませんが、今のところは代用できるのではないでしょうか。

以上、参考になれば幸いです。

## 愚痴

`Swipe to dismiss`だと、「リストの行を横にスワイプして消すこと」としか解釈されないGeminiさんとの応答に苦労しました。