드래그 및 확대

이 강의에서는 터치 이벤트를 가로채는 onTouchEvent()를 통해 터치 동작을 사용하여 화면에서 객체를 드래그하고 확장하는 방법을 설명합니다.

다음 관련 리소스를 참조하세요.

객체 드래그

Android 3.0 이상을 타겟팅하는 경우 드래그 앤 드롭에 설명된 것처럼 View.OnDragListener와 함께 내장된 드래그 앤 드롭 이벤트 리스너를 사용할 수 있습니다.

터치 동작의 일반적인 작업은 터치를 사용해 화면에서 객체를 드래그하는 것입니다. 다음 스니펫은 사용자가 화면의 이미지를 드래그할 수 있게 만듭니다. 다음 사항을 참고하세요.

  • 드래그(또는 스크롤) 작업에서 앱은 손가락이 추가로 화면에 놓이더라도 원래 포인터(손가락)를 추적해야 합니다. 예를 들어 이미지를 드래그하는 중에 사용자가 터치 스크린에 두 번째 손가락을 올리고 첫 번째 손가락을 뗀다고 가정하겠습니다. 앱이 개별 포인터를 추적한다면 두 번째 포인터를 기본값으로 고려하여 이미지를 두 번째 포인터 위치로 이동할 것입니다.
  • 이러한 상황을 방지하려면 앱이 원래 포인터와 후속 포인터를 구별해야 합니다. 이를 위해 멀티터치 동작 처리에 설명된 ACTION_POINTER_DOWNACTION_POINTER_UP 이벤트를 추적합니다. 두 번째 포인터가 아래나 위로 이동할 때마다 ACTION_POINTER_DOWNACTION_POINTER_UPonTouchEvent() 콜백에 전달됩니다.
  • ACTION_POINTER_UP의 경우 예에서는 이 색인을 추출하고 활성 포인터 ID가 더 이상 화면을 터치하지 않는 포인터를 참조하지 않도록 합니다. 화면을 터치하지 않는 포인터를 활성 포인터 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
    }
    

자바

    // 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;
    }
    

드래그하여 이동

이전 섹션에서는 화면에서 개체를 드래그하는 예를 보여줍니다. 또 하나의 일반적인 시나리오는 화면 이동으로, 이는 사용자의 드래그 모션으로 스크롤이 x축과 y축 모두를 따라 이루어지는 것입니다. 위의 스니펫은 MotionEvent 작업을 바로 가로채서 드래그를 구현합니다. 이 섹션의 스니펫은 일반적인 동작에 관해 플랫폼에 내장된 지원을 활용합니다. 그리고 GestureDetector.SimpleOnGestureListener에서 onScroll()을 재정의합니다.

사용자가 콘텐츠를 이동하려고 손가락으로 드래그하면 추가 컨텍스트를 제공하기 위해 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
        }
    }
    

자바

    // 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)
    }
    

자바

    /**
     * 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()
        }
    }
    

자바

    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 '손가락으로 펼치기'(getCurrentSpanX/Y) 기능과 '포커스'(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
        }
    }
    

자바

    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;
        }
    };