Androidで縦横斜めを自由スクロール!KotlinでカスタムViewGroup「VHScrollView」を実装する
はじめに
Androidアプリを開発していると、「縦方向だけでなく横方向にも、しかも斜め方向にも自由にスクロールできるViewが欲しい」という場面に出くわすことがあります。地図や大きなスプレッドシート、画像ビューアなどがその典型です。標準の ScrollView は縦方向のみ、HorizontalScrollView は横方向のみと、それぞれ一方向にしか対応しておらず、組み合わせ次第では相互のタッチイベントが干渉しあい、思うような動作が得られないことも少なくありません。
本記事では、Kotlinで FrameLayout を継承して構築した VHScrollView(Vertical-Horizontal ScrollView)クラスの設計思想と実装の全コードを解説します。GestureDetectorCompat と OverScroller を組み合わせたAndroid標準コンポーネント活用の実践的な例として、カスタムViewのアーキテクチャを理解したい方にも最適な内容です。
基礎知識・概要
GestureDetectorCompat にジェスチャー解析を委譲しつつ、OverScroller で慣性スクロールの物理計算をOSに任せる「責務の分離」設計が、少ないコード量でSwipe/Fling両対応を可能にしています。
Androidのカスタムスクロールビューを実装する際に欠かせない2つのクラスを整理します。
GestureDetectorCompat: MotionEventの生ストリームを受け取り、「タッチダウン」「ドラッグ(onScroll)」「フリック(onFling)」などの高レベルなジェスチャーイベントに変換してくれるユーティリティクラスです。自力でタッチ座標の差分を計算したり、感度を実装する必要がなく、Androidの標準的な判定ルールを再利用できます。
OverScroller: フリング(弾き飛ばし)時の速度減衰・慣性アニメーションの座標計算を担当するクラスです。毎フレームの描画タイミングで computeScrollOffset() を呼ぶことで現在の理想スクロール位置を取得でき、端末のリフレッシュレートに合わせた滑らかな慣性アニメーションを、OSが計算してくれます。このふたつを組み合わせることで、スクロールロジックの本質(どこまでスクロールするか・何が端か)だけを書けばよくなります。
主要機能と詳細
2つのGestureDetectorの役割分担
VHScrollView の設計上の面白い点は、GestureDetectorCompat を用途別に2つ保持していることです。
- interceptDetector:
onInterceptTouchEvent専用。子Viewにイベントが届く前に「これはスクロール動作か?」を検知し、isScrollingフラグを立てるためだけに使われます。 - gestureDetector:
onTouchEvent専用。インターセプト後に配送されるイベントを受け取り、実際のスクロール移動(onScroll)およびフリング開始(onFling)処理を行います。
この分離により、子Viewが独自のタッチ処理(クリック等)を持っていても、サコンフリクトを最小限に抑えた自然なUXを保てます。
onMeasureとUNSPECIFIEDの意味
スクロールビューの根幹となる重要な処理が onMeasure 内の子ビューへの MeasureSpec.UNSPECIFIED 指定です。通常、親Viewは子Viewに「最大でこのサイズまでの表示を許可する」という制約(MeasureSpec)を渡します。しかしスクロールビューがそれをやってしまうと、子ビューが画面サイズに収まるよう自ら縮小してしまい、スクロールする意味がなくなります。UNSPECIFIED(制約なし)を渡すことで、「好きなサイズになっていい」と子ビューに伝え、その結果えられた子ビューの measuredWidth/Height と自身の表示領域との差が、スクロール可能な最大移動量(maxX / maxY)になります。
doScrollByとdoFlingの役割
doScrollBy はドラッグ中の指の移動量(distanceX/Y)をそのままスクロール位置に反映する最もシンプルな関数です。ここで coerceIn(0, maxX) によるクランプ処理(範囲固定)を行い、子Viewの端を超えてスクロールされないようにしています。
一方 doFling では、指を弾いた瞬間の速度(velocityX/Y)を OverScroller.fling() に渡し、以後の座標計算をOSに委ねます。GestureDetectorの返す速度は「指の移動方向」なので、スクロール(コンテンツの移動方向は逆)に合わせてマイナスに反転しているのがポイントです。
実装・実践ガイド:コードの詳細と使い方
computeScrollによるアニメーションループ
Androidのレンダリングループと OverScroller を繋ぐ「のり」が computeScroll() のオーバーライドです。フレームごとのVSYNC信号に合わせて呼ばれるこのメソッドで、scroller.computeScrollOffset() を呼ぶと OverScroller が減速計算を行い、現在フレームでの理想座標(currX/Y)を返してくれます。それを scrollTo() で適用し、最後に ViewCompat.postInvalidateOnAnimation(this) で次フレームの描画を再予約する、という無限ループでアニメーションが継続します。アニメーション終了時は computeScrollOffset() が false を返すためループが自然に終了します。
XMLレイアウトへの組み込み方法
使い方はシンプルです。スクロールさせたい大きなビューを、VHScrollView の直下の子(1つだけ)として配置するだけです。
<com.example.vhscrollview.VHScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- スクロールさせたい巨大なコンテンツView -->
<ImageView
android:layout_width="3000dp"
android:layout_height="2000dp"
android:src="@drawable/big_map" />
</com.example.vhscrollview.VHScrollView>
これだけで、ユーザーが指を動かした方向(縦・横・斜め)に合わせた自然なスクロールと、指を弾いた際のフリング慣性アニメーションが動作します。
依存関係の追加(build.gradle)
本実装は AndroidX Core の ViewCompat と GestureDetectorCompat を利用します。build.gradle(またはbuild.gradle.kts)の dependencies ブロックに以下が含まれていることを確認してください。
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
}
よくある課題と解決策
子Viewが RecyclerView など独自スクロールを持つ場合、タッチイベントの競合に注意が必要です。
子ViewにクリックやRecyclerViewが共存する場合
onInterceptTouchEvent でスクロール意図を検知した後にインターセプトする設計により、短いタップは子Viewに届きます。ただし、子View自体がスクロール可能な場合(RecyclerView など)は、ネストスクロールの競合が発生します。その場合は NestedScrollingParent3 インターフェースを追加実装し、requestDisallowInterceptTouchEvent の仕組みを組み合わせた制御が必要です。
端の「バウンス」エフェクトを追加する
現状の実装はコンテンツ端でピタッと止まる設計です(クランプ処理)。iOS風の「端を超えて少し伸びて戻る」バウンスエフェクトを追加したい場合は、OverScroller.fling() に オーバースクロールの許容量を渡す overX/overY パラメーターを設定し、EdgeEffect クラスを組み合わせることで実現できます。
アクセシビリティ(a11y)への対応
カスタムスクロールViewはスクリーンリーダー(TalkBack)からのスクロール操作が考慮されていません。ViewCompat.setAccessibilityDelegate を使ってスワイプアクションを定義するか、onInitializeAccessibilityNodeInfo をオーバーライドしてスクロール可能であることをアクセシビリティツリーに通知することが求められます。
まとめ
本記事では、FrameLayout を継承したカスタムViewGroup「VHScrollView」のKotlin実装を解説しました。設計のポイントを振り返ります。
- インターセプト用と操作用で GestureDetectorを責務分離 し、子Viewとのタッチ競合を最小化。
MeasureSpec.UNSPECIFIEDで子Viewを 「好きなサイズ」で計測 し、スクロール可能領域を正しく算出。OverScrollerに物理演算を委譲し、computeScroll()ループで OSネイティブ品質の慣性スクロール を実現。
この設計パターンは縦横スクロールに限らず、カスタムジェスチャー操作全般に応用が利きます。地図ビューアや大型レイアウトの閲覧UI、図面エディタのベースコンポーネントとしてぜひ活用してみてください。
完全なソースコード(VHScrollView.kt)
以下に VHScrollView.kt の完全なソースコードを掲載します。パッケージ名は各自のプロジェクトに合わせて変更してください。
package com.example.vhscrollview
import android.content.Context
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.widget.FrameLayout
import android.widget.OverScroller
import androidx.core.view.GestureDetectorCompat
import androidx.core.view.ViewCompat
/**
* 縦・横・斜めに自由にスクロールできるカスタム ViewGroup。
*
* 設計方針:
* - [FrameLayout] を継承し、直下の子ビューを1つだけ持つ([android.widget.ScrollView] と同じ制約)。
* - [GestureDetectorCompat] に `MotionEvent` の解析を委譲し、
* `onScroll`(ドラッグ移動量)と `onFling`(弾き初速)コールバックだけを処理する。
* - [OverScroller] に X/Y の速度と限界値を渡し、OS 標準の慣性スクロール計算を利用する。
*/
class VHScrollView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private val scroller = OverScroller(context)
/**
* onInterceptTouchEvent でドラッグを検知するための専用 GestureDetector。
* onScroll が呼ばれた = スクロール意図あり → インターセプトフラグを立てる。
*/
private var isScrolling = false
private val interceptDetector = GestureDetectorCompat(
context,
object : GestureDetector.SimpleOnGestureListener() {
override fun onDown(e: MotionEvent): Boolean = true
override fun onScroll(
e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float
): Boolean {
isScrolling = true
return true
}
}
)
private val gestureDetector = GestureDetectorCompat(
context,
object : GestureDetector.SimpleOnGestureListener() {
/**
* タッチダウン時にフリングアニメーションを止める。
* `onScroll` / `onFling` を受け取るには true を返す必要がある。
*/
override fun onDown(e: MotionEvent): Boolean {
if (!scroller.isFinished) {
scroller.abortAnimation()
}
return true
}
/**
* ドラッグ中に呼ばれる。指の移動量(distanceX, distanceY)をそのままスクロールに反映する。
*/
override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
doScrollBy(distanceX.toInt(), distanceY.toInt())
return true
}
/**
* 指を弾いた時に呼ばれる。フリングの初速を [OverScroller] に渡し、慣性計算を開始する。
* GestureDetector が返す velocityX/Y は「指の速度」なので、スクロール方向は反転する。
*/
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent,
velocityX: Float,
velocityY: Float
): Boolean {
doFling(-velocityX.toInt(), -velocityY.toInt())
return true
}
}
)
// -------------------------------------------------------------------------
// Measure
// -------------------------------------------------------------------------
/**
* 子ビューを [MeasureSpec.UNSPECIFIED] で計測する。
* これにより、親の画面サイズを超えた大きな子ビューも本来のサイズで計測される。
*/
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
if (childCount == 0) return
val child = getChildAt(0)
val unspecified = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
child.measure(unspecified, unspecified)
}
// -------------------------------------------------------------------------
// Intercept
// -------------------------------------------------------------------------
/**
* 子Viewよりも先にタッチイベントを確認し、ドラッグと判断したらインターセプト(横取り)する。
* インターセプト後は以降のイベントが [onTouchEvent] に直接届くようになる。
*/
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
// ACTION_CANCEL / ACTION_UP でフラグをリセット
if (ev.action == MotionEvent.ACTION_CANCEL || ev.action == MotionEvent.ACTION_UP) {
isScrolling = false
}
interceptDetector.onTouchEvent(ev)
return isScrolling
}
// -------------------------------------------------------------------------
// Touch
// -------------------------------------------------------------------------
override fun onTouchEvent(event: MotionEvent): Boolean {
return gestureDetector.onTouchEvent(event) || super.onTouchEvent(event)
}
// -------------------------------------------------------------------------
// Scroll
// -------------------------------------------------------------------------
/**
* 現在位置に [dx], [dy] を加算してスクロール位置を更新する。
* 0 〜 maxScroll の範囲にクランプすることで画面端を超えないようにする。
*/
private fun doScrollBy(dx: Int, dy: Int) {
val child = getChildAt(0) ?: return
val maxX = (child.measuredWidth - width).coerceAtLeast(0)
val maxY = (child.measuredHeight - height).coerceAtLeast(0)
scrollTo(
(scrollX + dx).coerceIn(0, maxX),
(scrollY + dy).coerceIn(0, maxY)
)
}
/**
* [OverScroller.fling] に X/Y の初速と限界値を設定し、慣性スクロールを開始する。
* 実際の座標反映は [computeScroll] で毎フレーム行う。
*/
private fun doFling(velocityX: Int, velocityY: Int) {
val child = getChildAt(0) ?: return
val maxX = (child.measuredWidth - width).coerceAtLeast(0)
val maxY = (child.measuredHeight - height).coerceAtLeast(0)
scroller.fling(
scrollX, scrollY,
velocityX, velocityY,
0, maxX,
0, maxY
)
ViewCompat.postInvalidateOnAnimation(this)
}
// -------------------------------------------------------------------------
// Animation loop
// -------------------------------------------------------------------------
/**
* [ViewCompat.postInvalidateOnAnimation] によるフレームごとに呼ばれる。
* [OverScroller] が計算した現在座標を取り出し、[scrollTo] で位置を確定する。
* アニメーションが続く間は次フレームの描画を再予約する。
*/
override fun computeScroll() {
super.computeScroll()
if (!scroller.computeScrollOffset()) return
val child = getChildAt(0)
val maxX = child?.let { (it.measuredWidth - width).coerceAtLeast(0) } ?: 0
val maxY = child?.let { (it.measuredHeight - height).coerceAtLeast(0) } ?: 0
scrollTo(
scroller.currX.coerceIn(0, maxX),
scroller.currY.coerceIn(0, maxY)
)
ViewCompat.postInvalidateOnAnimation(this)
}
}