目的
AndroidView時代(xmlレイアウト)のころ、GestureDetector
のonFling
で操作を検出して、画面を閉じるというのを実装していましたが、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のコードを覗いていきました。
こいつの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さんとの応答に苦労しました。