[깡쌤의 안드로이드 프로그래밍 with 코틀린][3. 앱의 기본 기능 구현하기] 8. 사용자 이벤트 처리하기
1. 터치와 키 이벤트
터치 이벤트
앱의 화면에서 발생하는 사용자 이벤트는 터치(touch)입니다. 터치란 손가락으로 화면을 잠시 눌렀다가 떼는 행위를 말합니다. 앱은 사용자의 터치를 인식하고 화면을 손가락으로 눌렀는지 떼었는지 스와이프(화면에 손가락을 댄 상태로 쓸어넘기는 동작을 말함) 했는지에 따라 알맞게 동작하도록 구현합니다.
이처럼 앱의 화면에서 발생하는 사용자의 터치 이벤트를 처리하고 싶다면 액티비티 클래스에 터치 이벤트의 콜백 함수인 onTouchEvent()를 선언하면 됩니다. 콜백 함수란 어떤 이벤트가 발생하거나 시점에 도달했을 떄 시스템에서 자동으로 호출하는 함수를 말합니다.
class MainActivity : AppCompatActivity() {
...
override fun onTouchEvent(event : MotionEvent?): Boolean {
return super.onTouchEvent(event)
}
}
액티비티에 onTouchEvent() 함수를 재정의해서 선언만 해놓으면 사용자가 이 액티비티 화면을 터치하는 순간 onTouchEvent() 함수가 자동으로 호출됩니다. onTouchEvent() 함수에 전달되는 매개변수는 MotionEvent 객체이며, 이 객체에 터치의 종류와 발생 지점(좌푯값)이 담깁니다.
터치 이벤트는 다음 3가지로 구분됩니다.
- ACTION_DOWN : 화면을 손가락으로 누른 순간의 이벤트
- ACTION_UP : 화면에서 손가락을 떼는 순간의 이벤트
- ACTION_MOVE : 화면을 손가락으로 누른 채로 이동하는 순간의 이벤트
만약 화면을 손가락으로 살짝 눌렀다가 떼었다면 onTouchEvent() 함수는 2번 호출됩니다. 첫 번째는 ACTION_DOWN 이벤트가, 두 번째는 ACTION_UP 이벤트가 호출됩니다. 만약 화면을 터치해서 손가락을 이동한 후 떼었다면 처음에 ACTION_DOWN 이벤트가 발생하고 이동하면서 ACTION_MOVE 이벤트가 계속 발생하다가 손가락을 떼는 순간 ACTION_UP 이벤트가 발생합니다.
override fun onTouchEvent(event: MotionEvent?): Boolean {
when (event?.action) {
MotionEvent.ACTION_DOWN -> {
Log.d("kkang", "Touch down event")
}
MotionEvent.ACTION_UP -> {
Log.d("kkang", "Touch up event")
}
}
return super.onTouchEvent(event)
}
터치 이벤트 발생 좌표 얻기
터치 이벤트를 처리할 떄에는 이벤트의 종류뿐만 아니라 이벤트가 발생한 지점을 알아야 하는 경우도 있습니다. 이 좌표도 onTouchEvent() 함수의 매개변수인 MotionEvent 객체로 얻습니다.
- x : 이벤트가 발생한 뷰의 X 좌표
- y : 이벤트가 발생한 뷰의 Y 좌표
- rawX : 화면의 X 좌표
- rawY : 화면의 Y 좌표
override fun onTouchEvent(event: MotionEvent?): Boolean {
when (event?.action) {
MotionEvent.ACTION_DOWN -> {
Log.d("kkang", "Touch down event x: ${event.x}, rawX: ${event.rawX}")
}
}
return super.onTouchEvent(event)
}
x와 rawX 모두 좌푯값이지만 의미하는 바는 다릅니다. x는 터치 이벤트가 발생한 뷰에서의 좌푯값이며 rawX는 스크린, 즉 화면에서의 좌푯값입니다.
키 이벤트
키 이벤트는 사용자가 폰의 키를 누르는 순간에 발생합니다. 액티비티에서 키 이벤트를 처리하려면 다음과 같이 콜백 함수를 재정의해야 합니다. 그러면 키 이벤트가 발생할 때 해당 함수가 자동으로 호출됩니다.
- onKeyDown : 키를 누른 순간의 이벤트
- onKeyUp : 키를 떼는 순간의 이벤트
- onKeyLongPress : 키를 오래 누르는 순간의 이벤트
class MainActivity2 : AppCompatActivity() {
...
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
Log.d("kkang", "onKeyDown")
return super.onKeyDown(keyCode, event)
}
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
Log.d("kkang", "onKeyUp")
return super.onKeyUp(keyCode, event)
}
}
키 이벤트 함수의 첫 번째 매개변수는 키의 코드이며 이 값으로 사용자가 어떤 키를 눌렀는지 식별할 수 있습니다.
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
when (keyCode) {
KeyEvent.KEYCODE_0 -> Log.d("kkang", "0 키를 눌렀네요")
KeyEvent.KEYCODE_A -> Log.d("kkang", "A 키를 눌렀네요")
}
return super.onKeyDown(keyCode, event)
}
그런데 이러한 키 이벤트가 발생하는 키는 폰에서 제공하는 소프트 키보드의 키를 의미하지 않습니다. 오른쪽 그림처럼 앱에서 글을 입력할 떄 화면 아래에서 올라오는 키보드를 소프트 키보드(soft keyboard)라고 합니다.
소프트 키보드의 키는 키 이벤트로 처리할 수 없습니다. 즉, 액티비티에 onKeyDown() 등의 함수를 선언해 놓더라도 사용자가 소프트 키보드의 키를 눌렀을 때 이벤트 함수가 호출되지 않습니다. 소프트 키보드는 안드로이드 시스템에 등록된 앱으로서 키를 누르면 글은 입력되지만 키 이벤트는 발생하지 않습니다.
그렇다면 키 이벤트가 필요없지 않을까? 라고 생각할 수도 있지만 그렇지 않습니다. 버튼의 이벤트를 처리하는데에 쓰이기도 하고 안드로이드 하단 네비게이션 바의 뒤로 가기 버튼이나 기기의 불륨 조절 버튼을 다루는 데에도 쓰입니다. 그러나 전원, 홈, 오버뷰 버튼은 액티비티에 onKeyDown() 함수를 선언해 놓아도 사용자가 버튼을 눌렀을 때 함수가 호출되지 않습니다. 즉, 앱에서 이벤트를 처리할 수 없는 버튼입니다.
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
when (keyCode) {
KeyEvent.KEYCODE_BACK -> Log.d("kkang", "BACK button을 눌렀네요")
KeyEvent.KEYCODE_VOLUME_UP -> Log.d("kkang", "Volume Up 키를 눌렀네요")
KeyEvent.KEYCODE_VOLUME_DOWN -> Log.d("kkang", "Volume Down 키를 눌렀네요")
}
return super.onKeyDown(keyCode, event)
}
2. 뷰 이벤트
액티비티의 화면은 TextView, EditText, ImageView, Button 등의 뷰로 화면을 구성하고 구현합니다. 이런 뷰를 사용자가 터치했을 때 이벤트 처리는 앞에서 살펴본 터치 이벤트를 활용하지 않습니다. 각 뷰에서 이벤트를 별도로 제공합니다.
뷰 이벤트의 처리 구조
뷰 이벤트는 일정한 구조에 따라 처리됩니다. 앞에서 다룬 터치 이벤트는 이벤트 콜백 함수인 onTouchEvent()만 액티비티에 선언해 놓으면 처리할 수 있습니다. 또한 키 이벤트는 이벤트 콜백 함수인 onKeyDown()만 액티비티에 선언해 놓아도 이벤트를 처리할 수 있습니다. 그런데 뷰 이벤트는 이벤트 콜백 함수만 선언해서는 처리할 수 없습니다.
뷰 이벤트 처리는 이벤트 소스(event source)와 이벤트 핸들러(event handler)로 역할이 나뉘며 이 둘을 리스너(listener)로 연결해야 이벤트를 처리할 수 있습니다.
- 이벤트 소스 : 이벤트가 발생한 객체
- 이벤트 핸들러 : 이벤트 발생 시 실행할 로직이 구현된 객체
- 리스너 : 이벤트 소스와 이벤트 핸들러를 연결해 주는 함수
즉, 이벤트 소스에 리스너로 이벤트 핸들러를 등록해 놓으면 이벤트가 발생할 때 실행되는 구조입니다. 다음 코드는 체크박스의 체크 상태가 변경될 때 이벤트 처리를 작성한 것입니다. 여기서 checkbox 객체가 이벤트가 발생하는 이벤트 소스이며, 이벤트 처리 내용이 담긴 이벤트 핸들러는 OnCheckedChangeListener 인터페이스를 구현한 객체입니다.
// checkbox : 이벤트 소스
// setOnCheckedChangeListener : 리스터 (이벤트 핸들러 등록)
// object : 이벤트 핸들러
binding.checkbox.setOnCheckedChangeListener(object: CompoundButton.OnCheckedChangeListener {
override fun onCheckedChanged(p0: CompoundButton?, p1: Boolean) {
Log.d("kkang", "체크박스 클릭")
}
}
인터페이스를 구현한 Object 클래스를 이벤트 핸들러로 만들었지만, 액티비티 자체에서 인터페이스를 구현할 수도 있습니다. 또한 이벤트 핸들러를 별도의 클래스로 만들어 처리할 수도 있으며 코틀린의 SAM(Single Abstract Method) 기법을 이용할 수도 있습니다.
(SAM이란 코틀린 코드에서 자바 인터페이스를 간단하게 사용하기 위해 제공하는 기법입니다.)
이 3가지 예를 차례로 살펴보면 다음과 같습니다. 어떻게 작성하든 지정된 인터페이스를 구현한 객체를 이벤트 핸들러로 등록한다는 점은 같습니다.
<액티비티에서 인터페이스를 구현한 예>
class MainActivity3 : AppCompatActivity(), CompoundButton.OnCheckedChangeListener {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityMain3Binding.inflace(layoutInflater)
setContentView(binding.root)
binding.checkbox.setOnCheckedChangeListener(this)
}
override fun onCheckedChanged(p0: CompoundButton?, p1: Boolean) {
Log.d("kkang", "체크박스 클릭")
}
}
<이벤트 핸들러를 별도의 클래스로 만든 예>
class MyEventHandler : CompoundButton.OnCheckedChangeListener {
override fun onCheckedChanged(p0: CompoundButton?, p1: Boolean) {
Log.d("kkang", "체크박스 클릭")
}
}
class MainActivity3 : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityMain3Binding.inflace(layoutInflater)
setContentView(binding.root)
binding.checkbox.setOnCheckedChangeListener(MyEventHandler())
}
}
<SAM 기법으로 구현한 예>
class MainActivity3 : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityMain3Binding.inflace(layoutInflater)
setContentView(binding.root)
binding.checkbox.setOnCheckedChangeListener {
compoundButton, b ->
Log.d("kkang", "체크박스 클릭")
}
}
}
클릭과 롱클릭 이벤트 처리
안드로이드는 앱의 화면을 구성하는데 필요한 다양한 뷰를 제공하며 대부분 뷰에서 자체 이벤트를 제공합니다. 그런데 뷰가 아무리 많아도 이벤트 처리 구조는 같습니다. 그러므로 이벤트 소스와 이벤트 핸들러를 연결하는 구조만 이해한다면 어떤 뷰 이벤트라도 어렵지 않게 처리할 수 있습니다.
여기서는 대표적으로 뷰를 짧게 클릭할 떄 발생하는 ClickEvent와 길게 클릭할 때 발생하는 LongClickEvent를 살펴보겠습니다.
ClickEvent, LongClickEvent 는 뷰의 최상위 클래스인 View에 정의된 이벤트입니다. 즉, 가장 기초이면서 많이 이용하는 이벤트입니다. 두 이벤트의 핸들러는 다음과 같습니다.
- open fun setOnClickListener(l: View.OnClickListener?): Unit
- open fun setOnLongClickListener(l: View.OnLongClickListener?): Unit
ClickEvent는 OnClickListener를 구현한 객체를 이벤트 핸들러로 등록해야 하고 LongClickListener는 OnLongCLickListener를 구현한 객체를 이벤트 핸들러로 등록해야 합니다.
binding.button.setOnClickListener {
Log.d("kkang", "클릭 이벤트")
}
binding.button.setOnLongClickListener {
Log.d("kkang", "롱클릭 이벤트")
true
}
코드 자체는 어렵지 않지만 binding.button.setOnClickListener { } 와 같은 형식이 자바 개발자에게는 조금 낯설게 느껴질 수 있습니다. 앞에서 설명한 뷰 이벤트의 처리 구조를 보면 이벤트 핸들러는 setOnClickListener() 함수로 등록하고 여기에 OnClickListener 인터페이스를 구현한 객체를 지정해야 되는데, 위 코드는 그냥 setOnClickListener 에 람다 함수를 매개변수로 지정한 것처럼 보입니다.
예를 들어 이벤트 핸들러를 자바로 작성한다면 다음처럼 작성할 수 있습니다.
binding.btn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
}
});
이 자바 코드를 그대로 코틀린으로 전환한다면 다음처럼 작성할 수 있습니다.
binding.btn.setOnClickListener(object: View.OnClickListener {
override fun onClick(p0: View?) {
}
})
그런데 코틀린의 SAM 기법을 이용하면 이 코드를 조금 더 간단하게 작성할 수 있습니다. SAM은 자바 API를 코틀린에서 활용할 때 람다 표현식으로 쉽게 이용할 수 있게 해주는 기법입니다. SAM은 단어 뜻 그대로 하나의 추상 함수를 포함하는 인터페이스를 활용하는 방법입니다.
인터페이스가 자바에 작성되어 있고 그 인터페이스를 등록하는 세터함수도 자바에 작성되어 있으면 코틀린에서 세터 함수를 이용해 인터페이스를 구현한 객체를 등록할 때 람다 함수로 쉽게 등록할 수 있습니다. 예를 들어 자바에 다음처럼 선언된 인터페이스가 있다고 가정해 봅시다.
public interface JavaInterface1 {
void callback();
}
그리고 이 인터페이스 타입의 객체를 등록하는 함수도 다음처럼 자바에 선언되었다고 가정해봅시다.
public class SAMTest {
JavaInterface1 callback;
public void setInterface(JavaInterface1 callback) {
this.callback = callback;
}
}
이처럼 자바 함수인 setInterface() 를 코틀린에서 이용하려면 인터페이스를 구현한 객체를 매개변수로 지정해야 합니다. 따라서 다음처럼 작성할 수 있습니다.
obj.setInterface(object: JavaInterface1 {
override fun callback() {
println("hello kotlin")
}
})
그런데 이 코드는 SAM 기법을 이용하면 다음처럼 더 간결하게 작성할 수 있습니다.
obj.setInterface { println("hello SAM") }
물론 자바 인터페이스를 구현한 객체를 모두 SAM 기법으로 이용할 수 있는 것은 아닙니다. 추상 함수 하나를 포함하는 인터페이스만 SAM 기법으로 이용할 수 있습니다. (구현한 함수를 특정하려면 추상함수가 하나만 있어야 하기 때문에 그런것 같다는 생각이 들어요)
코틀린에서는 이러한 SAM 기법을 이용해 많은 이벤트 핸들러 등록 코드를 다음처럼 작성합니다.
binding.btn.setOnClickListener {
...
}
3. 시계 앱의 스톱워치 기능 만들기
스톱워치 앱을 만들기 위해 새 모듈을 만들고 그레들 설정을 해줍니다.
그레들 설정하기
뷰 바인딩 기법을 사용하기 위해 빌드 그레들 파일을 열고 android 부분에 아래처럼 buildFeatures를 추가합니다.
android {
...
buildFeatures {
viewBinding = true
}
}
프로젝트에서 빌드 구성이 변경되면 안드로이드 스튜디오는 프로젝트 파일을 동기화하도록 요청합니다. 편집 창 위 오른쪽에 <Sync Now>를 클릭해 프로젝트 그레들 파일과 동기화합니다.
둥근 버튼 만들기
버튼이 둥근 모양으로 출력되도록 버튼의 백그라운드에 설정되는 XML 파일을 만듭니다. 프로젝트 탐색창에서 [res > drawable] 디렉터리를 마우스 오른쪽 버튼으로 눌러 [New > Drawable Resource File] 메뉴를 선택합니다.
새 리소스 파일 생성 창에서 File name 에 round_button이라고 입력하고 <OK>를 눌러 XML 파일을 만듭니다.
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
android:padding="10dp">
<solid android:color="#6666FF"></solid>
<corners
android:bottomLeftRadius="30dp"
android:bottomRightRadius="30dp"
android:topLeftRadius="30dp"
android:topRightRadius="30dp"></corners>
</shape>
앱 화면 구성하기
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<Chronometer
android:id="@+id/chronometer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="100dp"
android:gravity="center_horizontal"
android:textSize="60dp"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_marginBottom="70dp"
android:gravity="center_horizontal"
android:orientation="horizontal">
<Button
android:id="@+id/startButton"
android:layout_width="100dp"
android:layout_height="wrap_content"
android:text="Start"
android:textColor="#FFFFFF"
android:textStyle="bold"/>
<Button
android:id="@+id/stopButton"
android:layout_width="100dp"
android:layout_height="wrap_content"
android:layout_marginLeft="25dp"
android:enabled="false"
android:text="Stop"
android:textColor="#FFFFFF"
android:textStyle="bold"/>
<Button
android:id="@+id/resetButton"
android:layout_width="100dp"
android:layout_height="wrap_content"
android:layout_marginLeft="25dp"
android:enabled="false"
android:text="Reset"
android:textColor="#FFFFFF"
android:textStyle="bold"/>
</LinearLayout>
</RelativeLayout>
메인 액티비티 작성하기
package com.example.myapplication3
import android.os.Bundle
import android.os.SystemClock
import android.view.KeyEvent
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.example.myapplication3.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
// 뒤로가기 버튼을 누른 시각을 저장하는 속성
var initTime = 0L
// 멈춘 시각을 저장하는 속성
var pauseTime = 0L
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.startButton.setOnClickListener {
binding.chronometer.base = SystemClock.elapsedRealtime()+pauseTime
binding.chronometer.start()
// 버튼 표시 여부 조정
binding.stopButton.isEnabled=true
binding.resetButton.isEnabled=true
binding.startButton.isEnabled=false
}
binding.stopButton.setOnClickListener {
pauseTime=binding.chronometer.base - SystemClock.elapsedRealtime()
binding.chronometer.stop()
binding.stopButton.isEnabled=false
binding.resetButton.isEnabled=true
binding.startButton.isEnabled=true
}
binding.resetButton.setOnClickListener {
pauseTime = 0L
binding.chronometer.base = SystemClock.elapsedRealtime()
binding.chronometer.stop()
binding.stopButton.isEnabled=false
binding.resetButton.isEnabled=false
binding.startButton.isEnabled=true
}
}
// 뒤로가기 버튼 이벤트 핸들러
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
// 뒤로가기 버튼을 눌렀을 때 처리
if(keyCode == KeyEvent.KEYCODE_BACK){
// 뒤로가기 버튼을 처음 눌렀거나 누른 지 3초가 지났을 때 처리
if(System.currentTimeMillis() - initTime > 3000){
Toast.makeText(this, "종료하려면 한 번 더 누르세요!!", Toast.LENGTH_SHORT).show()
initTime = System.currentTimeMillis()
return true
}
}
return super.onKeyDown(keyCode, event)
}
}
이제 앱을 실행하면 스톱워치 기능이 구현된 앱을 확인할 수 있습니다.