動機
あまり頻度のあるユースケースではないかもしれませんが、時折、下図のように画面よりもかなり小さいスクロールビューを見せたい時があります(個人的に何度かそういうデザインを実装したことがありました)。しかしスワイプやパンと言った動きは横方向に大きな動きなので、スクロールのジェスチャー自体は画面全体から取りたかったりします。
Android の場合
GestureDetector
の onScroll(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)
結果
スクロールビューは青い額の中ですが、額の外をスクロールしてもちゃんと額の中がスクロールします。残念ながら 100% というわけではなく、キリンのところで一度空振りしています。
iOS の場合
この記事は、総じてこれが書きたかった感じですが ^^; iOS はこの挙動を非常に簡単に書けます。UIScrollView
のスクロールは、それに乗っている UIPanGestureRecognizer
と UISwipeGestureRecognizer
によって管理されています。なので、これらの 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)
}
}
}
結果
構造的に、外側のジェスチャーをそのまま中に伝えられるので、挙動自体も iOS の方がスムーズです。
ちなみに、この方法は WWDC 2014 Session 235 で紹介されています。
まとめ
画面中に小さなスクロールビューがあり、外側でスワイプジェスチャーをした場合にもスクロールをさせたい場合、iOS でも Android でも外側のジェスチャーをスクロールビューに送る形で実現できます。
ただし構造的に、iOS の方が圧倒的に簡単に実装できます。また、iOS の場合、UICollectionView
も UIScrollView
を継承しているため、そちらを採用しても完全に同じコードを使える安心感もあります。Android の場合、ここでは ViewPager
を用いましたが、ScrollView
や RecyclerView
を用いた場合は、ViewPager
の fakeDrag()
メソッドが使えず、その部分で少し違うコードを書かないといけないでしょう。
Android をディスるつもりは全くないんですが、iOS におけるUIScrollView
は画面コンポーネントの中核として、とてもよくデザインされているなあ、という印象を強くしました。(いや、お前の Android の理解が乏しいだけだ、というご指摘があれば是非ご教授ください)