❗ 안드로이드 앱 마인드 블루밍의 개발 과정을 포스팅한 글입니다.
객체 드래그는 활성 포인터의 최초 위치를 기록📝하고 포인터가 이동한 거리를 계산한 뒤, 개체를 새 위치로 이동시키는 과정을 거쳐 이루어집니다. 단, 이러한 과정 속에서 추가 포인터의 가능성을 올바르게 관리해야 합니다.
드래그 작업 중이라면 앱은 손가락👆이 추가로 화면에 놓이더라도 기존의 포인터를 계속 추적해야 합니다. 기존 포인터와 후속 포인터의 구별은 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()
}
}