Edited at

スクロールビュー外のジェスチャーをスクロールビューに反映させる (Android/iOS)


動機

あまり頻度のあるユースケースではないかもしれませんが、時折、下図のように画面よりもかなり小さいスクロールビューを見せたい時があります(個人的に何度かそういうデザインを実装したことがありました)。しかしスワイプやパンと言った動きは横方向に大きな動きなので、スクロールのジェスチャー自体は画面全体から取りたかったりします。

Japanese.png


Android の場合

GestureDetectoronScroll(MotionEvent, MotionEvent, Float, Float) を実装したものに、外側の view で発生した touch イベントを捕捉させます。そして onScroll(...) 内で得られた横方向の distance をスクロールビューに反映します。

コード例では、スクロールビューというより、ViewPager を利用した場合を用いますが、ScrollView でも RecyclerView でも基本的な考え方は使えると思います。

以下、実装例は Kotlin コードです。


GestureDetector を初期化

    private var isScrolling = false

private val panGestureDetector = GestureDetectorCompat(activity, object: GestureDetector.SimpleOnGestureListener() {
override fun onScroll(
e1: MotionEvent, e2: MotionEvent,
distanceX: Float, distanceY: Float
): Boolean {
// 1. ここに来るということは間違いなくスクロール中であるので、カスタムフラグを立てる。
isScrolling = true
// 2. もしまだ ViewPager に ドラッグを指示していなければ、ドラッグ開始を指示
if (view?.viewPager?.isFakeDragging() == false) {
view?.viewPager?.beginFakeDrag()
}
// 3. スクロールイベントと反対方向に ViewPager をドラッグ
view?.viewPager?.fakeDragBy(-distanceX)

return true
}

override fun onDown(e: MotionEvent?): Boolean {
// 4. onDown イベントをオーバーライドしておかないと、スクロールなど onDown から開始されるイベントを捕捉できないっぽい。
return true
}
})


View.OnTouchListener を作って、外側の view の touch イベントをキャッチ

    private val touchListner = object: View.OnTouchListener {

override fun onTouch(v: View?, event: MotionEvent?): Boolean {
// 1. panGestureDetector が処理できるイベントに関しては panGestureDetector に任せる
if (panGestureDetector.onTouchEvent(event)) {
return true
}

// 2. ドラッグの後処理。ACTION_UP を受理したということはドラッグが終了したということ。
if (event?.getAction() == MotionEvent.ACTION_UP) {
if (isScrolling) {
if (view?.viewPager?.isFakeDragging() == true) {
// 2.1. ViewPager にドラッグの終了を指示して
view?.viewPager?.endFakeDrag()
}
// 2.2. カスタムフラグをリセット
isScrolling = false
}
}

return true
}
}

あとは、touchListener をスクロールイベントを捕捉したい外側の view に設定してやるだけです。

    view.setOnTouchListener(touchListner)


結果

final_android.gif

スクロールビューは青い額の中ですが、額の外をスクロールしてもちゃんと額の中がスクロールします。残念ながら 100% というわけではなく、キリンのところで一度空振りしています。


iOS の場合

この記事は、総じてこれが書きたかった感じですが ^^; iOS はこの挙動を非常に簡単に書けます。UIScrollView のスクロールは、それに乗っている UIPanGestureRecognizerUISwipeGestureRecognizer によって管理されています。なので、これらの GestureRecognizer を外側の view に乗せてやれば、あら不思議、まるでスクロールビューのタッチエリアが外側まで拡張されているかのように動きます。

以下、コード例は Swift で書かれています。

        if let gestureRecognizers = scrollView.gestureRecognizers {

for gestureRecognizer in gestureRecognizers.makeIterator() {
if gestureRecognizer is UIPanGestureRecognizer || gestureRecognizer is UISwipeGestureRecognizer {
// 外側の view に GestureRecognizer を乗せる。
view.addGestureRecognizer(gestureRecognizer as UIGestureRecognizer)
}
}
}


結果

final_ios.gif

構造的に、外側のジェスチャーをそのまま中に伝えられるので、挙動自体も iOS の方がスムーズです。

ちなみに、この方法は WWDC 2014 Session 235 で紹介されています。


まとめ

画面中に小さなスクロールビューがあり、外側でスワイプジェスチャーをした場合にもスクロールをさせたい場合、iOS でも Android でも外側のジェスチャーをスクロールビューに送る形で実現できます。

ただし構造的に、iOS の方が圧倒的に簡単に実装できます。また、iOS の場合、UICollectionViewUIScrollView を継承しているため、そちらを採用しても完全に同じコードを使える安心感もあります。Android の場合、ここでは ViewPager を用いましたが、ScrollViewRecyclerView を用いた場合は、ViewPagerfakeDrag() メソッドが使えず、その部分で少し違うコードを書かないといけないでしょう。

Android をディスるつもりは全くないんですが、iOS におけるUIScrollView は画面コンポーネントの中核として、とてもよくデザインされているなあ、という印象を強くしました。(いや、お前の Android の理解が乏しいだけだ、というご指摘があれば是非ご教授ください)