[마인드 블루밍] 3. 개발 이슈 - 드래그 및 확대

Hwichan Ji·2021년 2월 2일
0

마인드 블루밍

목록 보기
4/4
post-thumbnail

❗ 안드로이드 앱 마인드 블루밍의 개발 과정을 포스팅한 글입니다.

🥇 객체 드래그

객체 드래그는 활성 포인터의 최초 위치를 기록📝하고 포인터가 이동한 거리를 계산한 뒤, 개체를 새 위치로 이동시키는 과정을 거쳐 이루어집니다. 단, 이러한 과정 속에서 추가 포인터의 가능성을 올바르게 관리해야 합니다.

❗ 추가 포인터 관리

드래그 작업 중이라면 앱은 손가락👆이 추가로 화면에 놓이더라도 기존의 포인터를 계속 추적해야 합니다. 기존 포인터와 후속 포인터의 구별은 ACTION_POINTER_DOWN 이벤트 및 ACTION_POINTER_UP 이벤트를 통해 할 수 있습니다. 두 이벤트는 onTouchEvent() 콜백에 전달됩니다.

멀티터치 이벤트

  • ACTION_DOWN: 화면을 터치하는 첫 번째 포인터의 이벤트. 이 이벤트가 동작을 시작
  • ACTION_UP: 마지막 포인터가 화면 밖으로 나갈 때 발생하는 이벤트
  • ACTION_MOVE: 누르기 동작 중에 변경사항이 있으면 발생하는 이벤트
  • ACTION_POINTER_DOWN: 추가 포인터 발생 이벤트
  • ACTION_POINTER_UP: 포인터가 두 개 이상인 상황에서 한 포인터가 위로 올라갔을때 발생하는 이벤트

아래 코드는 첫 번째 포인터와 추가 포인터를 추적하여 객체 이동 위치를 계산하는 코드입니다.

// active pointer: 현재 객체를 이동시키고 있는 포인터
private var mActivePointerId = INVALID_POINTER_ID

override fun onTouchEvent(ev: MotionEvent): Boolean {
    // ScaleGestureDetector가 모든 이벤트를 감시하도록 설정
    mScaleDetector.onTouchEvent(ev)

    // MotionEvent의 작업 가져옴
    val action = MotionEventCompat.getActionMasked(ev)

    when (action) {
        MotionEvent.ACTION_DOWN -> {
            MotionEventCompat.getActionIndex(ev).also { pointerIndex ->
                // 드래그 시작 위치 기록
                mLastTouchX = MotionEventCompat.getX(ev, pointerIndex)
                mLastTouchY = MotionEventCompat.getY(ev, pointerIndex)
            }

            // active pointer 설정
            mActivePointerId = MotionEventCompat.getPointerId(ev, 0)
        }

        MotionEvent.ACTION_MOVE -> {
            // active pointer가 이동한 위치를 기록
            val (x: Float, y: Float) =
                    MotionEventCompat.findPointerIndex(ev, mActivePointerId).let { pointerIndex ->
                        // to: pair를 만드는 키워드
                        MotionEventCompat.getX(ev, pointerIndex) to
                               MotionEventCompat.getY(ev, pointerIndex)
                    }
            // 이동할 위치 계산
            mPosX += x - mLastTouchX
            mPosY += y - mLastTouchY

            // view 무효화. view가 visible 상태이면 곧 `onDraw()`가 자동으로 호출됨
            invalidate()

            // 이동한 위치 기록
            mLastTouchX = x
            mLastTouchY = y
        }
        
        MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
            // 첫번째 포인터가 사라졌을 때 active pointer가 추가 포인터를 참조하지 않도록 설정
            mActivePointerId = INVALID_POINTER_ID
        }
        
        MotionEvent.ACTION_POINTER_UP -> {
            MotionEventCompat.getActionIndex(ev).also { pointerIndex ->
                MotionEventCompat.getPointerId(ev, pointerIndex)
                        .takeIf { it == mActivePointerId }
                        ?.run {
                            // active pointer가 위로 올라갔을 때 추가 포인터를 active pointer로 설정
                            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
}

🥈 드래그하여 화면 이동

드래그를 통한 화면 이동은 사용자의 드래그 모션으로 스크롤이 x축과 y축 모두를 따라 이루어지는 것입니다. 이를 위해 GestureDetector.SimpleOnGestureListener에서 onScroll()을 재정의합니다.

// viewport: 현재 디스플레이 되고 있는 뷰의 도메인과 범위를 표현하는 사각형
private val mCurrentViewport = RectF(AXIS_X_MIN, AXIS_Y_MIN, AXIS_X_MAX, AXIS_Y_MAX)
// 데이터가 그려질 목표 사각형 (픽셀 좌표계)
private val mContentRect: Rect? = null

private val mGestureListener = object : GestureDetector.SimpleOnGestureListener() {
    // ...
    override fun onScroll(
            e1: MotionEvent,
            e2: MotionEvent,
            distanceX: Float,
            distanceY: Float
    ): Boolean {
        // 스크롤링은 픽셀이 아닌 viewport 기반의 수학을 사용 
        mContentRect?.apply {
            // 픽셀 단위를 viewport 단위로 변경
            val viewportOffsetX = distanceX * mCurrentViewport.width() / width()
            val viewportOffsetY = -distanceY * mCurrentViewport.height() / height()

            // viewport를 변경하고 리프레쉬
            setViewportBottomLeft(
                mCurrentViewport.left + viewportOffsetX,
                mCurrentViewport.bottom + viewportOffsetY
            )
        }
        return true
    } 
}

// 현재 viewport를 주어진 x, y 포지션에 따라 설정
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)
    // 화면을 업데이트하기 위해 뷰를 무효화
    ViewCompat.postInvalidateOnAnimation(this)
}

🥉 터치를 사용하여 확장

Android에서는 확장을 위해 ScaleGestureDetector를 제공합니다.

private var mScaleFactor = 1f

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

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

        // 최대 크기와 최소 크기 제한을 둬야함
        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 {
    // ScaleGestureDetector 모든 이벤트를 감지하도록 설정
    mScaleDetector.onTouchEvent(ev)
    return true
}

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

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

📖 Reference

  1. Android Developer - 드래그 및 확대
  2. Android Developer - 멀티터치 동작 처리하기
  3. Example Code
profile
안드로이드 개발자를 꿈꾸는 사람

0개의 댓글