[Android] CustomView & Touch Event

커스텀 뷰 생성하기

안드로이드는 뛰어난 기능의 표준 뷰와 위젯을 많이 제공한다. 그러나 때로는 앱 특유의 비주얼을 보여주는 커스텀(custom) 뷰가 필요하다.

커스텀 뷰에는 여러 종류가 있지만 크게 두 가지 유형으로 분류할 수 있다.

  • 단순(simple) : 단순 뷰는 내부적으로 복잡할 수 있지만, 자식 뷰가 없어서 구조가 간단하다. 대부분 커스텀 렌더링을 수행한다.
  • 복합(composite) : 복합 뷰는 서로 다른 뷰 객체들로 구성된다. 일반적으로 복합 뷰는 자식 뷰들을 관리하지만, 자신은 커스텀 렌더링을 하지 않는다. 대신에 렌더링은 각 자식 뷰에게 위임한다.

커스텀 뷰를 생성하려면 다음의 세 단계를 거친다.

  1. 슈퍼 클래스를 선택한다. 단순 커스텀 뷰에서 View는 비어 있는 캔버스와 같아서 가장 많이 사용된다. 복합 커스텀 뷰에서는 FrameLayout과 같이 적합한 레이아웃 클래스를 선택한다.
  2. 1번에서 선택한 슈퍼 클래스의 서브 클래스를 만들고, 해당 슈퍼 클래스의 생성자를 오버라이드한다.
  3. 슈퍼 클래스의 주요 함수들을 오버라이드해 커스터마이징한다.

BoxDrawingView 생성하기

BoxDrawingView는 단순 뷰이면서 View의 직계 서브 클래스가 된다.

BoxDrawingView라는 이름의 새로운 클래스를 생성하고 View를 슈퍼 클래스로 지정한다. 그리고 BoxDrawingView.kt에서 아래 코드와 같이 생성사를 추가한다. 이 생성자는 Context 객체 및 null이 가능하면서 기본값이 null인 AttributeSet 객체를 인자로 받는다.

BoxDrawingView의 초기 구현 (BoxDrawingView.kt)

1
2
class BoxDrawingView(context: Context, attrs: AttributeSet? = null) : View(context, attrs) {
}

이처럼 AttributeSet에 기본값을 지정하면, 실제로는 두 개의 생성자가 제공된다. 우리 뷰의 인스턴스가 ➀ 코드 또는 ➁ 레이아웃 XML 파일로부터 생성될 수 있어야 하기 때문이다. 레이아웃 파일로부터 인스턴스가 생성되어 초기화되는 뷰는 XML에 지정된 속성들을 포함하는 AttributeSet의 인스턴스를 인자로 받는다.

그다음으로 BoxDrawingView를 사용하도록 res/layout/activity_drag_and_drawing.xml 레이아웃 파일을 변경한다.

BoxDrawingView를 레이아웃에 추가하기 (res/layout/activity_drag_and_drawing.xml)

1
2
3
4
<com.june0122.draganddraw.BoxDrawingView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent" />

여기서는 레이아웃 인플레이터가 찾을 수 있게 BoxDrawingView 클래스가 속한 패키지의 전체 경로를 지정해야 한다. 인플레이터는 View 인스턴스를 생성하는 데 필요한 레이아웃 파일을 찾는다. 이때 요소로 지정된 클래스 이름에 전체 패키지 경로가 지정되지 않으면 인플레이터가 android.view와 android.widget 패키지에서 해당 이름의 클래스를 찾는다. 따라서 해당 클래스가 다른 곳에 있다면 레이아웃 인플레이터는 그것을 찾지 못하고 앱은 실행이 중단된다. 그러므로 android.view와 android.widget 패키지 외부에 있는 커스텀 클래스나 이외의 다른 클래스들에서는 반드시 전체 패키지 경로가 포함된 클래스 이름을 지정해야 한다.

터치 이벤트 처리하기

터치 이벤트를 리스닝할 때는 다음의 View 함수를 사용해서 터치 이벤트 리스너를 설정한다.

1
fun setOnTouchListener(l: View.OnTouchListener)

이 함수는 setOnClickListener(View.OnClickListener)와 같은 방법으로 작동한다. 즉, 함수의 인자로 View.OnClickListener를 구현한 리스너 객체(여기서는 View의 서브 클래스인 BoxDrawingView 인스턴스)를 전달하면 터치 이벤트가 발생할 때마다 이 객체에 구현된 onTouchEvent(…) 함수가 호출된다.

따라서 BoxDrawingView에서는 다음 View 함수를 오버라이드하면 된다.

1
override fun onTouchEvent(evenet: MotionEvent): Boolean

이 함수는 MotionEvent 인스턴스를 인자로 받는다. MotionEvent는 터치 이벤트를 나타내는 클래스이며, 화면을 터치한 위치와 액션(action)을 포함한다. 액션은 다음과 같이 이벤트 발생 단계를 나타낸다.

액션 상수 의미
ACTION_DOWN 사용자가 화면을 손가락으로 터치함
ACTION_MOVE 사용자가 화면 위에서 손가락을 움직임
ACTION_UP 사용자가 화면에서 손가락을 뗌
ACTION_CANCEL 부모 뷰가 터치 이벤트를 가로챔

onTouchEvent(MotionEvent)의 구현 코드에서는 MotionEvent 객체의 다음 함수를 호출해 액션의 값을 확인할 수 있다.

1
final fun getAction(): Int

BoxDrawingView.kt에 아래 코드를 추가하자. 여기서는 이벤트가 제대로 처리되는지 로그캣에서 확인하기 위해 로그 태그 상수와 네 개의 각 액션에 대해 로그 메시지를 출력하는 onTouchEvent(MotionEvent)의 구현 코드도 추가한다.

BoxDrawingView 구현하기 (BoxDrawingView.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private const val TAG = "BoxDrawingView"

class BoxDrawingView(context: Context, attrs: AttributeSet? = null) : View(context, attrs) {

override fun onTouchEvent(event: MotionEvent): Boolean {
val current = PointF(event.x, event.y)
var action = ""
when (event.action) {
MotionEvent.ACTION_DOWN -> {
action = "ACTION_DOWN"
}
MotionEvent.ACTION_MOVE -> {
action = "ACTION_MOVE"
}
MotionEvent.ACTION_UP -> {
action = "ACTION_UP"
}
MotionEvent.ACTION_CANCEL -> {
action = "ACTION_CANCEL"
}
}
Log.i(TAG, "$action at x=${current.x}, y=${current.y}")

return true
}
}

여기서는 터치된 위치를 나타내는 X와 Y 좌표를 PointF 객체에 넣는다. 이 장의 나머지 코드에서 두 값을 같이 사용해야 하기 때문이다. PointF는 이런 역할을 하는 안드로이드의 컨테이너 클래스다.

Logcat 창의 검색 상자에 I/BoxDrawingView를 입력하고 앱을 실행하여 화면을 터치하고 끌어보는 등 상호 작용을 하면 BoxDrawingView가 받는 모든 터치 액션의 X, Y 좌표가 로그에 실시간으로 출력된다.

앱 화면과 상호 작용을 했을 때 로그에 나타나는 X, Y 좌표값들

모션 이벤트 추적하기

BoxDrawingView에서는 좌표만 로깅하는 게 아니라 화면에 박스들도 그릴 것이다. 이렇게 하려면 몇 가지 해결할 것이 있다.

우선 박스를 정의하기 위해 시작 지점(손가락이 처음 놓인 곳)과 현재 지점(손가락이 현재 있는 곳)이 반드시 필요하다.

그다음에 박스를 정의하려면 하나 이상의 MotionEvent로부터 발생하는 데이터를 추적해야하며, 이 데이터를 Box 객체에 저장해야 한다.

하나의 박스를 정의하는 데이터를 나타내는 Box 클래스를 생성해 아래의 코드를 추가한다.

Box 클래스 추가하기 (Box.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Box(val start: PointF) {
var end: PointF = start

val left: Float
get() = min(start.x, end.x)

val right: Float
get() = max(start.x, end.x)

val top: Float
get() = min(start.y, end.y)

val bottom: Float
get() = max(start.y, end.y)
}

사용자가 BoxDrawingView를 터치하면 새로운 Box 객체가 생성되어 기존 박스 List에 추가되도록 하자.

사용자가 그리는 상태 정보를 추적하기 위해 BoxDrawingView 클래스에 새로운 Box 객체를 사용하는 코드를 추가한다.

Box 객체를 사용하는 코드 추가하기 (BoxDrawingView.kt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class BoxDrawingView(context: Context, attrs: AttributeSet? = null) : View(context, attrs) {

private var currentBox: Box? = null
private var boxen = mutableListOf<Box>()

override fun onTouchEvent(event: MotionEvent): Boolean {
val current = PointF(event.x, event.y)
var action = ""
when (event.action) {
MotionEvent.ACTION_DOWN -> {
action = "ACTION_DOWN"
// 그리기 상태를 재설정한다
currentBox = Box(current).also { boxen.add(it) }
}
MotionEvent.ACTION_MOVE -> {
action = "ACTION_MOVE"
updateCurrentBox(current)
}
MotionEvent.ACTION_UP -> {
action = "ACTION_UP"
updateCurrentBox(current)
currentBox = null
}
MotionEvent.ACTION_CANCEL -> {
action = "ACTION_CANCEL"
currentBox = null
}
}
Log.i(TAG, "$action at x=${current.x}, y=${current.y}")

return true
}

private fun updateCurrentBox(current: PointF) {
currentBox?.let {
it.end = current
invalidate()
}
}
}

여기서는 ACTION_DOWN 모션 이벤트를 받을 때마다 currentBox 속성을 새로운 Box 객체로 설정한다. 이 객체는 이벤트가 발생한 위치를 시작 지점으로 가지며 박스 List에 저장된다(본문의 뒤에서 그리기를 구현할 때 BoxDrawingView에서 이 박스 List에 저장된 모든 Box를 화면에 그린다).

사용자의 손가락이 화면을 이동하거나 화면에서 떨어지면 currentBox.end를 변경한다. 그리고 터치가 취소되거나 사용자의 손가락이 화면에서 떨어지면 그리기를 끝내기 위해 currentBox를 null로 변경한다. 즉, Box 객체는 List에 안전하게 저장되지만, 모션 이벤트에 관해서는 더 이상 변경이 생기지 않는다.

updateCurrentBox() 함수에서 invalidate()를 호출한다. invalidate() 함수를 호출하면 뷰가 무효(invalid)라는 것을 안드로이드에게 알려주므로 안드로이드 시스템이 해당 뷰의 변경 사항을 반영해서 다시 그려준다. 여기서는 사용자가 손가락을 움직여서 새로운 박스를 생성하거나 박스 크기를 조정할 때마다 invalidate() 함수를 호출해 BoxDrawingView를 다시 그리게 한다. 이렇게 하면 사용자가 손가락을 끌어서 박스를 생성하는 동안 어떤 모습인지 볼 수 있다.

참고로 앱이 시작되면 앱의 모든 뷰가 무효 상태가 되어 뷰들이 화면에 어떤 것도 그릴 수 없게 된다. 이런 상황을 해결하기 위해 안드로이드는 최상위 수준 Viewdraw() 함수를 호출함으로써 부모 뷰가 자신을 그리게 되고, 이것의 자식 뷰들 또한 자신들을 그리게 된다. 뷰 계층을 따라 내려가면서 자식 뷰들의 또 다른 자식 뷰들도 자신들을 그리게 되는 식이다. 결국 뷰 계층의 모든 뷰가 자신을 그리게 되면 최상위 수준 View는 더 이상 무효 상태가 되지 않는다.

다음으로 박스를 화면에 그려보자.

onDraw(Canvas) 내부에서 렌더링하기

뷰가 화면에 그려지게 하려면 다음 View 함수를 오버라이드해야 한다.

1
protected fun onDraw(canvas: Canvas)

onTouchEvent(MotionEvent)ACTION_MOVE에 대한 응답에서 호출한 invalidate() 함수는 BoxDrawingView를 다시 무효 상태로 만든다. 그럼으로써 BoxDrawingView는 자신을 다시 그리게 되고 이때 onDraw(Canvas)가 다시 호출된다.

이제는 Canvas 매개변수에 대해 알아보자. CanvasPaint 모두 안드로이드의 주요 그리기 클래스다.

  • Canvas 클래스는 모든 그리기 함수를 갖고 있다. 우리가 호출하는 Canvas의 함수들은 그리는 위치와 선, 원, 단어, 사각형 등의 형태를 결정한다.
  • Paint 클래스는 이런 함수들이 어떻게 수행되는지를 결정한다. 즉, 우리가 호출하는 Paint의 함수들은 도형이 채워져야 하는지, 어떤 폰트의 텍스트를 그리는지, 어떤 색의 선인지와 같은 특성을 지정한다.

BoxDrawingView 인스턴스가 초기화될 때 두 개의 Paint 객체를 생성하도록 BoxDrawingView.kt를 변경한다.

Paint 객체 생성하기 (BoxDrawingView.kt)

1
2
3
4
5
6
7
8
9
10
11
12
class BoxDrawingView(context: Context, attrs: AttributeSet? = null) : View(context, attrs) {

private var currentBox: Box? = null
private var boxen = mutableListOf<Box>()
private val boxPaint = Paint().apply {
color = 0x22ff0000.toInt()
}
private val backgroundPaint = Paint().apply {
color = 0xfff8efe0.toInt()
}
...
}

이제는 화면에 박스를 그릴 수 있다.

onDraw(Canvas) 오버라이드 하기 (BoxDrawingView.kt)

1
2
3
4
5
6
7
8
9
10
11
12
class BoxDrawingView(context: Context, attrs: AttributeSet? = null) : View(context, attrs) {
...

override fun onDraw(canvas: Canvas) {
// 배경을 채운다
canvas.drawPaint(backgroundPaint)

boxen.forEach { box ->
canvas.drawRect(box.left, box.top, box.right, box.bottom, boxPaint)
}
}
}

황백색 배경의 Paint를 사용해서 박스의 배경인 캔버스를 채운다. 그다음에 박스 List에 저장된 각 Box 객체에 대해 박스의 두 점을 조사해 직사각형의 왼쪽, 오른쪽, 위, 아래의 꼭지점 위치를 결정한다. 왼쪽과 위의 값은 X와 Y의 최솟값이, 아래쪽과 오른쪽은 최댓값이 된다.

이 값들을 산출한 후 Canvas.drawRect(…)를 호출해 화면에 빨간색의 사각형을 그린다.

궁금증 해소 💁🏻‍♂️ : GestureDetector

터치 이벤트를 처리하는 또 다른 방법으로 GestureDetector 객체가 있다. GestureDetector는 특정 이벤트가 발생하면 알려주는 리스너를 갖고 있다. 예를 들어, GestureDetector.OnGestureListener는 화면을 길게 누르거나 밀거나 스크롤하는 등의 이벤트를 리스닝하는 함수들을 갖고 있다. 그리고 두 번 두드림 이벤트를 리스닝하는 GestureDetector.OnDoubleTapListener도 있다. 대부분은 View의 onTouch(…)onTouchEvent(…) 함수를 오버라이드해서 사용하는 다양한 이벤트 처리가 필요하지 않다. 따라서 이러한 함수 대신 GestureDetector를 사용하는 것도 아주 좋은 방법이다.

댓글