이 문서에서는 터치 동작을 사용하여 화면의 객체를 드래그하고 확장하는 방법을 설명합니다. 이때 onTouchEvent()
를 사용하여 터치 이벤트를 가로챕니다.
객체 드래그
터치 동작의 일반적인 작업은 터치를 사용해 화면에서 객체를 드래그하는 것입니다.
드래그 또는 스크롤 작업에서 앱은 손가락이 추가로 화면을 터치하더라도 원래 포인터를 추적해야 합니다. 예를 들어 이미지를 드래그하는 중에 사용자가 터치 스크린에 두 번째 손가락을 올리고 첫 번째 손가락을 뗀다고 가정하겠습니다. 앱이 개별 포인터만 추적하는 경우 두 번째 포인터를 기본값으로 간주하여 이미지를 두 번째 포인터 위치로 이동합니다.
이러한 상황을 방지하려면 앱이 원래 포인터와 후속 포인터를 구별해야 합니다. 이를 위해 멀티터치 동작 처리에 설명된 ACTION_POINTER_DOWN
및 ACTION_POINTER_UP
이벤트를 추적합니다.
두 번째 포인터가 아래나 위로 이동할 때마다 ACTION_POINTER_DOWN
과 ACTION_POINTER_UP
이 onTouchEvent()
콜백에 전달됩니다.
ACTION_POINTER_UP
의 경우 이 색인을 추출하고 활성 포인터 ID가 더 이상 화면을 터치하지 않는 포인터를 참조하지 않도록 할 수 있습니다. 화면을 터치하지 않는 포인터를 활성 포인터 ID가 참조하고 있다면 다른 포인터를 활성 포인터로 선택하고 포인터의 현재 X 위치와 Y 위치를 저장할 수 있습니다. ACTION_MOVE
경우에 저장된 이 위치를 사용하여 화면의 객체를 이동할 거리를 계산합니다. 이렇게 하면 앱은 항상 올바른 포인터의 데이터를 사용하여 이동 거리를 계산합니다.
다음 코드 스니펫을 사용하면 사용자가 화면에서 객체를 드래그할 수 있습니다. 활성 포인터의 최초 위치를 기록하고, 포인터가 이동한 거리를 계산하고, 개체를 새 포지션으로 이동합니다. 또한 추가 포인터의 가능성을 올바르게 관리합니다.
스니펫은 getActionMasked()
메서드를 사용합니다. MotionEvent
의 작업을 가져올 때는 항상 이 메서드를 사용합니다.
Kotlin
// The "active pointer" is the one moving the 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 you start 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 is the active pointer going up. Choose a new // active pointer and adjust it 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 moving the 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 the starting position of the pointer. 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 is the active pointer going up. Choose a new // active pointer and adjust it 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 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 must 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 and refreshes the display. setViewportBottomLeft( mCurrentViewport.left + viewportOffsetX, mCurrentViewport.bottom + viewportOffsetY ) } return true } }
자바
// The current viewport. This rectangle represents the 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 must 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. 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 the viewport * extremes, such as AXIS_X_MAX, minus the viewport size. For example, if * the extremes are 0 and 10 and the viewport size is 2, the scroll range * is 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 the viewport * extremes, such as AXIS_X_MAX, minus the viewport size. For example, if * the extremes are 0 and 10 and the viewport size is 2, the scroll range * is 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
를 제공합니다.
뷰에서 추가 동작을 인식하도록 하려면 GestureDetector
와 ScaleGestureDetector
를 함께 사용할 수 있습니다.
감지된 동작 이벤트를 보고하기 위해 동작 감지기는 생성자에 전달된 리스너 객체를 사용합니다. ScaleGestureDetector
는 ScaleGestureDetector.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
span(getCurrentSpanX
및 getCurrentSpanY
) 및 '포커스'(getFocusX
및 getFocusY
) 기능을 사용하여 여러 손가락을 사용한 스크롤, 화면 이동, 크기 조절을 지원합니다.
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. It can be a * local variable, but keep it here to minimize per-frame allocations. */ private val viewportFocus = PointF() private var lastSpanX: Float = 0f private var lastSpanY: Float = 0f // Detects 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 // Ensures 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. It can be a * local variable, but keep it here to minimize per-frame allocations. */ private PointF viewportFocus = new PointF(); private float lastSpanX; private float lastSpanY; // Detects 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(); // Ensures 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; } };
추가 리소스
입력 이벤트, 센서, 맞춤 뷰를 대화형으로 만드는 방법에 관한 자세한 내용은 다음 참조를 참고하세요.