ドラッグしてサイズ変更

このレッスンでは、onTouchEvent() を使用してタップイベントをインターセプトし、タップ操作を使用して画面上のオブジェクトをドラッグし拡大縮小する方法について説明します。

以下の関連リソースもご覧ください。

オブジェクトをドラッグする

Android 3.0 以降を対象としている場合、ドラッグ&ドロップで説明しているように、View.OnDragListener で組み込みのドラッグ&ドロップ イベント リスナーを使用できます。

タップ操作の一般的なオペレーションは、画面上でのオブジェクトのドラッグに使用することです。次のスニペットで、ユーザーは画面上の画像をドラッグできるようになります。次の点にご注意ください。

  • ドラッグ(またはスクロール)操作では、画面上に別の指がさらに置かれた場合でも、アプリは元のポインタ(指)をトラッキングし続ける必要があります。たとえば、画像をドラッグしながら、ユーザーがタッチ スクリーンに 2 本目の指を置き、最初の指を離すとします。アプリが個々のポインタをトラッキングしているだけの場合は、2 番目のポインタをデフォルトと見なし、画像をその場所に移動します。
  • これを防ぐには、アプリで元のポインタと後続のポインタを区別する必要があります。そのためには、マルチタップ操作の処理で説明している ACTION_POINTER_DOWN イベントと ACTION_POINTER_UP イベントをトラッキングします。 ACTION_POINTER_DOWNACTION_POINTER_UP は、2 番目のポインタがダウンまたはアップするたびに onTouchEvent() コールバックに渡されます。
  • ACTION_POINTER_UP の場合、この例ではこのインデックスを抽出し、アクティブなポインタ ID が画面から離れたポインタを参照していないことを確認します。そうである場合、アプリはアクティブにする別のポインタを選択し、現在の X 位置と Y 位置を保存します。この保存された位置は、ACTION_MOVE の場合に画面上のオブジェクトを移動する距離の計算に使用されるため、アプリは常に正しいポインタからのデータを使用して、移動する距離を計算します。

次のスニペットで、ユーザーは画面上でオブジェクトをドラッグできるようになります。アクティブなポインタの初期位置を記録し、ポインタが移動した距離を計算し、オブジェクトを新しい位置に移動します。上記のように、追加のポインタの可能性を正しく管理します。

なお、このスニペットでは getActionMasked() メソッドを使用しています。MotionEvent のアクションを取得するには、常にこのメソッド(できれば MotionEventCompat.getActionMasked() の互換バージョン)を使用する必要があります。過去の getAction() メソッドとは異なり、getActionMasked() は複数のポインタを扱うように設計されています。ポインタのインデックス ビットを含めずに、実行中のマスクされたアクションを返します。

Kotlin

    // The ‘active pointer’ is the one currently moving our object.
    private var mActivePointerId = INVALID_POINTER_ID

    override fun onTouchEvent(ev: MotionEvent): Boolean {
        // Let the ScaleGestureDetector inspect all events.
        mScaleDetector.onTouchEvent(ev)

        val action = MotionEventCompat.getActionMasked(ev)

        when (action) {
            MotionEvent.ACTION_DOWN -> {
                MotionEventCompat.getActionIndex(ev).also { pointerIndex ->
                    // Remember where we started (for dragging)
                    mLastTouchX = MotionEventCompat.getX(ev, pointerIndex)
                    mLastTouchY = MotionEventCompat.getY(ev, pointerIndex)
                }

                // Save the ID of this pointer (for dragging)
                mActivePointerId = MotionEventCompat.getPointerId(ev, 0)
            }

            MotionEvent.ACTION_MOVE -> {
                // Find the index of the active pointer and fetch its position
                val (x: Float, y: Float) =
                        MotionEventCompat.findPointerIndex(ev, mActivePointerId).let { pointerIndex ->
                            // Calculate the distance moved
                            MotionEventCompat.getX(ev, pointerIndex) to
                                    MotionEventCompat.getY(ev, pointerIndex)
                        }

                mPosX += x - mLastTouchX
                mPosY += y - mLastTouchY

                invalidate()

                // Remember this touch position for the next move event
                mLastTouchX = x
                mLastTouchY = y
            }
            MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
                mActivePointerId = INVALID_POINTER_ID
            }
            MotionEvent.ACTION_POINTER_UP -> {

                MotionEventCompat.getActionIndex(ev).also { pointerIndex ->
                    MotionEventCompat.getPointerId(ev, pointerIndex)
                            .takeIf { it == mActivePointerId }
                            ?.run {
                                // This was our active pointer going up. Choose a new
                                // active pointer and adjust accordingly.
                                val newPointerIndex = if (pointerIndex == 0) 1 else 0
                                mLastTouchX = MotionEventCompat.getX(ev, newPointerIndex)
                                mLastTouchY = MotionEventCompat.getY(ev, newPointerIndex)
                                mActivePointerId = MotionEventCompat.getPointerId(ev, newPointerIndex)
                            }
                }
            }
        }
        return true
    }
    

Java

    // The ‘active pointer’ is the one currently moving our object.
    private int mActivePointerId = INVALID_POINTER_ID;

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        // Let the ScaleGestureDetector inspect all events.
        mScaleDetector.onTouchEvent(ev);

        final int action = MotionEventCompat.getActionMasked(ev);

        switch (action) {
        case MotionEvent.ACTION_DOWN: {
            final int pointerIndex = MotionEventCompat.getActionIndex(ev);
            final float x = MotionEventCompat.getX(ev, pointerIndex);
            final float y = MotionEventCompat.getY(ev, pointerIndex);

            // Remember where we started (for dragging)
            mLastTouchX = x;
            mLastTouchY = y;
            // Save the ID of this pointer (for dragging)
            mActivePointerId = MotionEventCompat.getPointerId(ev, 0);
            break;
        }

        case MotionEvent.ACTION_MOVE: {
            // Find the index of the active pointer and fetch its position
            final int pointerIndex =
                    MotionEventCompat.findPointerIndex(ev, mActivePointerId);

            final float x = MotionEventCompat.getX(ev, pointerIndex);
            final float y = MotionEventCompat.getY(ev, pointerIndex);

            // Calculate the distance moved
            final float dx = x - mLastTouchX;
            final float dy = y - mLastTouchY;

            mPosX += dx;
            mPosY += dy;

            invalidate();

            // Remember this touch position for the next move event
            mLastTouchX = x;
            mLastTouchY = y;

            break;
        }

        case MotionEvent.ACTION_UP: {
            mActivePointerId = INVALID_POINTER_ID;
            break;
        }

        case MotionEvent.ACTION_CANCEL: {
            mActivePointerId = INVALID_POINTER_ID;
            break;
        }

        case MotionEvent.ACTION_POINTER_UP: {

            final int pointerIndex = MotionEventCompat.getActionIndex(ev);
            final int pointerId = MotionEventCompat.getPointerId(ev, pointerIndex);

            if (pointerId == mActivePointerId) {
                // This was our active pointer going up. Choose a new
                // active pointer and adjust accordingly.
                final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
                mLastTouchX = MotionEventCompat.getX(ev, newPointerIndex);
                mLastTouchY = MotionEventCompat.getY(ev, newPointerIndex);
                mActivePointerId = MotionEventCompat.getPointerId(ev, newPointerIndex);
            }
            break;
        }
        }
        return true;
    }
    

ドラッグしてパンする

前のセクションでは、画面上でオブジェクトをドラッグする例を示しました。もう 1 つの一般的なシナリオはパンです。つまりユーザーのドラッグ動作によって x 軸と y 軸の両方がスクロールする場合です。上記のスニペットでは、MotionEvent アクションを直接インターセプトしてドラッグを実装しました。このセクションのスニペットでは、プラットフォームに組み込まれている、一般的な操作に対するサポートを利用しています。GestureDetector.SimpleOnGestureListeneronScroll() をオーバーライドします。

もう少し詳しく説明すると、ユーザーが指をドラッグしてコンテンツをパンしているときに onScroll() が呼び出されます。 onScroll() は、指が画面上にある場合にのみ呼び出されます。指が画面から離れるとすぐに、操作が終了するか、フリング操作が開始します(指が離れる直前に、ある程度の速度で動いていた場合)。スクロールとフリングの詳細については、スクロール操作のアニメーション化をご覧ください。

onScroll() のスニペットを次に示します。

Kotlin

    // The current viewport. This rectangle represents the currently visible
    // chart domain and range.
    private val mCurrentViewport = 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 should be drawn.
    private val mContentRect: Rect? = null

    private val mGestureListener = object : GestureDetector.SimpleOnGestureListener() {
        ...
        override fun onScroll(
                e1: MotionEvent,
                e2: MotionEvent,
                distanceX: Float,
                distanceY: Float
        ): Boolean {
            // Scrolling uses math based on the viewport (as opposed to math using pixels).

            mContentRect?.apply {
                // Pixel offset is the offset in screen pixels, while viewport offset is the
                // offset within the current viewport.
                val viewportOffsetX = distanceX * mCurrentViewport.width() / width()
                val viewportOffsetY = -distanceY * mCurrentViewport.height() / height()

                // Updates the viewport, refreshes the display.
                setViewportBottomLeft(
                        mCurrentViewport.left + viewportOffsetX,
                        mCurrentViewport.bottom + viewportOffsetY
                )
            }

            return true
        }
    }
    

Java

    // The current viewport. This rectangle represents the currently visible
    // chart domain and range.
    private RectF mCurrentViewport =
            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 should be drawn.
    private Rect mContentRect;

    private final GestureDetector.SimpleOnGestureListener mGestureListener
                = new GestureDetector.SimpleOnGestureListener() {
    ...

    @Override
    public boolean onScroll(MotionEvent e1, MotionEvent e2,
                float distanceX, float distanceY) {
        // Scrolling uses math based on the viewport (as opposed to math using pixels).

        // Pixel offset is the offset in screen pixels, while viewport offset is the
        // offset within the current viewport.
        float viewportOffsetX = distanceX * mCurrentViewport.width()
                / mContentRect.width();
        float viewportOffsetY = -distanceY * mCurrentViewport.height()
                / mContentRect.height();
        ...
        // Updates the viewport, refreshes the display.
        setViewportBottomLeft(
                mCurrentViewport.left + viewportOffsetX,
                mCurrentViewport.bottom + viewportOffsetY);
        ...
        return true;
    }
    

onScroll() の実装では、タップ操作に応じてビューポートをスクロールします。

Kotlin

    /**
     * Sets the current viewport (defined by mCurrentViewport) to the given
     * X and Y positions. Note that the Y value represents the topmost pixel position,
     * and thus the bottom of the mCurrentViewport rectangle.
     */
    private fun setViewportBottomLeft(x: Float, y: Float) {
        /*
         * Constrains within the scroll range. The scroll range is simply the viewport
         * extremes (AXIS_X_MAX, etc.) minus the viewport size. For example, if the
         * extremes were 0 and 10, and the viewport size was 2, the scroll range would
         * be 0 to 8.
         */

        val curWidth: Float = mCurrentViewport.width()
        val curHeight: Float = mCurrentViewport.height()
        val newX: Float = Math.max(AXIS_X_MIN, Math.min(x, AXIS_X_MAX - curWidth))
        val newY: Float = Math.max(AXIS_Y_MIN + curHeight, Math.min(y, AXIS_Y_MAX))

        mCurrentViewport.set(newX, newY - curHeight, newX + curWidth, newY)

        // Invalidates the View to update the display.
        ViewCompat.postInvalidateOnAnimation(this)
    }
    

Java

    /**
     * Sets the current viewport (defined by mCurrentViewport) to the given
     * X and Y positions. Note that the Y value represents the topmost pixel position,
     * and thus the bottom of the mCurrentViewport rectangle.
     */
    private void setViewportBottomLeft(float x, float y) {
        /*
         * Constrains within the scroll range. The scroll range is simply the viewport
         * extremes (AXIS_X_MAX, etc.) minus the viewport size. For example, if the
         * extremes were 0 and 10, and the viewport size was 2, the scroll range would
         * be 0 to 8.
         */

        float curWidth = mCurrentViewport.width();
        float curHeight = mCurrentViewport.height();
        x = Math.max(AXIS_X_MIN, Math.min(x, AXIS_X_MAX - curWidth));
        y = Math.max(AXIS_Y_MIN + curHeight, Math.min(y, AXIS_Y_MAX));

        mCurrentViewport.set(x, y - curHeight, x + curWidth, y);

        // Invalidates the View to update the display.
        ViewCompat.postInvalidateOnAnimation(this);
    }
    

タップを使用してサイズ変更する

一般的な操作の検出で説明しているように、GestureDetector は、スクロール、フリング、長押しなど、Android で使用される一般的な操作の検出に役立ちます。サイズ変更については、Android には ScaleGestureDetector が用意されています。ビューに追加の操作を認識させる場合、GestureDetectorScaleGestureDetector を一緒に使用できます。

検出された操作イベントをレポートするために、操作検出機能はコンストラクタに渡されたリスナー オブジェクトを使用します。ScaleGestureDetectorScaleGestureDetector.OnScaleGestureListener を使用します。Android には ScaleGestureDetector.SimpleOnScaleGestureListener というヘルパークラスが用意されており、レポートされたイベントすべてを対象にするわけでない場合に拡張できます。

基本的なサイズ変更の例

サイズ変更に関連する基本的な要素を示すスニペットを次に示します。

Kotlin

    private var mScaleFactor = 1f

    private val scaleListener = object : ScaleGestureDetector.SimpleOnScaleGestureListener() {

        override fun onScale(detector: ScaleGestureDetector): Boolean {
            mScaleFactor *= detector.scaleFactor

            // Don't let the object get too small or too large.
            mScaleFactor = Math.max(0.1f, Math.min(mScaleFactor, 5.0f))

            invalidate()
            return true
        }
    }

    private val mScaleDetector = ScaleGestureDetector(context, scaleListener)

    override fun onTouchEvent(ev: MotionEvent): Boolean {
        // Let the ScaleGestureDetector inspect all events.
        mScaleDetector.onTouchEvent(ev)
        return true
    }

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)

        canvas?.apply {
            save()
            scale(mScaleFactor, mScaleFactor)
            // onDraw() code goes here
            restore()
        }
    }
    

Java

    private ScaleGestureDetector mScaleDetector;
    private float mScaleFactor = 1.f;

    public MyCustomView(Context mContext){
        ...
        // View code goes here
        ...
        mScaleDetector = new ScaleGestureDetector(context, new ScaleListener());
    }

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        // Let the ScaleGestureDetector inspect all events.
        mScaleDetector.onTouchEvent(ev);
        return true;
    }

    @Override
    public void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        canvas.save();
        canvas.scale(mScaleFactor, mScaleFactor);
        ...
        // onDraw() code goes here
        ...
        canvas.restore();
    }

    private class ScaleListener
            extends ScaleGestureDetector.SimpleOnScaleGestureListener {
        @Override
        public boolean onScale(ScaleGestureDetector detector) {
            mScaleFactor *= detector.getScaleFactor();

            // Don't let the object get too small or too large.
            mScaleFactor = Math.max(0.1f, Math.min(mScaleFactor, 5.0f));

            invalidate();
            return true;
        }
    }
    

より複雑なサイズ変更の例

このクラスで提供される InteractiveChart サンプルの、より複雑な例を次に示します。InteractiveChart サンプルは、ScaleGestureDetector の「span」( getCurrentSpanX/Y )機能と「focus」( getFocusX/Y )機能を使用して、スクロール(パン)と複数の指によるサイズ変更の両方をサポートします。

Kotlin

    private val mCurrentViewport = RectF(AXIS_X_MIN, AXIS_Y_MIN, AXIS_X_MAX, AXIS_Y_MAX)
    private val mContentRect: Rect? = null
    ...
    override fun onTouchEvent(event: MotionEvent): Boolean {
        return mScaleGestureDetector.onTouchEvent(event)
                || mGestureDetector.onTouchEvent(event)
                || super.onTouchEvent(event)
    }

    /**
     * The scale listener, used for handling multi-finger scale gestures.
     */
    private val mScaleGestureListener = object : ScaleGestureDetector.SimpleOnScaleGestureListener() {

        /**
         * This is the active focal point in terms of the viewport. Could be a local
         * variable but kept here to minimize per-frame allocations.
         */
        private val viewportFocus = PointF()
        private var lastSpanX: Float = 0f
        private var lastSpanY: Float = 0f

        // Detects that new pointers are going down.
        override fun onScaleBegin(scaleGestureDetector: ScaleGestureDetector): Boolean {
            lastSpanX = scaleGestureDetector.currentSpanX
            lastSpanY = scaleGestureDetector.currentSpanY
            return true
        }

        override fun onScale(scaleGestureDetector: ScaleGestureDetector): Boolean {
            val spanX: Float = scaleGestureDetector.currentSpanX
            val spanY: Float = scaleGestureDetector.currentSpanY

            val newWidth: Float = lastSpanX / spanX * mCurrentViewport.width()
            val newHeight: Float = lastSpanY / spanY * mCurrentViewport.height()

            val focusX: Float = scaleGestureDetector.focusX
            val focusY: Float = scaleGestureDetector.focusY
            // Makes sure that the chart point is within the chart region.
            // See the sample for the implementation of hitTest().
            hitTest(focusX, focusY, viewportFocus)

            mContentRect?.apply {
                mCurrentViewport.set(
                        viewportFocus.x - newWidth * (focusX - left) / width(),
                        viewportFocus.y - newHeight * (bottom - focusY) / height(),
                        0f,
                        0f
                )
            }
            mCurrentViewport.right = mCurrentViewport.left + newWidth
            mCurrentViewport.bottom = mCurrentViewport.top + newHeight
            // Invalidates the View to update the display.
            ViewCompat.postInvalidateOnAnimation(this@InteractiveLineGraphView)

            lastSpanX = spanX
            lastSpanY = spanY
            return true
        }
    }
    

Java

    private RectF mCurrentViewport =
            new RectF(AXIS_X_MIN, AXIS_Y_MIN, AXIS_X_MAX, AXIS_Y_MAX);
    private Rect mContentRect;
    private ScaleGestureDetector mScaleGestureDetector;
    ...
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        boolean retVal = mScaleGestureDetector.onTouchEvent(event);
        retVal = mGestureDetector.onTouchEvent(event) || retVal;
        return retVal || super.onTouchEvent(event);
    }

    /**
     * The scale listener, used for handling multi-finger scale gestures.
     */
    private final ScaleGestureDetector.OnScaleGestureListener mScaleGestureListener
            = new ScaleGestureDetector.SimpleOnScaleGestureListener() {
        /**
         * This is the active focal point in terms of the viewport. Could be a local
         * variable but kept here to minimize per-frame allocations.
         */
        private PointF viewportFocus = new PointF();
        private float lastSpanX;
        private float lastSpanY;

        // Detects that new pointers are going down.
        @Override
        public boolean onScaleBegin(ScaleGestureDetector scaleGestureDetector) {
            lastSpanX = ScaleGestureDetectorCompat.
                    getCurrentSpanX(scaleGestureDetector);
            lastSpanY = ScaleGestureDetectorCompat.
                    getCurrentSpanY(scaleGestureDetector);
            return true;
        }

        @Override
        public boolean onScale(ScaleGestureDetector scaleGestureDetector) {

            float spanX = ScaleGestureDetectorCompat.
                    getCurrentSpanX(scaleGestureDetector);
            float spanY = ScaleGestureDetectorCompat.
                    getCurrentSpanY(scaleGestureDetector);

            float newWidth = lastSpanX / spanX * mCurrentViewport.width();
            float newHeight = lastSpanY / spanY * mCurrentViewport.height();

            float focusX = scaleGestureDetector.getFocusX();
            float focusY = scaleGestureDetector.getFocusY();
            // Makes sure that the chart point is within the chart region.
            // See the sample for the implementation of hitTest().
            hitTest(scaleGestureDetector.getFocusX(),
                    scaleGestureDetector.getFocusY(),
                    viewportFocus);

            mCurrentViewport.set(
                    viewportFocus.x
                            - newWidth * (focusX - mContentRect.left)
                            / mContentRect.width(),
                    viewportFocus.y
                            - newHeight * (mContentRect.bottom - focusY)
                            / mContentRect.height(),
                    0,
                    0);
            mCurrentViewport.right = mCurrentViewport.left + newWidth;
            mCurrentViewport.bottom = mCurrentViewport.top + newHeight;
            ...
            // Invalidates the View to update the display.
            ViewCompat.postInvalidateOnAnimation(InteractiveLineGraphView.this);

            lastSpanX = spanX;
            lastSpanY = spanY;
            return true;
        }
    };