1. 코틀린으로 뽀모도로(Pomodoro) 타이머 안드로이드 앱 만들기
코틀린으로 안드로이드 뽀모도로 타이머를 만들어보고 사용한 주요 기능에 대해 포스팅으로 기록하도록 하겠습니다. 기본적인 개념에 대해서는 스킵하고 특징되는 부분의 기술들에 대해서 정리하려고합니다.
주요 기능
- 사용자가 슬라이더 바를 통해 타이머를 설정 (최대 60분 타이머)
- 카운트 다운이 진행 될 동안 Ticking 사운드 재생
- 카운트 완료시 Belling
- 토마토 느낌의 백그라운드
주요 사용 기술
- ConstraintLayout
- CountDownTimer
- SoundPool
- 오디오 사운드를 재생 및 관리
- 오디오 파일을 메모리에 로드하고 빠른 실행이 가능하게 한다.
1.1. 기본 레이아웃 (Main Activity)
메인 레이아웃은 토마토 꼭지 이미지뷰, 시/분을 나타내는 텍스트 박스 그리고 타이머를 설정할 수 있는 슬라이더바 로 구성되어 있습니다.
이때 분 옆에 있는 초 텍스트뷰의 정렬은 layout_constraintBaseline_toBaselineOf 속성을 사용해서 분의 해당하는 텍스트뷰의 베이스 라인으로 정렬하도록 설정해주는 부분입니다.
app:layout_constraintBaseline_toBaselineOf="@id/remainMinutesTextView"
1.2. SeekBar 디자인 커스터마이징
디자인된 SeekBar를 보시면 기본적인 SeekBar와는 다르게 눈금이 있고 움직이는 Thumb 또한 모양이 다른 것을 알 수 있습니다. 이는 개발자가 따로 지정해주어 변경된 모습인데 이처럼 디자인을 커스터마이징 하는 방법을 알아보겠습니다.
틱 마크
먼저 틱 마크입니다. 이는 그저 눈금자 형태로 drawable 자원을 추가해서 하얀색의 사각형을 나타내는 모양으로 정의해주시면 됩니다.
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/white"/>
<size android:width="2dp" android:height="5dp"/>
</shape>
Thumb
Thumb의 경우에는 안드로이드가 가지고 있는 기본 Vector Asset 중에 Baseline unfold를 추가해서 그대로 사용했습니다.
이후 xml 코드상에서 tickMark, thumb 속성을 우리가 추가해준 자원으로 각각 설정해주면 해당되는 모습으로 SeekBar가 표시되는 것을 디자인 창에서 확인할 수 있습니다.
2. 뷰 바인딩 하기
먼저 시간(분)을 나타내는 텍스트 뷰와 SeekBar를 바인딩 할 필요가 있습니다. 최대치를 60으로 설정해두었으니 60분이 최대가 되겠고, 슬라이더가 이동될 때마다 해당되는 분으로 표시되도돌 바인딩해야합니다.
또, 타이머가 진행 될 때마다 시간(분) 텍스트뷰와 슬라이더바 또한 바인딩 되도록 해야합니다. 이에 대해서 아래와 같이 코드를 작성합니다.
private fun bindViews() {
// 각각의 뷰에대한 리스너와 코드를 연결
seekBar.setOnSeekBarChangeListener(
//object로 선언하면 클래스 선언과 동시에 객체가 생성됩니다.
//object 객체 역시 다른 class를 상속하거나 interface를 구현할 수 있습니다.
object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(
seekBar: SeekBar?,
progress: Int,
fromUser: Boolean
) {
if (fromUser) { // updateSeekBar 에서 변경되는 경우도 있기때문에 유저가 만질때만.
// 프로그레스바를 조정하고 있으면 초를 0으로 맞춰주기 위해 추가 (텍스트뷰 갱신)
updateRemainTimes(progress * 60 * 1000L)
}
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {
// 조정하기 시작하면 기존 타이머가 있을 때 cancel 후 null
stopCountDown()
}
override fun onStopTrackingTouch(seekBar: SeekBar?) {
seekBar ?: return
if (seekBar.progress == 0) {
stopCountDown()
} else {
startCountDown()
}
}
}
)
}
이때 SeekBar의 이벤트 리스너로 object 로 선언하여 만들어준 객체를 지정하게 됩니다. object로 선언하게 되면 해당 이벤트 리스너가 지정해둔 인터페이스를 우리가 알맞게 재정의 해줌으로써 이벤트 리스너를 설정하게됩니다.
각각 onProgressChanged, onStartTrackingTouch, onStopTrackingTouch 인터페이스 함수를 재정의 해주어야하며 이는 각각 프로그레스가 바뀔때, 터치가 이뤄질 때, 변경 터치가 멈춘 경우에 해당하는 동작을 작성하게 됩니다.
onProgressChanged 에서 유저가 Thumb를 움직인 경우 해당하는 progress를 남은 시간으로 재설정하는 코드를 넣어줍니다. 이로써 사용자가 조작 시 알맞게 뷰 바인딩이 이뤄지게 됩니다.
그 외 onStartTrackingTouch, onStopTrackingTouch 내부에 있는 기능들로는 onStartTrackingTouch 에서는 조정 시 새로운 타이머를 동작시키기 위해서 카운트 다운을 멈추고, onStopTrackingTouch 에서 터치가 멈춘 경우 새로운 타이머를 시작하도록 합니다.
onStopTrackingTouch 에서는 seekBar가 널가능 변수이기 때문에 엘비스연산(?:) 을 통해 널 검사가 필요합니다. 그 후 null 이 아닌 경우에는 사용자가 설정한 시간이 0이 아닐 때 타이머를 시작합니다.
자세한건 캡슐화 되어 각각의 기능을 하는 함수로 되어있어 아래에서 자세한 코드를 보도록 하겠습니다. 참고로 터치를 시작할 때 기존 타이머를 죽이고 새로 시작해야 계속 타이머를 생성하는 일이 없기 때문에 이같이 작성하게 되는것입니다.
private fun updateRemainTimes(remainMillis: Long) {
val remainSeconds = remainMillis / 1000
remainMinutesTextView.text = "%02d'".format(remainSeconds / 60)
remainSecondsTextView.text = "%02d".format(remainSeconds % 60)
}
먼저 updateRemainTimes 함수를 보겠습니다. 매개변수로 남은 시간을 밀리초로 받으며 받은 밀리초를 바로 시간(분, 초) 텍스트 뷰에 할당해주는 기능을 합니다.
2.1. CountDownTimer
카운트 다운 타이머를 통해서 타이머를 사용하는 부분입니다. 사용자가 Thumb에서 손을 떼는 순간 타이머가 시작되도록 구현하였습니다.
private fun startCountDown() {
// 사용자가 바에서 손을 떼는 순간 새로운 타이머 생성
currentCountDownTImer = createCountDownTimer(seekBar.progress * 60 * 1000L)
currentCountDownTImer?.start()
// 소리 재생 (null 아닌 경우 사운드 재생)
// 디바이스 자체에 요청하는 거기 때문에 화면 종료시 계속 재생될 수 있음
// 생명주기이 따라 처리 필요.
tickingSoundId?.let { soundId ->
soundPool.play(soundId, 1F, 1F, 0, -1, 1F)
}
}
먼저 startCountDown()입니다. 사용자가 바에서 손을 떼는 순간 새 타이머를 생성하여 start() 하게됩니다. 이때 ticking 사운드를 재생하는데 이는 soundPool 을 사용합니다. (soundPool 에 대해서는 잠시후 살펴봅니다.)
private fun createCountDownTimer(initialMillis: Long): CountDownTimer =
object : CountDownTimer(initialMillis, 1000L) {
override fun onTick(millisUntilFinished: Long) {
updateRemainTimes(millisUntilFinished)
updateSeekBar(millisUntilFinished)
}
override fun onFinish() {
completeCountDown()
}
}
createCountDownTimer 에서는 넘겨준 밀리초만큼 타이머를 설정하고 onTick 에서 타이머가 작동 할 때 어떤 동작을 할지 설정하고 (남은 시간과 SeekBar를 업데이트 해주는 기능). onFinish 에서는 카운트 다운 완료 처리 함수를 호출합니다.
private fun updateSeekBar(remainMillis: Long) {
seekBar.progress = (remainMillis / 1000 / 60).toInt() // 분
}
updateRemainTimes() 함수는 위에서 살펴보았으니 updateSeekBar() 함수만 살펴보면, 넘겨준 밀리초를 바탕으로 몇분이 남았는지 계산하여 SeekBar의 progress를 재설정 해주고 있습니다.
private fun completeCountDown() {
updateRemainTimes(0)
updateSeekBar(0)
soundPool.autoPause()
bellSoundId?.let { soundId ->
soundPool.play(soundId, 1F, 1F, 0, 0, 1F)
}
}
completeCoundDown() 에서는 타이머가 종료되었으므로 텍스트뷰를 0으로 초기화하고 ticking 사운드를 중지, bell을 울리는 기능을 합니다. 이때 let문을 사용해서 bellSoundId가 null 이 아닌 경우 해당 객체를 받아서 동작을 수행하도록 했습니다.
private fun stopCountDown() {
currentCountDownTImer?.cancel()
currentCountDownTImer = null
soundPool.autoPause()
}
타이머를 start 하는 함수가 있으니 stop하는 함수도 있어야 겠습니다. stopCoundDown() 에서는 타이머를 cancel 시키고 null 을 할당합니다. ticking 사운드로 정지하게됩니다. 이때 autoPause() 를 사용한 이유는 재생되고 있는 모든 사운드를 자동으로 중지시키기 위해서입니다.
3. SoundPool 로 효과음 사용하기
안드로이드에서는 SoundPool 이라는 기능을 제공해서 비교적 큰 메모리를 차지 않하는 사운드를 빠르게 재생시켜주어 효과음 등을 딜레이없이 재생시키는 기능을 제공하고 있습니다.
private val soundPool = SoundPool.Builder().build()
private fun initSounds() {
// sound 로드
tickingSoundId = soundPool.load(this, R.raw.timer_ticking, 1)
bellSoundId = soundPool.load(this, R.raw.timer_bell, 1)
}
전역으로 soundPool을 선언하고 onCreate 에서 init하는 방식으로 사용했습니다. SoundPool은 위 코드와 같이 Builder를 통해 빌드하게 되고 각각 사운드는 load 매서드를 통해서 직접 추가한 mp4 확장 파일을 불러오도록 했습니다.
load의 마지막 인자인 우선순위의 경우에는 해당 프로젝트에서 사용하지 않을 거라 그냥 모두 1로 설정해주었습니다. load() 의 경우 호출시 SoundID를 반환하므로 이를 받아서 각각 전역 변수에 저장해서 사용하겠습니다.
tickingSoundId?.let { soundId ->
soundPool.play(soundId, 1F, 1F, 0, -1, 1F)
}
soundPool.autoPause()
그리고 앞서 보인 코드에서 보았다시피 play와 autoPause 를 사용해서 사운드를 재생시키거나 일시정지 시켜서 ticking 사운드를 재생할 수 있습니다. play 함수 인자로는 재생속도, 좌우 볼륨 등을 지정할 수도 있습니다.
3.1. 생명 주기에 따른 사운드 관리
Ticking 사운드 및 bell 사운드를 재생할 때 다른 앱으로 전환 하거나 홈으로 나갔을 때 등등 전환이 일어날 경우에는 사운드가 재생되지 않도록 하고 다시 뽀모도로 타이머를 켰을 때만 재개하도록 하는 처리가 필요합니다.
그래서 생명주기에 따라서 사운드풀 관리가 필요합니다.
override fun onResume() {
super.onResume()
// 앱이 다시 시작되는 경우
soundPool.autoResume()
}
override fun onPause() {
super.onPause()
// 앱이 화면에 보이지 않을 경우
//soundPool.pause() // 특정 스트림 아이디로 정지
soundPool.autoPause() // 모든 활성 스트림 정지
}
override fun onDestroy() {
super.onDestroy()
soundPool.release() // 더이상 필요 없으면 사운드풀 메모리에서 해제
}
앱이 화면에 보이지 않는 경우 onPause()가 실행 되며 이때 여기에 모든 사운드가 정지되도록 설정합니다. onResume() 은 앱이 다시 재개되는 경우인데 이때는 사운드도 또한 다시 재생되도록 설정합니다.
마지막으로 onDestory() 에서 앱이 완전히 종료되어 사용되지 않을 때 사운드를 release 시켜서 메모리에서 해제해주도록 합니다.
프로젝트에 사용된 코드는 저의 깃허브 저장소에서 확인하실 수 있습니다.
2021.05.18 - [Android/App] - [Android] simple 전자 액자 어플 만들기 with Kotlin
2021.05.03 - [Android/App] - [안드로이드] 비밀 일기장 어플 다이어리 앱 만들기 (Kotlin)
'Android > App' 카테고리의 다른 글
[Android] 심플 웹 브라우저 만들어보기 with Kotlin (0) | 2021.05.31 |
---|---|
[Android] 음성 녹음기 어플 만들어보기 with Kotlin (1) | 2021.05.26 |
[Android] simple 전자 액자 어플 만들기 with Kotlin (0) | 2021.05.18 |
[안드로이드] 계산기 어플 앱 만들기 with 코틀린 (0) | 2021.05.07 |
[안드로이드] 비밀 일기장 어플 다이어리 앱 만들기 (Kotlin) (0) | 2021.05.03 |