0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

目的

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

元々のコード

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

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を作りたい

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

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

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

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

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

GestureDetectorのコードを深掘る

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

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

GestureDetectorのコード

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

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

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

mMinimumFlingVelocity = ViewConfiguration.getMinimumFlingVelocity();

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

結論

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


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()
                    }
                }
            }
        }
    }
}

このサンプルから、ビューを移動させるアニメーション部分を削除しています。
なので、当然、操作中にビューは動きません。

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

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

modifier = modifier.flingDownToDismiss(
   minimumFlingVelocity = scaledMinimumFlingVelocity,
)

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

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さんとの応答に苦労しました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?