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

# 動機

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

![Japanese.png](https://qiita-image-store.s3.amazonaws.com/0/108030/a7a57b11-ab66-73b3-0bf3-08edde1c301c.png)


# Android の場合

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

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

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

## `GestureDetector` を初期化

```kotlin
    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 イベントをキャッチ

```kotlin
    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` に設定してやるだけです。

```kotlin
    view.setOnTouchListener(touchListner)
```

## 結果

![final_android.gif](https://qiita-image-store.s3.amazonaws.com/0/108030/c42e25ab-cdec-fd84-31ed-429419454768.gif)

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


# iOS の場合

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

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

```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](https://qiita-image-store.s3.amazonaws.com/0/108030/6f01dab7-d650-c31c-6ff9-64001c2dac77.gif)

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

ちなみに、この方法は [WWDC 2014 Session 235](https://developer.apple.com/videos/play/wwdc2014/235/) で紹介されています。

# まとめ

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

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

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