横方向RecyclerViewとSwipeRefreshLayoutが競合してRecyclerViewがスクロールしづらい問題を解決する


本記事はand factory Advent Calendar 2019の11日目の記事になります。昨日はMatsuNaoPenさんのadbコマンドで端末を操作するでした。

問題

僕が開発しているアプリには上記のような画面があります。なんら難しくないRecyclerViewをSwipeRefreshLayoutで囲んであげるだけのやつですね。

<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
    android:id="@+id/swipe_refresh_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

こんな感じのxmlになるかと思います。


ある日社内チェックで「なんかスクロールしづらいんですけど、、、」という指摘をされました。
確かに、斜め方向にスクロールすると横にスクロールしたりSwipeRefreshLayoutが反応したりと使いづらいなと感じたので修正してみました。

原因はSwipeRefreshLayoutにあり

SwipeRefreshLayoutがスクロールに過敏に反応しすぎてしまうのが原因であることがわかりました。よってSwipeRefreshLayoutをカスタマイズして凡そ縦方向に指をスライドさせた時にだけ反応するように修正します。

修正方法

コードはこちらです

class OnlyVerticalSwipeRefreshLayout(context: Context, attrs: AttributeSet) :
    SwipeRefreshLayout(context, attrs) {

    private var touchSlop: Int = ViewConfiguration.get(context).scaledTouchSlop
    private var prevX: Float = 0.toFloat()
    private var declined: Boolean = false

    override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                prevX = MotionEvent.obtain(event).x
                declined = false // New action
            }

            MotionEvent.ACTION_MOVE -> {
                val eventX = event.x
                val xDiff = abs(eventX - prevX)
                if (declined || xDiff > touchSlop) {
                    declined = true // Memorize
                    return false
                }
            }
        }
        return super.onInterceptTouchEvent(event)
    }
}

ポイントその1:scaledTouchSlop

scaledTouchSlopとは公式ドキュメントによると

Distance in pixels a touch can wander before we think the user is scrolling

と書かれており、

ユーザーがスクロールを開始したとOSが認識する前にスクロールできるピクセル距離

ということだと解釈しました。要するにスクロールを開始した瞬間の指の移動距離のことだと思います。

ポイントその2:onInterceptTouchEvent

onInterceptTouchEventでは子Viewで発生したイベントを親Viewが傍受します。更にそのタッチイベントを親Viewイベント奪う場合はtrue、親Viewがイベントを奪わず子Viewにイベントを流す場合はfalseを返却します。今回のケースでは子Viewが横方向RecyclerView、親ViewがSwipeRefreshLayoutになります。

ポイントその3:指の移動距離からSwipeRefreshLayoutがイベントを奪うかどうか判断する

when (event.action) {
    MotionEvent.ACTION_DOWN -> {
        prevX = MotionEvent.obtain(event).x
        declined = false // New action
    }

    MotionEvent.ACTION_MOVE -> {
        val eventX = event.x
        val xDiff = abs(eventX - prevX)
        if (declined || xDiff > touchSlop) {
            declined = true // Memorize
            return false
        }
    }
}

この部分になります。
ACTION_DOWNで最初のタップ位置を保持します。ACTION_MOVEでx方向の移動距離を計算して、移動距離がtouchSlopを超えた場合はRecyclerViewにイベントを流します。


最後にSwipeRefreshLayoutをOnlyVerticalSwipeRefreshLayoutに変更してあげれば修正完了です。

参考サイト