안드로이드 터치 시스템

seong-hwan Kim
shDev
Published in
14 min readDec 24, 2021

목표

  • 터치 이벤트 종류
  • 제스처
  • 액티비티 부터 터치 대상 까지 터치 이벤트가 전달되는 과정
  • 터치 이벤트 전달의 응용

터치 이벤트

안드로이드의 위젯과 상호작용이 필요할 때 클릭 등의 간단한 동작이라면 setOnClickListener를 설정하는 것으로 충분하다. 하지만 클릭에서 각각의 동작을 구분하여 처리할 필요가 있거나, 좀 더 복잡한 상호작용을 위해서는 뷰에 전달되는 터치 이벤트마다 각각의 작업을 정의해야 한다.

안드로이드의 터치 이벤트는 ViewOnTouchListener#onTouch 또는 onTouchEvent#onTouchEvent 메소드에 MotionEvent의 형태로 전달된다.

각각의 MotionEvent는 터치가 시작되었는지, 터치가 된 상태에서 움직였는지, 터치가 종료되었는지 등 action에 대한 정보를 가지고 있다. 해당 액션 정보를 사용하여 분기하면 터치의 세부 동작마다 수행할 작업을 정의할 수 있다.

MotionEvent의 문서에서는 MotionEvent의 액션에 대한 정보를 제공한다. 문서에 따라 현재 Deprecated 되지 않은 액션은 다음과 같다. 여기서 POINTER와 관련된 액션은 멀티 터치 이벤트 처리에서 사용된다.

  • ACTION_DOWN
  • ACTION_UP
  • ACTION_MOVE
  • ACTION_CANCEL
  • ACTION_OUTSIDE
  • ACTION_POINTER_DOWN
  • ACTION_POINTER_UP
  • ACTION_HOVER_MOVE
  • ACTION_SCROLL
  • ACTION_HOVER_ENTER
  • ACTION_HOVER_EXIT
  • ACTION_BUTTON_PRESS
  • ACTION_BUTTON_RELEASE

제스처

위젯과 상호작용하기 위해 일반적인 터치 이벤트 외에도 더블 탭, 튕기기(fling) 등의 제스처에 따른 동작이 필요할 수도 있다. onTouchEvent를 오버라이드하거나 커스텀 제스처를 정의할 수도 있지만 이를 직접 구현하는 것은 복잡한 과정이며 실수가 발생하기 쉽다.

제스처를 사용하는 가장 간편한 방법은 안드로이드가 제공하는 GestrueDetector를 사용하는 것이다.

GestureDetector는 제스처를 감지하는 몇 가지 리스너를 제공한다.

해당 제스처를 감지하기 위해선 위의 인터페이스를 구현하는 리스너를 생성하고, 해당 리스너를 사용하여 GestureDetectorCompat 객체를 생성한다.

이후 제스처를 감지해야 하는 뷰의 onTouchEvent 메소드에서 GestureDetectorCompat에게 MotionEvent를 전달하는 것으로 제스처 리스너를 사용할 수 있다.

안드로이드 터치 시스템

들어가며

Q: 레이아웃과 뷰가 중첩되어 있을 터치 이벤트를 처리하는 target

A: 터치한 포인트에서 최상단에 위치한 뷰가 터치 이벤트를 처리한다.

Q: Boolean 값을 반환하는 이벤트의 의미

A: Boolean 값을 반환하는 이벤트에서 true를 반환한다는 것은 여러 가지 의미를 가진다.

  • 현재 이벤트에 대해 관심이 있으니 후속 이벤트를 보내달라.
    ⇒ 뷰에서 ACTION_DOWN을 수신했을 때 true를 반환하면, 해당 뷰에서 후속 이벤트(ACTION_MOVE, ACTION_UP, ...)가 호출된다.
  • 현재 이벤트를 처리하였으니 이벤트에 대한 추가 작업을 하지 마라.
    onLongClick 등의 이벤트에서 true를 반환하면 onClick 이벤트는 호출하지 않는다.

Q: 터치 이벤트를 등록하는 방법

A: 터치 이벤트는 여러 가지 방법을 사용하여 수신할 수 있다.

  • Activity#onTouchEvent
  • View#onTouchEvent
  • onTouchListener#onTouch

터치 이벤트의 전달 과정

안드로이드 터치 시스템의 동작 과정을 알아보기 위해 먼저 공식 문서에서 터치 시스템에 대해 소개하는 내용을 살펴보자.

이벤트를 처리하기 위해 기본적으로 제공하는 이벤트 리스너와 핸들러를 사용할 수도 있지만, 레이아웃 안에서 좀 더 복잡한 이벤트를 관리하는 경우 다음 메서드를 사용할 수도 있다.

  • Activity.dispatchTouchEvent(MotionEvent): 터치 이벤트가 window에 전달되기 전에 액티비티에서 먼저 처리할 수 있다.
  • ViewGroup.onInterceptTouchEvent(MotionEvent): 자식 뷰로 전달되는 터치 이벤트를 부모 뷰에서 가로채어 처리할 수 있다. 이 메소드에서 true를 반환하면 터치 이벤트는 자식 뷰로 전달되지 않고 부모 뷰에서 처리된다. ViewGroup의 메소드이기 때문에 View에서는 사용할 수 없다.
  • ViewParent.requestDisallowInterceptTouchEvent(boolean): 자식 뷰에서 부모 뷰가 onInterceptTouchEvent를 통해 터치 이벤트를 가로채는 것을 막기 위해 사용된다. 이 메소드는 하나의 터치 이벤트에 대해 동작하기 때문에 지속적인 disallow가 필요하다면 onTouchEvent 내에서 터치 이벤트가 발생할 때 마다 호출해야 한다.

그럼 일반적인 터치 이벤트가 어떻게 전달되는지 확인해보자.

터치 이벤트 시뮬레이션

시뮬레이션을 위해 다음과 같은 레이아웃을 사용한다. 각각의 레이어는 FrameLayout을 상속하며, 터치 이벤트 관련 메소드에서 로그를 출력한다.

가장 녹색 영역이 짙은 Layer3을 터치했을 때의 로그는 다음과 같이 출력된다.

로그를 확인하면 터치 이벤트가 발생했을 때 먼저 Activity에서 dispatchTouchEvent가 호출되고, onInterceptTouchEvent에서 터치 이벤트 인터셉트 여부를 확인하고, 레이아웃 계층을 따라 터치 이벤트가 전파되는 것을 볼 수 있다.

계층을 따라 전파된 터치 이벤트는 타겟에 도달했을 때 TouchEvent를 호출하며, 이벤트를 사용할 때는 타겟 뷰부터 액티비티까지 역순으로 TouchEvent가 호출된다. 또한 이벤트 소모에 대한 처리를 하지 않았기 때문에 후속 이벤트(ACTION_UP)에 대해선 액티비티가 처리하고 있는 것을 볼 수 있다.

그렇다면 이번엔 Layer3의 onTouchEvent에서 true를 반환했을 때의 로그를 확인해보자.

터치 이벤트가 전달 되는 과정은 동일하지만 Layer3의 onTouchEvent에서 true를 반환했기 때문에 후속 이벤트가 Layer3에 전달되는 것을 볼 수 있다.

만약 여기서 Layer1이 터치 이벤트를 인터셉트 한다면 어떻게 될까?

터치 이벤트가 전달되는 과정에서 Layer1에 의해 인터셉트 되었기 때문에 자식 뷰로 전달되지 않고 Layer1의 onTouchEvent가 즉시 호출된다. 하지만 Layer1의 onTouchEvent에서 이벤트 소모에 대한 처리를 하지 않아서 후속 이벤트는 액티비티에 전달되었다.

지금까지 한 일을 잠깐 요약해보자.

자식 뷰의 onTouchEvent에서 true를 반환하면 터치 이벤트가 상위 뷰로 전달되지 않으며, 후속 이벤트를 자식 뷰에서 받을 수 있다.

부모 뷰의 onInterceptTouchEvent에서 true를 반환하면 자식 뷰로 터치 이벤트를 전달하지 않고 부모 뷰에서 해당 이벤트를 처리한다.

그렇다면 View#dispatchTouchEvent에서 반환 값의 의미는 무엇일까?

해당 메소드의 코멘트에서는 다음과 같이 설명한다.

Pass the touch screen motion event down to the target view, or this view if it is the target.

disaptchTouchEvent 메소드는 터치 이벤트를 자식 뷰로 전달하거나, 자신이 뷰가 타겟이라면 자신에게 터치 이벤트를 전달한다고 말한다. 즉, disaptchTouchEvent에서 true를 반환한다는 것은 시스템에게 자신이 터치 이벤트를 받는 대상이라고 알리는 것이 된다.

하지만 여기서 한 가지 알아야 할 점은 disaptchTouchEvent의 반환 값이 사용되는 시점이다.

disaptchTouchEvent 에서는 onInterceptTouchEvent를 호출하고, 자식 뷰에 대해 dispatchTouchEvent를 호출한 다음, onTouchEvent를 호출하고 종료된다. 즉, dispatchTouchEvent의 반환 값은 터치 이벤트가 모든 자식 뷰들에게 한 번씩 전달되고 나서 후속 이벤트가 발생하는 시점에서 의미를 가진다.

이전의 작업들을 지우고 Layer1에서 disaptchTouchEvent가 true를 반환하는 작업만 추가하여 로그를 확인해보자.

로그에서 볼 수 있듯 처음의 터치 이벤트가 자식 뷰들에게 전파된 후 후속 이벤트에 대해서만 Layer1이 처리하고 있다. 따라서 부모 뷰에서 자식 뷰의 터치 이벤트를 가로챈 후 후속 이벤트에 대해서도 부모 뷰가 처리할 수 있도록 구현하려면 부모 뷰에서 onInterceptTouchEvent가 true를 반환하도록 수정할 뿐만 아니라, onTouchEvent 또는 dispatchTouchEvent에서 true를 반환하여 후속 이벤트를 받는 대상이 자신이라는 것을 알려야 한다.

하지만 onInterceptTouchEventdispatchTouchEvent를 단순히 true로 설정한다면 자식 뷰들이 터치 이벤트를 수신할 수 없기 때문에 사용에 주의가 필요하다. 일반적인 경우라면 onTouchEvent에서 처리하는 것이 권장되는 듯 하다.

여기 까지 터치 이벤트가 전달되는 과정을 이해했으면 아래 그림이 어떤 의미를 가지는지 알 수 있다.

https://stackoverflow.com/questions/7449799/how-are-android-touch-events-delivered/46862320#46862320
https://stackoverflow.com/questions/7449799/how-are-android-touch-events-delivered/46862320#46862320

다만 위의 그림에서는 한 가지 오류가 있는데, 가장 아래 View에서 onTouchListener를 처리하는 부분이다.

그림에 따르면 뷰에서 onTouchListener가 설정되어 있는 경우 onTouchEvent 대신 onTouch를 수행하고 부모 뷰로 전달되는데, 실제로는 onTouch에서 false를 반환했을 때 해당 뷰의 onTouchEvent가 실행되고, onTouchEvent에서도 false를 반환했을 때 부모 뷰로 이벤트가 전달된다.

여기에 따르면 다음과 같이 그림을 수정할 수 있다.

https://stackoverflow.com/questions/7449799/how-are-android-touch-events-delivered/46862320#46862320

터치 이벤트의 응용

EditText의 Touch 영역 밖에서 키보드 숨기기

일반적인 UX로 사용자가 EditText 작업 중 다른 영역을 터치하면 소프트 키보드가 숨겨지기를 기대한다. 하지만 이는 EditText가 자체적으로 제공하는 기능이 아니기 때문에 개발자가 해당 기능을 구현해야 한다.

이를 위해서는 모든 터치 이벤트가 액티비티부터 dispatch 된다는 것을 활용할 수 있다.

액티비티에서 터치 이벤트를 수신했을 때 현재 포커스가 EditText에 있고, 터치된 영역이 EditText가 차지하는 영역 밖이라면 소프트 키보드를 숨기는 방식으로 구현할 수 있다.

위의 방식은 몇 가지 문제점이 존재한다. EditText 외부 영역 터치 시 키보드 숨기기 를 확인하길 바란다.

이중 스크롤

vertical 리사이클러 뷰 내부에 horizontal 리사이클러 뷰가 중첩된 구조를 생각해보자(그 반대도 관계 없다).

리사이클러 뷰는 구현 상 현재 스크롤중인 리사이클러 뷰가 터치 이벤트를 수신하게 된다.

좀 더 구체적으로 말해보자면 위의 레이아웃에서 수직 방향으로 스크롤 중인 경우 외부 리사이클러 뷰의 onInterceptTouchEvent에서 true를 반환하기 때문에 스크롤 중의 터치는 외부 리사이클러 뷰에게 전달된다.

내부 리사이클러 뷰의 경우에는 스크롤 중 requestDisallowInterceptTouchEvent를 호출하기 때문에 수평 방향 스크롤 중의 터치가 발생해도 외부 리사이클러 뷰에서 가로챌 수 없다.

이러한 동작은 스크롤이 빠르게 이루어질때 이질감을 줄 수 있다.

https://rubensousa.com/2019/08/16/nested_recyclerview_part1/ 참고

각각의 리사이클러 뷰에서 터치 이벤트를 가로채지 않고 매끄럽게 동작시키기 위해서는 리사이클러 뷰의 onInterceptTouchEvent를 재정의하여 터치 이벤트를 가로채는 로직을 변경해야 한다.

메소드의 구현을 보면 터치 이벤트가 발생했을 때 터치된 포인트를 저장하고, 현재 스크롤중이라면 fake 모션 이벤트를 만들어서 전달한다. (테스트 중 fake 이벤트를 전달할 필요가 없을 듯 하여 단순히 false를 반환하는 것으로 변경해 봤는데 동작에 문제가 없었다.

이후 ACTION_MODE에서 이동된 포인트와 기존의 터치 포인트의 차이를 계산해서 스크롤의 방향을 결정하고, 해당 방향으로 스크롤 할 수 있는지 확인한다.

마지막으로 allowScroll을 확인하여 false인 경우 onInterceptTouchEventfalse를 반환하는데, 이는 수직 방향 스크롤 중 수평 방향으로 스크롤 하거나, 그 반대의 경우 내부 또는 외부의 리사이클러 뷰가 터치 이벤트를 처리해야 하므로 스크롤 중인 리사이클러 뷰에서는 터치 이벤트를 가로채지 않고 넘긴다는 것을 의미한다.

스크롤 뷰와 리사이클러 뷰에서 터치 이벤트가 전달되는 과정은 스크롤 인터셉트라는 영상에서 자세히 설명한 후 솔루션을 제공하고 있다. 하지만 솔루션의 경우 내부 리사이클러 뷰의 스크롤 중엔 수직 스크롤이 동작하지 않아 생략하였다.

참고

터치 이벤트

소프트 키보드

이중 스크롤

--

--