Android では、スクロールは通常、ScrollView
クラスを使用して実現します。コンテナの境界を超えて拡張される可能性のある標準レイアウトは、フレームワークで管理されるスクロール可能なビューを提供するために、ScrollView
にネストする必要があります。カスタム スクローラーの実装は、特別なシナリオでのみ必要です。このドキュメントでは、スクローラーを使用してタッチ操作に応じてスクロール効果を表示する方法について説明します。
アプリでは、スクローラー(Scroller
または OverScroller
)を使用して、タッチイベントに応じてスクロール アニメーションを生成するために必要なデータを収集できます。これらは似ていますが、OverScroller
には、パン操作またはフリング操作の後にコンテンツの端に到達したことをユーザーに示すメソッドも含まれています。
- Android 12(API レベル 31)以降では、ドラッグ イベントでは視覚要素がストレッチして元に戻り、フリング イベントではフリングして元に戻ります。
- Android 11(API レベル 30)以前では、端までドラッグまたはフリングした後に、境界に「グロー」効果が表示されます。
このドキュメントの InteractiveChart
サンプルでは、EdgeEffect
クラスを使用してこれらのオーバースクロール エフェクトを表示します。
スクローラーを使用すると、摩擦、速度などの品質など、プラットフォーム標準のスクロール物理を使用して、スクロールを経時的にアニメーション化できます。スクローラー自体は、実際は何も描画しません。スクローラーはスクロール オフセットを経時的にトラックしますが、位置が自動的にビューに適用されるわけではありません。スクロール アニメーションがスムーズに見える速度で新しい座標を取得して適用する必要があります。
スクロールの用語について
スクロールは、文脈によって Android でさまざまな意味を持つことがある単語です。
スクロールは、ビューポート(つまり、見ているコンテンツの「ウィンドウ」)を移動する一般的なプロセスです。スクロールが x 軸と y 軸の両方で行われる場合は、パンといいます。このドキュメントの InteractiveChart
サンプルアプリは、2 種類のスクロール、ドラッグ、フリングを示しています。
- ドラッグ: ユーザーがタッチスクリーン上で指をドラッグしたときに発生するスクロールの一種です。ドラッグは、
GestureDetector.OnGestureListener
のonScroll()
をオーバーライドすることで実装できます。ドラッグの詳細については、ドラッグとスケーリングをご覧ください。 - フリング: ユーザーが指をすばやくドラッグして離したときに発生するスクロールの一種です。ユーザーが指を離した後、通常はビューポートの移動が続けますが、ビューポートの移動が停止するまで減速します。フリングは、
GestureDetector.OnGestureListener
のonFling()
をオーバーライドし、スクローラー オブジェクトを使用して実装できます。 - パン: x 軸と y 軸の両方を同時にスクロールすることをパンといいます。
スクローラー オブジェクトをフリング操作と組み合わせて使用することは一般的ですが、タッチイベントに応じて UI にスクロールを表示する場合、ほとんどすべてのコンテキストで使用できます。たとえば、onTouchEvent()
をオーバーライドしてタッチイベントを直接処理し、これらのタッチイベントに応じてスクロール効果または「ページにスナップする」アニメーションを生成できます。
スクロールの実装が組み込まれているコンポーネント
次の Android コンポーネントには、スクロールとオーバースクロールの動作が組み込みでサポートされています。
GridView
HorizontalScrollView
ListView
NestedScrollView
RecyclerView
ScrollView
ViewPager
ViewPager2
アプリで別のコンポーネント内のスクロールとオーバースクロールをサポートする必要がある場合は、次の手順を完了します。
- タップベースのカスタム スクロールを実装する。
- Android 12 以降を搭載したデバイスをサポートするには、ストレッチ オーバースクロール効果を実装します。
カスタム タッチベースのスクロールの実装を作成する
このセクションでは、スクロールとオーバースクロールの組み込みサポートが含まれていないコンポーネントをアプリで使用している場合に、独自のスクロールを作成する方法について説明します。
次のスニペットは InteractiveChart
サンプルから取得したものです。GestureDetector
を使用し、GestureDetector.SimpleOnGestureListener
メソッドの onFling()
をオーバーライドします。OverScroller
を使用し、フリング操作をトラックします。ユーザーがフリング操作を実行した後にコンテンツの端に到達すると、コンテナはユーザーがコンテンツの最後まで到達したことを示します。表示内容は、デバイスで実行されている Android のバージョンによって異なります。
- Android 12 以降では、視覚要素がストレッチして元に戻ります。
- Android 11 以前では、視覚要素にグロー効果が表示されます。
次のスニペットの最初の部分は、onFling()
の実装を示しています。
Kotlin
// Viewport extremes. See currentViewport for a discussion of the viewport. private val AXIS_X_MIN = -1f private val AXIS_X_MAX = 1f private val AXIS_Y_MIN = -1f private val AXIS_Y_MAX = 1f // The current viewport. This rectangle represents the visible chart // domain and range. The viewport is the part of the app that the // user manipulates via touch gestures. private val currentViewport = RectF(AXIS_X_MIN, AXIS_Y_MIN, AXIS_X_MAX, AXIS_Y_MAX) // The current destination rectangle—in pixel coordinates—into which // the chart data must be drawn. private lateinit var contentRect: Rect private lateinit var scroller: OverScroller private lateinit var scrollerStartViewport: RectF ... private val gestureListener = object : GestureDetector.SimpleOnGestureListener() { override fun onDown(e: MotionEvent): Boolean { // Initiates the decay phase of any active edge effects. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { releaseEdgeEffects() } scrollerStartViewport.set(currentViewport) // Aborts any active scroll animations and invalidates. scroller.forceFinished(true) ViewCompat.postInvalidateOnAnimation(this@InteractiveLineGraphView) return true } ... override fun onFling( e1: MotionEvent, e2: MotionEvent, velocityX: Float, velocityY: Float ): Boolean { fling((-velocityX).toInt(), (-velocityY).toInt()) return true } } private fun fling(velocityX: Int, velocityY: Int) { // Initiates the decay phase of any active edge effects. // On Android 12 and later, the edge effect (stretch) must // continue. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { releaseEdgeEffects() } // Flings use math in pixels, as opposed to math based on the viewport. val surfaceSize: Point = computeScrollSurfaceSize() val (startX: Int, startY: Int) = scrollerStartViewport.run { set(currentViewport) (surfaceSize.x * (left - AXIS_X_MIN) / (AXIS_X_MAX - AXIS_X_MIN)).toInt() to (surfaceSize.y * (AXIS_Y_MAX - bottom) / (AXIS_Y_MAX - AXIS_Y_MIN)).toInt() } // Before flinging, stops the current animation. scroller.forceFinished(true) // Begins the animation. scroller.fling( // Current scroll position. startX, startY, velocityX, velocityY, /* * Minimum and maximum scroll positions. The minimum scroll * position is generally 0 and the maximum scroll position * is generally the content size less the screen size. So if the * content width is 1000 pixels and the screen width is 200 * pixels, the maximum scroll offset is 800 pixels. */ 0, surfaceSize.x - contentRect.width(), 0, surfaceSize.y - contentRect.height(), // The edges of the content. This comes into play when using // the EdgeEffect class to draw "glow" overlays. contentRect.width() / 2, contentRect.height() / 2 ) // Invalidates to trigger computeScroll(). ViewCompat.postInvalidateOnAnimation(this) }
Java
// Viewport extremes. See currentViewport for a discussion of the viewport. private static final float AXIS_X_MIN = -1f; private static final float AXIS_X_MAX = 1f; private static final float AXIS_Y_MIN = -1f; private static final float AXIS_Y_MAX = 1f; // The current viewport. This rectangle represents the visible chart // domain and range. The viewport is the part of the app that the // user manipulates via touch gestures. private RectF currentViewport = new RectF(AXIS_X_MIN, AXIS_Y_MIN, AXIS_X_MAX, AXIS_Y_MAX); // The current destination rectangle—in pixel coordinates—into which // the chart data must be drawn. private final Rect contentRect = new Rect(); private final OverScroller scroller; private final RectF scrollerStartViewport = new RectF(); // Used only for zooms and flings. ... private final GestureDetector.SimpleOnGestureListener gestureListener = new GestureDetector.SimpleOnGestureListener() { @Override public boolean onDown(MotionEvent e) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { releaseEdgeEffects(); } scrollerStartViewport.set(currentViewport); scroller.forceFinished(true); ViewCompat.postInvalidateOnAnimation(InteractiveLineGraphView.this); return true; } ... @Override public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { fling((int) -velocityX, (int) -velocityY); return true; } }; private void fling(int velocityX, int velocityY) { // Initiates the decay phase of any active edge effects. // On Android 12 and later, the edge effect (stretch) must // continue. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { releaseEdgeEffects(); } // Flings use math in pixels, as opposed to math based on the viewport. Point surfaceSize = computeScrollSurfaceSize(); scrollerStartViewport.set(currentViewport); int startX = (int) (surfaceSize.x * (scrollerStartViewport.left - AXIS_X_MIN) / ( AXIS_X_MAX - AXIS_X_MIN)); int startY = (int) (surfaceSize.y * (AXIS_Y_MAX - scrollerStartViewport.bottom) / ( AXIS_Y_MAX - AXIS_Y_MIN)); // Before flinging, stops the current animation. scroller.forceFinished(true); // Begins the animation. scroller.fling( // Current scroll position. startX, startY, velocityX, velocityY, /* * Minimum and maximum scroll positions. The minimum scroll * position is generally 0 and the maximum scroll position * is generally the content size less the screen size. So if the * content width is 1000 pixels and the screen width is 200 * pixels, the maximum scroll offset is 800 pixels. */ 0, surfaceSize.x - contentRect.width(), 0, surfaceSize.y - contentRect.height(), // The edges of the content. This comes into play when using // the EdgeEffect class to draw "glow" overlays. contentRect.width() / 2, contentRect.height() / 2); // Invalidates to trigger computeScroll(). ViewCompat.postInvalidateOnAnimation(this); }
onFling()
が postInvalidateOnAnimation()
を呼び出すと、computeScroll()
がトリガーされて x と y の値が更新されます。これは通常、上の例のように、ビューの子がスクローラー オブジェクトを使用してスクロールをアニメーション化しているときに行われます。
ほとんどのビューでは、スクローラー オブジェクトの x と y の位置が scrollTo()
に直接渡されます。次の computeScroll()
実装は異なるアプローチを取ります。すなわち、computeScrollOffset()
を呼び出して x と y の現在地を取得します。オーバースクロールの「グロー」エッジ効果を表示するための条件(ディスプレイがズームインされている、x または y が範囲外である、アプリにオーバースクロールが表示されていない)が満たされると、コードはオーバースクロールのグロー効果を設定し、postInvalidateOnAnimation()
を呼び出してビューの無効化をトリガーします。
Kotlin
// Edge effect/overscroll tracking objects. private lateinit var edgeEffectTop: EdgeEffect private lateinit var edgeEffectBottom: EdgeEffect private lateinit var edgeEffectLeft: EdgeEffect private lateinit var edgeEffectRight: EdgeEffect private var edgeEffectTopActive: Boolean = false private var edgeEffectBottomActive: Boolean = false private var edgeEffectLeftActive: Boolean = false private var edgeEffectRightActive: Boolean = false override fun computeScroll() { super.computeScroll() var needsInvalidate = false // The scroller isn't finished, meaning a fling or // programmatic pan operation is active. if (scroller.computeScrollOffset()) { val surfaceSize: Point = computeScrollSurfaceSize() val currX: Int = scroller.currX val currY: Int = scroller.currY val (canScrollX: Boolean, canScrollY: Boolean) = currentViewport.run { (left > AXIS_X_MIN || right < AXIS_X_MAX) to (top > AXIS_Y_MIN || bottom < AXIS_Y_MAX) } /* * If you are zoomed in, currX or currY is * outside of bounds, and you aren't already * showing overscroll, then render the overscroll * glow edge effect. */ if (canScrollX && currX < 0 && edgeEffectLeft.isFinished && !edgeEffectLeftActive) { edgeEffectLeft.onAbsorb(scroller.currVelocity.toInt()) edgeEffectLeftActive = true needsInvalidate = true } else if (canScrollX && currX > surfaceSize.x - contentRect.width() && edgeEffectRight.isFinished && !edgeEffectRightActive) { edgeEffectRight.onAbsorb(scroller.currVelocity.toInt()) edgeEffectRightActive = true needsInvalidate = true } if (canScrollY && currY < 0 && edgeEffectTop.isFinished && !edgeEffectTopActive) { edgeEffectTop.onAbsorb(scroller.currVelocity.toInt()) edgeEffectTopActive = true needsInvalidate = true } else if (canScrollY && currY > surfaceSize.y - contentRect.height() && edgeEffectBottom.isFinished && !edgeEffectBottomActive) { edgeEffectBottom.onAbsorb(scroller.currVelocity.toInt()) edgeEffectBottomActive = true needsInvalidate = true } ... } }
Java
// Edge effect/overscroll tracking objects. private EdgeEffectCompat edgeEffectTop; private EdgeEffectCompat edgeEffectBottom; private EdgeEffectCompat edgeEffectLeft; private EdgeEffectCompat edgeEffectRight; private boolean edgeEffectTopActive; private boolean edgeEffectBottomActive; private boolean edgeEffectLeftActive; private boolean edgeEffectRightActive; @Override public void computeScroll() { super.computeScroll(); boolean needsInvalidate = false; // The scroller isn't finished, meaning a fling or // programmatic pan operation is active. if (scroller.computeScrollOffset()) { Point surfaceSize = computeScrollSurfaceSize(); int currX = scroller.getCurrX(); int currY = scroller.getCurrY(); boolean canScrollX = (currentViewport.left > AXIS_X_MIN || currentViewport.right < AXIS_X_MAX); boolean canScrollY = (currentViewport.top > AXIS_Y_MIN || currentViewport.bottom < AXIS_Y_MAX); /* * If you are zoomed in, currX or currY is * outside of bounds, and you aren't already * showing overscroll, then render the overscroll * glow edge effect. */ if (canScrollX && currX < 0 && edgeEffectLeft.isFinished() && !edgeEffectLeftActive) { edgeEffectLeft.onAbsorb((int)mScroller.getCurrVelocity()); edgeEffectLeftActive = true; needsInvalidate = true; } else if (canScrollX && currX > (surfaceSize.x - contentRect.width()) && edgeEffectRight.isFinished() && !edgeEffectRightActive) { edgeEffectRight.onAbsorb((int)mScroller.getCurrVelocity()); edgeEffectRightActive = true; needsInvalidate = true; } if (canScrollY && currY < 0 && edgeEffectTop.isFinished() && !edgeEffectTopActive) { edgeEffectRight.onAbsorb((int)mScroller.getCurrVelocity()); edgeEffectTopActive = true; needsInvalidate = true; } else if (canScrollY && currY > (surfaceSize.y - contentRect.height()) && edgeEffectBottom.isFinished() && !edgeEffectBottomActive) { edgeEffectRight.onAbsorb((int)mScroller.getCurrVelocity()); edgeEffectBottomActive = true; needsInvalidate = true; } ... }
実際のズームを行うコードのセクションを次に示します。
Kotlin
lateinit var zoomer: Zoomer val zoomFocalPoint = PointF() ... // If a zoom is in progress—either programmatically // or through double touch—this performs the zoom. if (zoomer.computeZoom()) { val newWidth: Float = (1f - zoomer.currZoom) * scrollerStartViewport.width() val newHeight: Float = (1f - zoomer.currZoom) * scrollerStartViewport.height() val pointWithinViewportX: Float = (zoomFocalPoint.x - scrollerStartViewport.left) / scrollerStartViewport.width() val pointWithinViewportY: Float = (zoomFocalPoint.y - scrollerStartViewport.top) / scrollerStartViewport.height() currentViewport.set( zoomFocalPoint.x - newWidth * pointWithinViewportX, zoomFocalPoint.y - newHeight * pointWithinViewportY, zoomFocalPoint.x + newWidth * (1 - pointWithinViewportX), zoomFocalPoint.y + newHeight * (1 - pointWithinViewportY) ) constrainViewport() needsInvalidate = true } if (needsInvalidate) { ViewCompat.postInvalidateOnAnimation(this) }
Java
// Custom object that is functionally similar to Scroller. Zoomer zoomer; private PointF zoomFocalPoint = new PointF(); ... // If a zoom is in progress—either programmatically // or through double touch—this performs the zoom. if (zoomer.computeZoom()) { float newWidth = (1f - zoomer.getCurrZoom()) * scrollerStartViewport.width(); float newHeight = (1f - zoomer.getCurrZoom()) * scrollerStartViewport.height(); float pointWithinViewportX = (zoomFocalPoint.x - scrollerStartViewport.left) / scrollerStartViewport.width(); float pointWithinViewportY = (zoomFocalPoint.y - scrollerStartViewport.top) / scrollerStartViewport.height(); currentViewport.set( zoomFocalPoint.x - newWidth * pointWithinViewportX, zoomFocalPoint.y - newHeight * pointWithinViewportY, zoomFocalPoint.x + newWidth * (1 - pointWithinViewportX), zoomFocalPoint.y + newHeight * (1 - pointWithinViewportY)); constrainViewport(); needsInvalidate = true; } if (needsInvalidate) { ViewCompat.postInvalidateOnAnimation(this); }
これが、上のスニペットで呼び出される computeScrollSurfaceSize()
メソッドです。現在のスクロール可能な表面サイズをピクセル単位で計算します。たとえば、グラフ領域全体が表示されている場合、これは mContentRect
の現在のサイズです。グラフを両方向に 200% ズームすると、返されるサイズは水平と垂直の 2 倍になります。
Kotlin
private fun computeScrollSurfaceSize(): Point { return Point( (contentRect.width() * (AXIS_X_MAX - AXIS_X_MIN) / currentViewport.width()).toInt(), (contentRect.height() * (AXIS_Y_MAX - AXIS_Y_MIN) / currentViewport.height()).toInt() ) }
Java
private Point computeScrollSurfaceSize() { return new Point( (int) (contentRect.width() * (AXIS_X_MAX - AXIS_X_MIN) / currentViewport.width()), (int) (contentRect.height() * (AXIS_Y_MAX - AXIS_Y_MIN) / currentViewport.height())); }
スクローラーの別の使用例については、ViewPager
クラスのソースコードをご覧ください。フリングに応じてスクロールし、スクロールを使用して「ページにスナップする」アニメーションを実装します。
ストレッチ オーバースクロール効果を実装する
Android 12 以降では、EdgeEffect
に、オーバースクロールのストレッチ効果を実装するための次の API が追加されています。
getDistance()
onPullDistance()
ストレッチ オーバースクロールを使って最適なユーザー エクスペリエンスを実現するには、次の手順を行います。
- ユーザーがコンテンツをタップしたときにストレッチ アニメーションが有効になっている場合は、そのタップを「キャッチ」として登録します。ユーザーはアニメーションを停止し、ストレッチの操作を再開します。
- ユーザーがストレッチの反対方向に指を動かした場合、ストレッチが完全に終わるまで解放し、その後スクロールを開始します。
- ユーザーがストレッチ中にフリングした場合、
EdgeEffect
をフリングしてストレッチ効果を強化します。
アニメーションをキャッチする
ユーザーがアクティブなストレッチ アニメーションをキャッチすると、EdgeEffect.getDistance()
は 0
を返します。この条件は、ストレッチをタップ モーションで操作する必要があることを示します。次のコード スニペットに示すように、ほとんどのコンテナでは onInterceptTouchEvent()
でキャッチが検出されます。
Kotlin
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { ... when (action and MotionEvent.ACTION_MASK) { MotionEvent.ACTION_DOWN -> ... isBeingDragged = EdgeEffectCompat.getDistance(edgeEffectBottom) > 0f || EdgeEffectCompat.getDistance(edgeEffectTop) > 0f ... } return isBeingDragged }
Java
@Override public boolean onInterceptTouchEvent(MotionEvent ev) { ... switch (action & MotionEvent.ACTION_MASK) { case MotionEvent.ACTION_DOWN: ... isBeingDragged = EdgeEffectCompat.getDistance(edgeEffectBottom) > 0 || EdgeEffectCompat.getDistance(edgeEffectTop) > 0; ... } }
上記の例では、mIsBeingDragged
が true
の場合、onInterceptTouchEvent()
は true
を返すため、子がイベントを消費する前にイベントを消費できます。
オーバースクロール効果を解放する
ストレッチ効果がスクロールするコンテンツに適用されないように、スクロールする前にストレッチ効果を解放することが重要です。次のコードサンプルでは、このベスト プラクティスを適用しています。
Kotlin
override fun onTouchEvent(ev: MotionEvent): Boolean { val activePointerIndex = ev.actionIndex when (ev.getActionMasked()) { MotionEvent.ACTION_MOVE -> val x = ev.getX(activePointerIndex) val y = ev.getY(activePointerIndex) var deltaY = y - lastMotionY val pullDistance = deltaY / height val displacement = x / width if (deltaY < 0f && EdgeEffectCompat.getDistance(edgeEffectTop) > 0f) { deltaY -= height * EdgeEffectCompat.onPullDistance(edgeEffectTop, pullDistance, displacement); } if (deltaY > 0f && EdgeEffectCompat.getDistance(edgeEffectBottom) > 0f) { deltaY += height * EdgeEffectCompat.onPullDistance(edgeEffectBottom, -pullDistance, 1 - displacement); } ... }
Java
@Override public boolean onTouchEvent(MotionEvent ev) { final int actionMasked = ev.getActionMasked(); switch (actionMasked) { case MotionEvent.ACTION_MOVE: final float x = ev.getX(activePointerIndex); final float y = ev.getY(activePointerIndex); float deltaY = y - lastMotionY; float pullDistance = deltaY / getHeight(); float displacement = x / getWidth(); if (deltaY < 0 && EdgeEffectCompat.getDistance(edgeEffectTop) > 0) { deltaY -= getHeight() * EdgeEffectCompat.onPullDistance(edgeEffectTop, pullDistance, displacement); } if (deltaY > 0 && EdgeEffectCompat.getDistance(edgeEffectBottom) > 0) { deltaY += getHeight() * EdgeEffectCompat.onPullDistance(edgeEffectBottom, -pullDistance, 1 - displacement); } ...
ユーザーがドラッグしている場合は、タッチイベントをネスト スクロール コンテナに渡す前や、スクロールをドラッグする前に、EdgeEffect
の pull 距離を消費します。上記のコードサンプルでは、getDistance()
は、エッジ効果が表示されていて、モーションを使用して解放できる場合に正の値を返します。タッチイベントがストレッチを解放すると、そのイベントはまず EdgeEffect
によって消費されるため、ネスト スクロールなどの他の効果が表示される前に完全に解放されます。getDistance()
を使用すると、現在の効果を解放するために必要な pull 距離を確認できます。
onPull()
とは異なり、onPullDistance()
は渡されたデルタの消費量を返します。Android 12 以降では、getDistance()
が 0
のときに onPull()
または onPullDistance()
に負の deltaDistance
値を渡した場合、ストレッチ効果は変化しません。Android 11 以前では、onPull()
で合計距離に負の値を指定すると、グロー効果が表示されます。
オーバースクロールを無効にする
オーバースクロールは、レイアウト ファイルで、またはプログラムによって無効にできます。
レイアウト ファイルでオプトアウトするには、次の例に示すように android:overScrollMode
を設定します。
<MyCustomView android:overScrollMode="never"> ... </MyCustomView>
プログラムでオプトアウトするには、次のようなコードを使用します。
Kotlin
customView.overScrollMode = View.OVER_SCROLL_NEVER
Java
customView.setOverScrollMode(View.OVER_SCROLL_NEVER);
参考情報
以下の関連リソースもご覧ください。