본문 바로가기
Development/Android

[Android] Touch 이벤트 완벽 가이드: 발생 순서부터 처리 방법까지

by 은스타 2019. 9. 10.
반응형

안드로이드 터치 이벤트 완벽 가이드: 발생 순서부터 처리 방법까지

안드로이드 앱 개발에서 사용자의 터치 입력을 올바르게 처리하는 것은 매우 중요합니다. 특히 복잡한 제스처나 커스텀 뷰를 구현할 때는 터치 이벤트의 발생 순서와 처리 메커니즘을 정확히 이해해야 합니다. 이 글에서는 안드로이드에서 화면 터치 시 발생하는 이벤트 순서와 처리 방법을 초보자도 이해하기 쉽게 설명하겠습니다.


목차

  1. 안드로이드 터치 이벤트란?
  2. 터치 이벤트의 발생 순서
  3. MotionEvent 객체 살펴보기
  4. 터치 이벤트 전달 과정
  5. onTouchEvent() 메서드 구현하기
  6. OnTouchListener 사용하기
  7. 멀티 터치 처리하기
  8. 제스처 감지기(GestureDetector) 활용하기
  9. 터치 이벤트 충돌 해결하기
  10. 실전 예제: 커스텀 터치 뷰 구현하기

 

#1. 안드로이드 터치 이벤트란?

안드로이드에서 터치 이벤트는 사용자가 화면을 터치했을 때 발생하는 입력 이벤트입니다. 이 이벤트는 MotionEvent 객체로 표현되며, 터치의 종류(누르기, 움직이기, 떼기 등)와 좌표 정보를 담고 있습니다.

public boolean onTouchEvent(MotionEvent event) {
    // 여기서 터치 이벤트를 처리
    return super.onTouchEvent(event);
}

터치 이벤트는 Android UI 시스템의 기본이 되는 요소로, 버튼 클릭부터 복잡한 제스처까지 모든 터치 기반 상호작용의 기반이 됩니다.

 

#2. 터치 이벤트의 발생 순서

안드로이드에서 화면을 터치할 때 발생하는 이벤트의 기본 순서는 다음과 같습니다:

  1. ACTION_DOWN: 사용자가 화면을 처음 터치했을 때 발생
  2. ACTION_MOVE: 터치한 상태에서 손가락을 움직일 때 발생 (여러 번 연속해서 발생할 수 있음)
  3. ACTION_UP: 사용자가 화면에서 손가락을 뗄 때 발생

이 기본 순서를 코드로 확인해보겠습니다:

override fun onTouchEvent(event: MotionEvent): Boolean {
    when (event.action) {
        MotionEvent.ACTION_DOWN -> {
            Log.d("TouchEvent", "손가락이 화면에 닿았습니다. (ACTION_DOWN)")
            return true // true를 반환하여 이후 이벤트도 받겠다고 선언
        }
        MotionEvent.ACTION_MOVE -> {
            Log.d("TouchEvent", "손가락이 움직입니다. (ACTION_MOVE) x: ${event.x}, y: ${event.y}")
        }
        MotionEvent.ACTION_UP -> {
            Log.d("TouchEvent", "손가락을 화면에서 뗐습니다. (ACTION_UP)")
        }
    }
    return super.onTouchEvent(event)
}

💡 중요: ACTION_DOWN에서 true를 반환해야 이후의 ACTION_MOVE와 ACTION_UP 이벤트를 받을 수 있습니다. false를 반환하면 해당 터치 시퀀스의 이후 이벤트는 더 이상 받지 않습니다.

 

#3. MotionEvent 객체 살펴보기

MotionEvent 객체는 터치 이벤트에 관한 모든 정보를 담고 있습니다. 주요 메서드는 다음과 같습니다:

  • getAction(): 현재 이벤트의 액션 코드 (ACTION_DOWN, ACTION_MOVE, ACTION_UP 등)
  • getX(), getY(): 터치 포인트의 X, Y 좌표 (뷰 기준)
  • getRawX(), getRawY(): 터치 포인트의 X, Y 좌표 (화면 기준)
  • getPointerCount(): 현재 터치하고 있는 손가락 수
  • getPointerId(int): 특정 인덱스의 포인터 ID
  • getPressure(): 터치 압력 (지원되는 기기에서만)
  • getSize(): 터치 면적 (지원되는 기기에서만)
  • getEventTime(): 이벤트 발생 시간 (밀리초)

다음은 MotionEvent에서 기본 정보를 추출하는 예시입니다:

fun extractMotionEventInfo(event: MotionEvent) {
    val action = event.action
    val x = event.x
    val y = event.y
    val pressure = event.pressure
    val size = event.size
    val eventTime = event.eventTime

    Log.d("MotionEvent", """
        액션: ${getActionString(action)}
        좌표: ($x, $y)
        압력: $pressure
        크기: $size
        시간: $eventTime
    """.trimIndent())
}

fun getActionString(action: Int): String {
    return when (action) {
        MotionEvent.ACTION_DOWN -> "ACTION_DOWN"
        MotionEvent.ACTION_MOVE -> "ACTION_MOVE"
        MotionEvent.ACTION_UP -> "ACTION_UP"
        MotionEvent.ACTION_CANCEL -> "ACTION_CANCEL"
        else -> "기타 액션: $action"
    }
}

 

#4. 터치 이벤트 전달 과정

안드로이드에서 터치 이벤트는 뷰 계층 구조를 따라 전달됩니다. 이 과정을 이해하는 것이 중요합니다:

  1. 사용자가 화면을 터치하면 시스템이 MotionEvent 생성
  2. 액티비티의 dispatchTouchEvent()로 이벤트 전달
  3. 액티비티는 이벤트를 최상위 뷰의 dispatchTouchEvent()로 전달
  4. 이벤트는 터치 지점에 있는 가장 하위 뷰까지 전달됨 (Top-Down 방식)
  5. 각 뷰는 이벤트를 처리할지 결정 (onTouchEvent() 반환값으로)
  6. 하위 뷰가 처리하지 않으면 상위 뷰로 이벤트가 다시 전달됨 (Bottom-Up 방식)

터치 이벤트 전달 과정

이 과정을 단순화된 코드로 표현하면 다음과 같습니다:

// View 클래스 내부 (안드로이드 프레임워크의 간소화된 버전)
public boolean dispatchTouchEvent(MotionEvent event) {
    boolean result = false;

    // 1. OnTouchListener가 있으면 먼저 호출
    if (mOnTouchListener != null && mOnTouchListener.onTouch(this, event)) {
        result = true;
    } 
    // 2. OnTouchListener에서 처리되지 않았다면 onTouchEvent() 호출
    else if (onTouchEvent(event)) {
        result = true;
    }

    return result;
}

 

#5. onTouchEvent() 메서드 구현하기

onTouchEvent() 메서드는 View 클래스에 정의된 메서드로, 터치 이벤트를 직접 처리할 수 있습니다. 커스텀 뷰를 만들 때 이 메서드를 오버라이드하여 터치 동작을 구현합니다.

class CustomTouchView(context: Context, attrs: AttributeSet?) : View(context, attrs) {

    private var lastTouchX = 0f
    private var lastTouchY = 0f

    override fun onTouchEvent(event: MotionEvent): Boolean {
        val x = event.x
        val y = event.y

        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                // 터치 시작 지점 저장
                lastTouchX = x
                lastTouchY = y
                return true // 이후 이벤트도 계속 받기 위해 true 반환
            }
            MotionEvent.ACTION_MOVE -> {
                // 이동 거리 계산
                val dx = x - lastTouchX
                val dy = y - lastTouchY

                // 뷰 위치 이동 (예: 드래그 기능)
                translationX += dx
                translationY += dy

                // 마지막 위치 업데이트
                lastTouchX = x
                lastTouchY = y
            }
            MotionEvent.ACTION_UP -> {
                // 터치 완료 시 수행할 작업
                performClick() // 접근성을 위해 클릭 이벤트도 발생시킴
            }
        }

        return super.onTouchEvent(event)
    }

    // 접근성을 위해 performClick() 오버라이드
    override fun performClick(): Boolean {
        super.performClick()
        return true
    }
}

 

#6. OnTouchListener 사용하기

뷰의 서브클래스를 만들지 않고도 터치 이벤트를 처리하려면 OnTouchListener를 사용할 수 있습니다:

val myView = findViewById<View>(R.id.my_view)
myView.setOnTouchListener { v, event ->
    when (event.action) {
        MotionEvent.ACTION_DOWN -> {
            Log.d("TouchListener", "View가 터치되었습니다")
            true // 이벤트 처리됨
        }
        MotionEvent.ACTION_MOVE -> {
            Log.d("TouchListener", "View 위에서 손가락이 움직입니다")
            true
        }
        MotionEvent.ACTION_UP -> {
            Log.d("TouchListener", "View에서 손가락을 뗐습니다")
            v.performClick() // 접근성을 위해 필요
            true
        }
        else -> false // 다른 이벤트는 처리하지 않음
    }
}

OnTouchListener는 특히 기존 뷰의 터치 동작을 확장하거나 수정할 때 유용합니다.

 

#7. 멀티 터치 처리하기

안드로이드는 여러 손가락으로 동시에 화면을 터치하는 멀티 터치를 지원합니다. 멀티 터치 처리는 다음과 같은 추가 이벤트 액션을 사용합니다:

  • ACTION_POINTER_DOWN: 두 번째 이상의 손가락이 화면에 닿았을 때
  • ACTION_POINTER_UP: 두 번째 이상의 손가락을 화면에서 뗐을 때

멀티 터치 이벤트를 처리하는 코드 예시:

override fun onTouchEvent(event: MotionEvent): Boolean {
    // 액션 마스크로 기본 액션 추출
    val action = event.actionMasked

    when (action) {
        MotionEvent.ACTION_DOWN, 
        MotionEvent.ACTION_POINTER_DOWN -> {
            // 새로운 손가락이 화면에 닿음
            val pointerIndex = event.actionIndex
            val pointerId = event.getPointerId(pointerIndex)
            val x = event.getX(pointerIndex)
            val y = event.getY(pointerIndex)

            Log.d("MultiTouch", "손가락 #$pointerId DOWN 이벤트 발생: ($x, $y)")
            return true
        }

        MotionEvent.ACTION_MOVE -> {
            // 모든 활성 포인터의 위치 추적
            for (i in 0 until event.pointerCount) {
                val pointerId = event.getPointerId(i)
                val x = event.getX(i)
                val y = event.getY(i)

                Log.d("MultiTouch", "손가락 #$pointerId MOVE: ($x, $y)")
            }
        }

        MotionEvent.ACTION_UP,
        MotionEvent.ACTION_POINTER_UP -> {
            // 손가락을 화면에서 뗌
            val pointerIndex = event.actionIndex
            val pointerId = event.getPointerId(pointerIndex)

            Log.d("MultiTouch", "손가락 #$pointerId UP 이벤트 발생")
        }

        MotionEvent.ACTION_CANCEL -> {
            Log.d("MultiTouch", "터치 이벤트가 취소됨")
        }
    }

    return super.onTouchEvent(event)
}

💡 참고: 멀티 터치에서는 event.action이 아닌 event.actionMasked를 사용하여 액션을 추출해야 합니다.

 

#8. 제스처 감지기(GestureDetector) 활용하기

복잡한 제스처 처리는 직접 구현하기 어려울 수 있습니다. 안드로이드는 이를 위해 GestureDetectorScaleGestureDetector 같은 도우미 클래스를 제공합니다.

class GestureExampleView(context: Context, attrs: AttributeSet?) : View(context, attrs) {

    private val gestureDetector: GestureDetector

    init {
        // GestureDetector 초기화
        gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
            override fun onDown(e: MotionEvent): Boolean {
                // 모든 제스처는 onDown()에서 시작됨
                return true
            }

            override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
                Log.d("Gesture", "단일 탭 감지됨")
                return true
            }

            override fun onDoubleTap(e: MotionEvent): Boolean {
                Log.d("Gesture", "더블 탭 감지됨")
                return true
            }

            override fun onLongPress(e: MotionEvent) {
                Log.d("Gesture", "롱 프레스 감지됨")
            }

            override fun onScroll(
                e1: MotionEvent, 
                e2: MotionEvent, 
                distanceX: Float, 
                distanceY: Float
            ): Boolean {
                Log.d("Gesture", "스크롤 감지됨: X=$distanceX, Y=$distanceY")
                return true
            }

            override fun onFling(
                e1: MotionEvent, 
                e2: MotionEvent, 
                velocityX: Float, 
                velocityY: Float
            ): Boolean {
                Log.d("Gesture", "플링 감지됨: velocityX=$velocityX, velocityY=$velocityY")
                return true
            }
        })
    }

    override fun onTouchEvent(event: MotionEvent): Boolean {
        // GestureDetector에 모든 터치 이벤트 전달
        return if (gestureDetector.onTouchEvent(event)) {
            true
        } else {
            super.onTouchEvent(event)
        }
    }
}

확대/축소 제스처는 ScaleGestureDetector로 처리할 수 있습니다:

// 확대/축소 제스처 감지 예시
val scaleDetector = ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
    override fun onScale(detector: ScaleGestureDetector): Boolean {
        val scaleFactor = detector.scaleFactor
        // 뷰 크기 조절
        scaleX *= scaleFactor
        scaleY *= scaleFactor
        return true
    }
})

// onTouchEvent()에서 사용
override fun onTouchEvent(event: MotionEvent): Boolean {
    scaleDetector.onTouchEvent(event)
    return true
}

 

#9. 터치 이벤트 충돌 해결하기

복잡한 뷰 계층에서는 여러 뷰가 터치 이벤트를 처리하려고 할 때 충돌이 발생할 수 있습니다. 예를 들어, ScrollView 안에 있는 커스텀 뷰의 제스처 처리가 스크롤과 충돌할 수 있습니다.

이런 문제를 해결하는 방법은 다음과 같습니다:

방법 1: 부모 뷰의 터치 이벤트 가로채기

// 부모 ScrollView가 특정 방향의 스크롤을 처리하지 않도록 설정
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    // 수평 방향 이동이 감지되면 부모가 이벤트를 가로채지 않음
    if (isHorizontalScrolling(ev)) {
        return false;
    }
    return super.onInterceptTouchEvent(ev);
}

방법 2: requestDisallowInterceptTouchEvent() 사용

자식 뷰에서 부모의 이벤트 가로채기를 금지할 수 있습니다:

override fun onTouchEvent(event: MotionEvent): Boolean {
    // 부모 뷰가 이 터치 이벤트를 가로채지 못하도록 설정
    parent.requestDisallowInterceptTouchEvent(true)

    when (event.action) {
        MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
            // 터치 시퀀스가 끝나면 부모의 가로채기 다시 허용
            parent.requestDisallowInterceptTouchEvent(false)
        }
    }

    return super.onTouchEvent(event)
}

 

#10. 실전 예제: 커스텀 터치 뷰 구현하기

이제 지금까지 배운 내용을 활용하여 간단한 드래그 가능한 커스텀 뷰를 구현해 보겠습니다:

class DraggableView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

    private var dX = 0f
    private var dY = 0f
    private val paint = Paint().apply {
        color = Color.BLUE
        style = Paint.Style.FILL
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        // 원 그리기
        val radius = width.coerceAtMost(height) / 2f * 0.8f
        canvas.drawCircle(width / 2f, height / 2f, radius, paint)
    }

    @SuppressLint("ClickableViewAccessibility")
    override fun onTouchEvent(event: MotionEvent): Boolean {
        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                // 터치 시작 시 오프셋 계산
                dX = x - event.rawX
                dY = y - event.rawY
                // 애니메이션으로 약간 축소하여 피드백 제공
                animate().scaleX(0.95f).scaleY(0.95f).setDuration(100).start()
                return true
            }
            MotionEvent.ACTION_MOVE -> {
                // 뷰 위치 업데이트 (드래그)
                x = event.rawX + dX
                y = event.rawY + dY
            }
            MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
                // 터치 종료 시 원래 크기로 복원
                animate().scaleX(1f).scaleY(1f).setDuration(100).start()
                performClick()
            }
        }
        return super.onTouchEvent(event)
    }

    override fun performClick(): Boolean {
        // 접근성을 위한 클릭 처리
        return super.performClick()
    }
}

레이아웃 XML에서 사용:

<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.example.app.DraggableView
        android:id="@+id/draggable_view"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:layout_gravity="center"/>

</FrameLayout>

요약

안드로이드에서 터치 이벤트는 다음과 같은 순서로 발생합니다:

  1. ACTION_DOWN: 터치 시작
  2. ACTION_MOVE: 손가락 이동 (여러 번 발생 가능)
  3. ACTION_UP: 터치 종료

또한 멀티 터치의 경우:

  • ACTION_POINTER_DOWN: 추가 손가락 터치 시작
  • ACTION_POINTER_UP: 추가 손가락 터치 종료

터치 이벤트 처리를 위한 주요 방법:

  • 뷰의 onTouchEvent() 메서드 오버라이드
  • OnTouchListener 구현
  • GestureDetector 사용

터치 이벤트를 올바르게 처리하면 직관적이고 반응성 좋은 사용자 인터페이스를 구현할 수 있습니다. 복잡한 제스처나 사용자 정의 컨트롤을 만들 때는 위에서 설명한 개념과 방법을 조합하여 사용하세요.

긴 글 읽어주셔서 감사합니다.

끝.

반응형