본문 바로가기

Android/개념 및 예제

Android 소리나는 카운트 다운 타이머 만들기 (CountdownTimer + SoundPool)

안녕하세요 이번엔 소리 나는 카운트 다운 타이머를 만들어보겠습니다.

 

구현할 기능

  • 1~60분까지 타이머를 설정할 수 있다.
  • 1초마다 화면을 갱신한다.
  • 상황에 따른 타이머 효과음을 적용한다. (진행 중, 타이머 종료)

 

사용하는 기술

  • CountDownTimer
  • SoundPool
  • SeekBar

 

CountDownTimer

일정한 간격에 대한 정기적인 알림과 함께 미래의 시간까지 카운트 다운을 예약하기 위한 타이머입니다.

다음은 텍스트 필드에 30초 카운트다운을 표시하는 예시입니다.

 object : CountDownTimer(30000, 1000) {

     override fun onTick(millisUntilFinished: Long) {
         mTextField.setText("seconds remaining: " + millisUntilFinished / 1000)
     }

     override fun onFinish() {
         mTextField.setText("done!")
     }
 }.start()

 

CountDownTimer생성자는 다음과 같으며 첫 번째 인자에 밀리세컨드 기준의 미래의 시간이 들어가고 두 번째 인자에 onTick 함수가 발생하기 위한 일정한 시간 값입니다.

CountDownTimer(long millisInFuture, long countDownInterval)

 

SoundPool

SoundPool 클래스는 응용 프로그램의 오디오 리소스를 관리하고 재생합니다.

지원하는 종류는 공식 문서에 나와 있고, 거의 대부분의 음원이라고 생각하면 될 것 같습니다.

음원은 res/raw 아래 위치하면 됩니다.

  • 음원 재생 프로세스

SoundPool을 사용한 오디오 재생은 다음과 같은 순서로 이루어집니다.

SoundPool 생성 -> 음원 로드 -> 음원 재생 -> 음원 재생 중지 -> SoundPool 메모리에서 제거

 

  • play() 함수의 매개변수에 대해 이해하기

음원을 재생하기 위해서는 play함수를 사용해야 합니다. play 함수에는 다음과 같은 매개변수가 있습니다.

이번 예시에서는 1초마다 발생하는 째깍째깍(tick) 사운드는 반복으로, 카운트 다운 타이머가 종료할 때 발생하는 종료 알림 사운드에는 반복을 적용해서 재생할 예정입니다.

soundID int: load() 함수에서 반환되는 soundId
leftVolume float: 왼쪽 볼륨 값 (range = 0.0 to 1.0)
rightVolume float: 오른쪽 볼륨 값 (range = 0.0 to 1.0)
priority int: 우선 순위 (0 = lowest priority)
loop int: 반복 여부 (0 = no loop, -1 = loop forever)
rate float: 재생 속도 (1.0 = normal playback, range 0.5 to 2.0)

 

SeekBar

SeekBar는 ProgressBar의 자식 클래스이고, 드래그 가능한 thumb이 있다는 게 특징입니다. 사용자는 thumb을 터치하고 왼쪽이나 오른쪽으로 드래그하여 현재 진행 수준을 설정하거나 화살표 키를 사용할 수 있습니다.

SeekBar는 사용자의 터치에 따른 이벤트 콜백 리스너가 있습니다.

SeekBar.OnSeekBarChangeListener

  1. onStartTrackingTouch() :  최초에 탭하여 드래그 시작할 때 발생
  2. onProgressChanged() : 드래그 하는 중에 발생
  3. onStopTrackingTouch() : 드래그를 멈출 때 발생

소리 나는 카운트 다운 타이머 구현하기

1. SeekBar View 및 남은 시간 보여줄 텍스트뷰 그리기

기본 SeekBar가 아닌 SeekBar의 주요 속성을 커스텀해서 사용할 것입니다.

  • max="60": 60초의 시간을 정하기 위함
  • progressDrawable="#00000000" : tickMark가 잘 보이기 위해 SeekBar의 선을 나타내는 부분을 투명한 흰색으로 표현합니다.
  • tickMark="@drawable/drawable_tick_mark": 째깍 째깍 지나가는 시간을 표현하는 직사각형 이미지입니다.
  • thumb="@drawable/ic_thumb": 둥근 커서를 나타냅니다. 
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">
    
    <TextView
        android:id="@+id/remainMinutesTextView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="00:"
        android:textColor="@color/black"
        android:textSize="70sp"
        android:textStyle="bold"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintHorizontal_chainStyle="packed"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toLeftOf="@id/remainSecondsTextView"
        app:layout_constraintTop_toTopOf="parent"
        tools:ignore="HardcodedText" />

    <TextView
        android:id="@+id/remainSecondsTextView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="00"
        android:textColor="@color/black"
        android:textSize="70sp"
        android:textStyle="bold"
        app:layout_constraintBaseline_toBaselineOf="@id/remainMinutesTextView"
        app:layout_constraintLeft_toRightOf="@id/remainMinutesTextView"
        app:layout_constraintRight_toRightOf="parent"
        tools:ignore="HardcodedText" />

    <SeekBar
        android:id="@+id/seekBar"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginHorizontal="20dp"
        android:max="60"
        android:progressDrawable="#00000000"
        android:thumb="@drawable/ic_thumb"
        app:tickMark="@drawable/drawable_tick_mark"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toBottomOf="@id/remainMinutesTextView"
        />

</androidx.constraintlayout.widget.ConstraintLayout>

 

* drawable_tick_mark.xml

검은색 직사각형 막대를 나타냅니다.

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">

    <solid android:color="@color/black"/>
    <size android:width="2dp" android:height="5dp"/>

</shape>

 

2. CountDownTimer 개발하기

class MainActivity : AppCompatActivity() {

    private val remainMinutesTextView: TextView by lazy {
        findViewById(R.id.remainMinutesTextView)
    }
    private val remainSecondsTextView: TextView by lazy {
        findViewById(R.id.remainSecondsTextView)
    }
    private val seekBar: SeekBar by lazy {
        findViewById(R.id.seekBar)
    }

    private val soundPool = SoundPool.Builder().build()

    private var currentCountDownTimer: CountDownTimer? = null
    private var tickingSoundId: Int? = null
    private var bellSoundId: Int? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        bindViews()
        initSounds()
    }

    override fun onResume() {
        super.onResume()
        soundPool.autoResume()
    }

    override fun onPause() {
        super.onPause()
        soundPool.autoPause()
    }

    override fun onDestroy() {
        super.onDestroy()
        // sound파일들이 메모리에서 제거된다.
        soundPool.release()
    }

    private fun bindViews() {
        seekBar.setOnSeekBarChangeListener(
            object : SeekBar.OnSeekBarChangeListener{
                override fun onProgressChanged(
                    seekBar: SeekBar?,
                    progress: Int,
                    fromUser: Boolean
                ) {
                    // 사용자가 seek바를 변경한 경우 시간을 변경해준다.
                    if (fromUser){
                    	// progress의 단위는 분이기 때문에
                        updateRemainTime(progress*60*1000L)
                    }
                }

                override fun onStartTrackingTouch(seekBar: SeekBar?) {
                    // 스크롤을 통해 다시 타이머를 시작하는 경우
                    // 현재 카운트 다운을 멈춘다.
                    stopCountDown()

                }

                override fun onStopTrackingTouch(seekBar: SeekBar?) {
                    seekBar?:return
                    // 사용자의 드래그가 멈췄을 경우
                    // 카운트 다운 시작 여부를 결정합니다.
                    if (seekBar.progress ==0){
                        // 0분이면 시작 안함 
                        stopCountDown()
                    }else{
                        // 0분이 아니면 시작함
                        startCountDown()
                    }
                }

            }
        )
    }

    private fun stopCountDown() {
        // 카운트 다운 타이머를 멈추고 사운드 Pool도 멈춥니다.   
        currentCountDownTimer?.cancel()
        currentCountDownTimer = null
        soundPool.autoPause()
    }

    private fun startCountDown(){
        currentCountDownTimer = createCountDownTimer(seekBar.progress*60*1000L)
        currentCountDownTimer?.start()

        tickingSoundId?.let {soundId->
            soundPool.play(soundId,1F,1F,0,-1,1F)
        }
    }

    private fun createCountDownTimer(initialMills:Long) =
        // 1초 마다 호출되도록 함
        object : CountDownTimer(initialMills, 1000L){
            override fun onTick(millisUntilFinished: Long) {
                updateRemainTime(millisUntilFinished)
                updateSeekBar(millisUntilFinished)
            }

            override fun onFinish() {
                completeCountDown()
            }

        }

    private fun completeCountDown(){
        updateRemainTime(0)
        updateSeekBar(0)


        // 끝난 경우
        // 끝난 벨소리 재생함
        soundPool.autoPause()
        bellSoundId?.let {soundId->
            soundPool.play(soundId, 1F,1F,0,0,1F)
        }
    }

    // 기본적으로 함수마다 초의 단위를 통일하는게 좋음. 개발할 때 가독성이 좋음
    private fun updateRemainTime(remainMillis: Long){
        // 총 남은 초
        val remainSeconds = remainMillis/1000

        // 분만 보여줌, 초만 보여줌
        remainMinutesTextView.text = "%02d:".format(remainSeconds/60)
        remainSecondsTextView.text= "%02d".format(remainSeconds%60)

    }

    private fun initSounds() {
        // 사운드 파일을 로드함
        tickingSoundId = soundPool.load(this, R.raw.timer_ticking, 1)
        bellSoundId = soundPool.load(this, R.raw.timer_bell, 1)
    }

    private fun updateSeekBar(remainMillis: Long) {
        // 밀리 세컨드를 분(정수)으로 바꿔서 보여줌
        seekBar.progress = (remainMillis / 1000 / 60).toInt()
    }
}
  1. initSounds를 통해 사운드 파일을 로드하고, bindViews를 통해 SeekBar의 OnSeekBarChangeListener를 설정해줍니다.
  2. createCountDownTimer() 함수를 통해 CountDownTimer 객체를 생성합니다.

주요 로직

1. 사용자가 SeekBar를 드래그로 움직이는 경우

-> 현재 카운트 다운 멈춤, 남은 시간 텍스트 업데이트 해줌

 

2. 사용자가 SeekBar를 드래그로 움직이는 것을 멈춘 경우 (= 카운트 다운 타이머 분 설정 완료)

-> 0분에 설정하면 현재 카운트 다운 멈춤

-> 0분이 아니게 설정한 경우 카운트 다운 시작함 + tick 사운드를 시작합니다.(반복 모드 적용)

 

3. 카운트 다운 타이머가 실행 중인 경우

-> 1초 간격으로 남은 시간 텍스트 업데이트한다. + SeekBar의 thumb 위치도 변경한다.

 

4. 카운트 다운 타이머가 종료된 경우

-> 남은 시간 텍스트와 SeekBar의 thumb 위치를 0으로 설정한다.

-> 현재 진행 중인 tick 사운드를 종료하고 끝났다는 사운드를 재생한다. (1회 적용)

 

5. 사용자가 현재 화면을 벗어난 경우(홈 버튼 등)

-> 카운트 다운 타이머는 계속 진행되고, 사운드만 멈추게 합니다.

-> onPause와 onResume 함수에 사운드 Pool 중지 및 재개 함수 사용함

 


전체 소스는 깃헙에서 확인할 수 있습니다.

https://github.com/keepseung/Android-Blog-Source

 

GitHub - keepseung/Android-Blog-Source: https://develop-writing.tistory.com/ 에서 제공하는 예제

https://develop-writing.tistory.com/ 에서 제공하는 예제. Contribute to keepseung/Android-Blog-Source development by creating an account on GitHub.

github.com