앱 프로젝트 - 06 - 1 ( 뽀모도로 타이머 ) - CountDownTimer, SoundPool, SeekBar, object ( 익명 클래스 ), 엘비스 연산자 ( ?: ), Theme( 테마 -> 윈도우 속성 건드리기, 앱 전체 NoActionBar, 상태바 색 변경 ), Vector Drawable에 대해서, text의 포멧(format) 설정하기

하이루·2022년 1월 18일
0
post-custom-banner

소개

뽀모도로 타이머를 구현한 앱

  • 1~60분까지 타이머를 설정할 수 있다.

  • 1초마다 화면을 갱신한다

  • 타이머 효과음을 들을 수 있다.

활용 기술

  • ConstraintLayout

  • CountDownTimer

  • SoundPool

////
0. 프로젝트 셋업
1. 기본 UI 구성
2. 타이머 기능 구현
3. 효과음 추가
4. 완성도 높이기


레이아웃 소개


시작하기에 앞서 알고갈 것들

object --> 익명 클래스

해당 글 참고 : https://codechacha.com/ko/kotlin-object-vs-class/

예시 코드


SeekBar

xml부분

1. SeekBar컴포넌트 ( SeekBar의 범위 )

  • max속성을 이용해서 SeekBar의 최대치를 설정할 수 있음
  • min속성을 이용해서 SeekBar의 최저치를 설정할 수 있음
    (범위)
/////////

2. SeekBar컴포넌트 ( SeekBar의 아이콘 )

  • thumb속성을 이용해서 SeekBar의 아이콘을 변경할 수 있음
    ( 해당 아이콘은 Vector Grapic으로 가져온 것 )

3. SeekBar컴포넌트 ( SeekBar의 색 )

  • porgressDrawable속성을 이용해서 SeekBar에 색을 줄 수 있음

--> 다음과 같이 alpha값을 0으로 설정한 색을 넣어서 투명하게 만들어줄 수도 있음
( transparent는 #00000000으로 설정한 내가 만든 색 )

4. SeekBar컴포넌트 ( 눈금 추가 )

  • tickMark속성을 사용하여 눈금을 추가해줄 수도 있음

--> 눈금을 위해 만든 ShapeDrawable파일

kt부분


......

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


seekBar.setOnSeekBarChangeListener(



            object : SeekBar.OnSeekBarChangeListener {

                override fun onProgressChanged(
                    seekBar: SeekBar?,
                    progress: Int,
                    fromUser: Boolean
                ) {

                    if(fromUser) {
                        // 아래에 내가 만들었던 함수인 updateSeekBar() 또한 SeekBar의 progress를 변경시키므로 onProgressChanged()함수를 실행시킨다.
                            // 이 경우 문제가 발생하므로 사용자가 직접 SeekBar를 조작했을 때만 작동하도록 fromUser로 조건문을 달아준 것이다.
                        updateRemainingTime(progress * 60 * 1000L)
                    }
                }

                override fun onStartTrackingTouch(seekBar: SeekBar?) {

                    currentCountDownTimer?.cancel()
                    currentCountDownTimer = null


                }

                override fun onStopTrackingTouch(seekBar: SeekBar?) {

                    seekBar ?: return
//                    ?: --> 좌측에 있는 값이 null일 경우 우측에 있는 값을 반환 혹은 실행함
                    // 엘비스 연산자 ?: --> null일 때 default값을 반환하고 싶은 경우 사용함

                   currentCountDownTimer = createCountDownTimer(seekBar.progress * 60 * 1000L)
                    currentCountDownTimer?.start()
                }
            }
        )


......

위 코드에 object : SeekBar.OnSeekBarChangeListener 는 object 선언을 이용한 익명클래스 객체 구현방식이다. ( class선언과 object선언의 차이 )

익명 클래스란 일회성으로 사용할 객체에 대해 이름 없이 만들어서 바로 사용하는 구현방식을 말한다.
보통 추상클래스나 인터페이스를 구현해서 바로 사용할 때 사용한다.

object : 구현할클래스 { 클래스 구현부 }

 --> 일반적으로 이런 형태를 가진다. ( class로 구현했을 때와 다르게 클래스명이 없다. )
 -> 이 자리에서 바로 사용할 것이기 때문 )

--> OnSeekBarChangeListener 인터페이스를 구현한 익명객체를 만들어서 바로 setOnSeekBarChangeListener의 파라미터의 인자로 사용한 것이다.

object : SeekBar.OnSeekBarChangeListener{}

OnSeekBarChangeListener 인터페이스는 3개의 메소드를 구현해야한다.

1. onProgressChanged( seekBar: SeekBar?, progress: Int, fromUser: Boolean ){}

fun onProgressChanged(
                    seekBar: SeekBar?,
                    progress: Int,
                    fromUser: Boolean
                ){}

--> SeekBar의 내용이 변경되었을 때 해당 메소드가 호출됨
( 사용자 뿐만 아니라 코드상의 변경 또한 해당 메소드를 호출함 )

  • 첫번째 파라미터인 seekBar는 이 이벤트가 발생한 seekBar를 가져옴

  • 두번째 파라미터인 progress는 어느 지점인지 ( SeekBar상의 위치에 따라 int로 가져옴 )

  • 세번째 파라미터인 fromUser는 코드상의 변경사항인지 아니면 사용자가 클릭해서 발생한 변경사항인지 -> 사용자가 클릭해서면 true 반환
    --> 코드상의 변경사항으로 인해 해당 코드가 실행될 경우 문제발생 여지가 많기 때문에
    위의 코드와 같이 사용자의 클릭으로만 발생해야하는 부분은 fromUser의 조건문으로 묶어놓는 것이 좋다.


......

	if(fromUser) {

       // 사용자가 직접 SeekBar를 조작했을 때만 작동하도록 fromUser로 조건문을 달아준 것이다.

           updateRemainingTime(progress * 60 * 1000L)
         }
         
......         
           

2. onStartTrackingTouch(seekBar: SeekBar?) {}

fun onStartTrackingTouch(seekBar: SeekBar?) {}

--> SeekBar를 눌렀을 때 해당 메소드가 호출됨

3. onStopTrackingTouch(seekBar: SeekBar?) {}

fun onStopTrackingTouch(seekBar: SeekBar?) {}

--> SeekBar를 누른 상태에서 뗐을 때 해당 메소드가 호출됨

SeekBar에 리스너에 대한 설명, setOnSeekBarChangeListener와 OnSeekBarChangeLisener

SeekBar에 setOnSeekBarChangeListener() 사용
setOnSeekBarChangeListener()메소드를 타고 들어가보면 파라미터로 OnSeekBarChangeLisener를 받는다는 것을 알 수 있다.

따라서 위의 kt 예제 코드는 OnSeekBarChangeListener 인터페이스를 불러서 구현한 후
setOnSeekBarChangeListener()의 파라미터에 넣는 과정인 것이다.

그리고 OnSeekBarChangeListener 인터페이스를 타고 들어가보면, SeekBar클래스의 내부에 정의되어 있으며,
아래와 같이 3개의 메소드를 구현하도록 되어 있다.

3개의 메소드들을 오버라이드 하여 구현하는 것으로 SeekBar에 대한 ChangeListener를 만들 수 있다.

그리고 위의 코드에서는 이런 구현을 object를 사용하여 익명 클래스로 만들어 해준 것이다.


CountDownTimer


 object : CountDownTimer(30000, 1000) {

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

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

CountDownTimer는 첫번째 파라미터로 CountDown이 될 시간을 받으며(ms),
두번째 파라미터로 CountDown도중에 onTick()함수를 실행시킬 주기를 받는다.(ms)

CountDownTimer는 정해놨던 시간이 완료되면 onFininsh() 메소드를 실행시키며,
중간에 수정은 불가능하고, 새로 생성하는 방법으로 대응해야 한다.


SoundPool

Audio Sound를 재생하고 관리하는 Class

  • Audio 파일을 메모리에 load하고 비교적 빠르게 재생하게 해주는 기능
    --> 매우 긴 Audio 파일을 메모리에 올리기 부담스럽기 때문에 제약이 걸려있음
    되도록이면 짧은 영상만 재생할 수 있게 제약이 걸려있음

오디오파일 넣기

res폴더 안에 raw폴더를 만들고 그 안에 오디오파일을 넣는다. ( mp4 등등 )

SoundPool 사용방법

1. SoundPool.Builder()를 이용해서 SoundPool 설정 및 build()
( 여기서 Builder()의 속성을 설정해줄 수 있음 -> 확장함수처럼 )

 private val soundPool = SoundPool.Builder().build()
 
 
 // 약간 이런 느낌으로 설정가능
      private val soundPool = SoundPool.Builder()
        .setMaxStreams(3)
        .setAudioAttributes("오디오 속성셋")
        .build()
 

2. build()한 soundPool에 사용할 오디오파일들을 load함
오디오 파일들은 미리 res폴더에 raw폴더 만들어서 넣어놔야함

val bellSoundId = soundPool.load(this, R.raw.servicebell, 1)
val doorSoundId = soundPool.load(this, R.raw.slidingdoor, 1)

load()는 SoundPool에서 사용하며

  • 첫번째 파라미터로 현재 앱 위치,

  • 두번째 파라미터로 load할 오디오파일 주소값,

  • 세번째 파라미터로 우선순위( 아직 안드로이드에서 기능구현 X )를 받는다.

또한 load()는 해당 오디오파일의 SoundPoolId를 반환하는데, 해당 오디오파일을 실행(play)할 때 필요하므로 변수에 넣어서 보관

3. SoundPool을 이용하여 load한 오디오파일을 실행(play)


soundPool.play(bellSoundId, 1f, 1f, 0, -1, 1f)

SoundPool의 메소드인 play()는

  • 첫번째 파라미터로 load()를 통해 받은 SoundPoolId를 받고,

  • 두번째 파라미터로 좌측볼륨

  • 세번째 파라미터로 우측볼륨

  • 네번째 파라미터로 우선순위

  • 다섯번째 파라미터로 몇번 반복할지
    (0=반복X, 1,2,3~ = 해당 횟수만큼 더 반복, -1 = 무한반복)

  • 여섯번째 인자로 재생속도를 받는다.

그리고 코드 상에서 load()의 반환값인 SoundPoolId를 담는 변수를 Nullable하게 선언하여 다루는 경우가 많기 때문에 아래와 같이 let함수와 같이 쓰이는 경우가 많다.


private var doorSoundId: Int? = null

......

 doorSoundId?.let {
                        soundPool.play(it, 1f, 1f, 0, -1, 1f)
                    }
     // SoundPoolId값을 담고 있는 doorSoundId변수가 Nullable하므로
     // let함수를 통해 nullsafe를 보장해주고 있다. ( 이렇게 안하면 nullsafe오류 발생 )

이 부분에 대한 필요성은 아래의 예시나, 이 앱의 코드를 보면 알 수 있음

4. pause(), autoPause(), resume(), autoResume(), release() 메소드들을 이용한 오디오 제어


 // pause()의 파라미터로 SoundPoolId값을 받아 해당 오디오파일만 정지
      doorSoundId?.let {
          soundPool.pause(it)
      }
     
 // 해당 SoundPool에 load되어있는 오디오파일 전체 정지
      soundPool.autoPause()
     
      
      
 // resume()의 파라미터로 SoundPoolId값을 받아 해당 오디오파일만 다시 재생     
      doorSoundId?.let {
          soundPool.resume(it)
      }
    
 // 해당 SoundPool에 load되어있는 오디오파일 전체 다시 재생     
      soundPool.autoResume()
   
      
      
 // SoundPool을 통해 메모리에 load해둔 오디오파일들을 모두 릴리즈 ( 최후엔 반드시 해줘야 함 )    
      soundPool.release()     

5. Activity Lifecycle에 맞춰서 SoundPool를 제어 ( 반드시 해줘야함 )


// Activity의 onResume() 메소드를 오버라이드
   override fun onResume() {
        super.onResume()

        soundPool.autoResume()
     
//        doorSoundId?.let {
//            soundPool.resume(it)
//        }

    }
    
    
    
// Activity의 onPause() 메소드를 오버라이드
    override fun onPause() {
        super.onPause()

        soundPool.autoPause()
        
        
//        doorSoundId?.let {
//            soundPool.pause(it)
//        }
    }


// Activity의 onDestroy() 메소드를 오버라이드
    override fun onDestroy() {
        super.onDestroy()
        
        soundPool.release()
        
 // SoundPool은 디바이스 차원에서 관리한다.     
 // 그래서 앱을 종료해도 load한 오디오파일 자체는 메모리에 남아있게 된다.
 // 그러므로 앱을 종료한 시점에서 load한 오디오파일을 모두 날려줘야 한다.

    }

왜 관리해줘야 할까 ??

  • onPause()와 onResume()에서 처리하는 이유
    SoundPool은 디바이스에서 관리하므로, 앱을 벗어나도 계속됨
    따라서 Activity Lifecycle에 따라 처리해줘야함

  • onDestroy()에서 처리하는 이유
    SoundPool의 경우 디바이스 차원에서 관리한다.
    따라서 앱을 종료해도 load한 오디오파일 자체는 메모리에 남아있게 되는데
    오디오파일의 경우 자원소모가 큰 것에 해당한다. 따라서 메모리에 남아있는 것 자체가 큰 자원 낭비다.
    그러므로 앱을 종료하는 시점에 load한 오디오파일을 메모리에서 모두 날려줘야 한다.

SoundPool 예시


......

// SoundPool에 load한 오디오파일의 Id값을 담을 변수
  private var doorSoundId: Int? = null
  
 
// SoundPool 생성(Build) 
  private val soundPool = SoundPool.Builder().build()
   

......

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

        
        initSounds()
        // 내가 만든 메소드
        musicStart()
      
        
            
            
        }
        
        
    }
    
......

// SoundPool에 오디오파일을 load + 반환되는 Id값을 변수에 넣는 함수 생성
    private fun initSounds() {
        doorSoundId = soundPool.load(this, R.raw.slidingdoor, 1)



// Id값을 바탕으로 load한 오디오파일을 구별하여 재생
    private fun musicStart(){
        
        doorSoundId?.let {
            soundPool.play(it, 1f, 1f, 0, -1, 1f)
                       
        }

    }

    }
   
......

// SoundPool 자원 관리

    override fun onResume() {
        super.onResume()

        soundPool.autoResume()

    }

    override fun onPause() {
        super.onPause()

        soundPool.autoPause()

    }

    override fun onDestroy() {
        super.onDestroy()
        
        soundPool.release()

    }
    
    
    

엘비스 연산자 ?: --> NullSafe를 위한 연산자

엘비스란 사람의 머리모양과 비슷하다고 해서 붙여진 이름

"?." 연산자

Nullable한 객체가 무언가를 호출하는 것에 대해 NullSafe를 보장하기 위해 사용되는 연산자

아래와 같이 ?.연산자 앞에 리턴값이 null일때 뒤에 호출되는 부분을 생략시킨다.

위 코드의 b와 같이
"?."의 좌항 리턴값이 null이면 뒤부분이 생략되므로 결과적으로 null이 리턴되게 된다.

--> 여기서 b가 null일 경우에 특정 값이 리턴되면 좋겠다고 생각되는 부분들이 있는데 그 때 아래의 "?:"(엘비스 연산자)를 사용하는 것이다.

"?:" 연산자

Nullable한 객체에 대해 해당 객체가 Null일 때, Null대신 들어갈 default값을 정할 수 있게 해주는 연산자

아래의 코드를 보면 b가 null이므로 default값인 "비었군"이 반환되었다.

이것뿐만 아니라 엘비스 연산자는 우항에 return과 throw까지 올 수 있다.

즉,

메소드에서 좌항이 null이면 해당 메소드를 나간다던가 ( ?: 우항에 return )

메소드에서 좌항이 null이면 해당 메소드에서 오류가 있음을 인지하고 오류 메세지를 띄운다던가 ( ?: 우항에 throw )


Theme( 테마 -> 윈도우 속성 건드리기, 앱 전체 NoActionBar, 상태바 색 변경 )

layout이 나타나는 과정Activity lifecycle의 주기에 따라 먼저 윈도우가 나타나고
거기에 앱의 컴포넌트들이 그려지는 순서
로 이어짐

따라서 layout의 background부분만 색을 칠하게 되면 맨 처음에 윈도우가 나올 시점에
아무것도 없는 흰 화면이 잠깐 나오게 되는 것을 볼 수 있음

그런 부분을 고치기 위해서는 이렇게 manifeset의 application에 세팅되는
테마에서 window에 대한 속성을 변경
해줘야 함

이번 앱의 경우는 layout이 빨간 배경이므로 윈도우도 같은 색으로 칠해서
처음에 윈도우 때문에 앱이 부자연스러워지지 않도록 해준 것임

NoActionBar

해당 Theme의 경우 manifest의 application에 세팅되어 앱 전체에 영향을 미치므로
아래와 같이 해당 부분을 NoActionBar로 해주면 앱 전체에 ActionBar가 없어진다.

상태바

해당 Theme의 이 부분을 바꿔주면 상태바의 색을 변경할 수 있다.

위에 이미지에 있는 targetApi의 경우 lolipop버전 이상이라는 의미인데, 
이미 내가 정해두고 있는 기종이 23버전으로 lolipop이상이다. 따라서 지워줘도 문제없다.

Vector Drawable

안드로이드 스튜디오에서 기본적으로 제공하는 Vector 이미지들

Vector Drawable의 필요성

디바이스들에는 기본적으로 해상도에 따른 밀도의 개념이 있음

  • 해상도가 낮을 경우 작은 이미지로도 충분
  • 해상도가 높다면 그게 맞는 밀도의 이미지로 해주지 않으면 너무 작아져 보임

따라서 기존에는 다중 밀도에 지원하기 위해서 밀도별로 다양한 이미지파일을 준비해서 프로젝트에 넣어야 했음
그러다보니 apk파일의 용량이 너무 커져버리고, 이미지를 공수하는 과정이 많이 까다로웠음

--------> 이것을 해소하기 위해 등장한 것이 Vector Graphic !

Vector Graphic이란

해당 Drawable들이 이미지파일 형식으로 저장되어 있는 것이 아닌
코드의 형식으로 저장되어 있다가 사용될 때 해당 코드를 보고 프로그래밍적으로 그리는 방식으로 작동하는 Drawable이다.

Vector Graphic의 장점들을 보자면

  • 다중 밀도에 하나의 파일로 대응할 수 있음

  • 코드파일이므로 이미지파일에 비해 용량크기가 작다

등이 있다.

하지만 Vector Graphic의 특징상 주의해야하는 부분도 있다.

  • 그리려는 아이콘이 복잡할수록 그릴 때 드는 비용이 늘어나기 때문에 간단한 아이콘을 사용하는 것이 권장된다.

  • 초기 로딩할 때, CPU를 많이 잡아먹으므로 초반에 앱이 느려질 우려가 있다.
    그리므로 200x200dp 사이즈를 넘지 않는 선에서 사용할 것이 권장된다.


Text의 format설정하기


......

private val TextView1 = findViewById<TextView>(R.id.textView1)
private val TextView2 = findViewById<TextView>(R.id.textView2)
private val TextView3 = findViewById<TextView>(R.id.textView3)
private val TextView4 = findViewById<TextView>(R.id.textView4)

private val button1 = fineViewById<Button>(R.id.button1)

private var num = 1

......

button1.setOnClickListener{

num++

TextView1.text = "$num"
// "1", "2", "3", "4"

TextView2 = "%02d".format("$num")
// "01", "02", "03", "04"

TextView3 = "%03d".format("$num")
// "001", "002", "003", "004"

TextView4 = "%02d'".format("$num")
// "01'", "02'", "03'", "04'"



}


  • 위의 코드에서 TextView1의 text는 한자리수는 1, 2, 3 이런식으로 나오고,

  • 위의 코드에서 TextView2의 text는 한자리수는 01, 02, 03 이렇게 나온다.

  • 만약에 "%03d"로 하면 001, 002, 003 이렇게 나온다 ( 대충 이런식임 )

  • 그리고 %02d'" 등과 같이 다른 기호가 들어갈 경우에도 001', 002', 003' 이런 식으로
    해당 기호까지 같이 나타나게 됨
    말 그대로 해당 텍스트에 대해 포멧을 정해주는 것


코드 소개

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<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"
    android:background="@color/pomodoro_red"
    tools:context=".MainActivity">

    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:src="@drawable/ic_baseline_grass_24"
        app:layout_constraintBottom_toTopOf="@+id/remainMinutesTextView"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />


    <TextView
        android:id="@+id/remainMinutesTextView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="00'"
        android:textColor="@color/white"
        android:textSize="120sp"
        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" />
    <!--    chain에 대한 방법론 -> 체인을 건 다음에는 어떤 스타일로 할까 한번 고민해볼 것-->
    <!--    chain에서 가장 왼쪽 위에 있는 컴포넌트가 head임-->


    <TextView
        android:id="@+id/remainSecondsTextView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="00"
        android:textColor="@color/white"
        android:textSize="70sp"
        android:textStyle="bold"
        app:layout_constraintBaseline_toBaselineOf="@+id/remainMinutesTextView"
        app:layout_constraintLeft_toRightOf="@+id/remainMinutesTextView"
        app:layout_constraintRight_toRightOf="parent" />
    <!--    app:layout_constraintBaseline_toBaselineOf="@+id/remainMinutesTextView" 속성을-->
    <!--    이용해서 해당 컴포넌트의 밑부분과 맞추었다 ( 상하를 맞춰야하므로 top, bottom에 대한 제약은 없애버림 )-->

    <SeekBar
        android:id="@+id/seekBar"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginHorizontal="20dp"
        android:max="60"
        android:progressDrawable="@color/transparent"
        android:thumb="@drawable/ic_thumb"
        android: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"
        tools:progress="50" />


</androidx.constraintlayout.widget.ConstraintLayout>


주목해야될 부분

TextView의 app:layout_constraintBaseline_toBaselineOf 속성

app:layout_constraintBaseline_toBaselineOf 속성을 이용해서 다른 컴포넌트의 밑부분과 맞추었다
( 상하를 맞춰야하므로 top, bottom에 대한 제약은 없애야 함 )

SeekBar에 android:layout_marginHorizontal 속성

android:layout_marginHorizontal 속성을 통해 좌우 margin을 동시에 줄 수 있다.
android:layout_marginVertical 속성을 통해 상하 margin을 동시에 줄 수 있다.


MainActivity.kt


package com.example.aop_part2_chapter6

import android.media.SoundPool
import android.os.Bundle
import android.os.CountDownTimer
import android.widget.SeekBar
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity


class MainActivity : AppCompatActivity() {

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

    private val remainSecondTextView: 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 bellSoundId: Int? = null
    private var doorSoundId: Int? = null

    private var currentCountDownTimer: CountDownTimer? = null

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

        bindViews()
        initSounds()
    }

    override fun onResume() {
        super.onResume()

//        doorSoundId?.let {
//            soundPool.resume(it)
//        }

        soundPool.autoResume()
        // pause(),autoPause()등을 통해 정지되어있는 오디오에 대해서만 작동함
        // SoundPool은 디바이스에서 관리하므로, 앱을 벗어나도 계속됨 -> 따라서 Activity Lifecycle에 따라 처리해줘야함
    }

    override fun onPause() {
        super.onPause()

//        doorSoundId?.let {
//            soundPool.pause(it)
//        }

        soundPool.autoPause()
        // SoundPool은 디바이스에서 관리하므로, 앱을 벗어나도 계속됨 -> 따라서 Activity Lifecycle에 따라 처리해줘야함
    }

    override fun onDestroy() {
        super.onDestroy()
        soundPool.release()

    }

    private fun bindViews() {

        seekBar.setOnSeekBarChangeListener(
            object : SeekBar.OnSeekBarChangeListener {
                override fun onProgressChanged(
                    seekBar: SeekBar?,
                    progress: Int,
                    fromUser: Boolean
                ) {
                    if (fromUser) {
                        // 아래에 내가 만들었던 함수인 updateSeekBar() 또한 SeekBar의 progress를 변경시키므로 onProgressChanged()함수를 실행시킨다.
                        // 이 경우 문제가 발생하므로 사용자가 직접 SeekBar를 조작했을 때만 작동하도록 fromUser로 조건문을 달아준 것이다.
                        updateRemainingTime(progress * 60 * 1000L)
                    }
                }

                override fun onStartTrackingTouch(seekBar: SeekBar?) {

                    stopCountDown()

                }

                override fun onStopTrackingTouch(seekBar: SeekBar?) {

                    seekBar ?: return
//                    ?: --> 좌측에 있는 값이 null일 경우 우측에 있는 값을 반환 혹은 실행함
                    // 엘비스 연산자 ?: --> null일 때 default값을 반환하고 싶은 경우 사용함

                    if (seekBar.progress == 0) {
                        stopCountDown()
                    } else {

                        startCountDown()
                    }
                }

                private fun stopCountDown() {
                    currentCountDownTimer?.cancel()
                    currentCountDownTimer = null
                    soundPool.autoPause()
                }

                // 가독성을 위해 onStopTrackingTouch()에 들어갈 내용 모듈화
                private fun startCountDown() {
                    currentCountDownTimer = createCountDownTimer(seekBar.progress * 60 * 1000L)
                    currentCountDownTimer?.start()

                    doorSoundId?.let {
                        soundPool.play(it, 2f, 2f, 0, -1, 1f)

                    }

                }
            }
        )
    }

    private fun initSounds() {
        bellSoundId = soundPool.load(this, R.raw.servicebell, 1)
        doorSoundId = soundPool.load(this, R.raw.slidingdoor, 1)

    }


    private fun createCountDownTimer(initialMillis: Long) =
        object : CountDownTimer(initialMillis, 1000L) {
            // 반환하려는 CountDownTimer는 추상클래스이므로 구현해야될 메소드가 존재한다. -> 따라서 object를 이용해서 익명 클래스로 구현하여 반환하는 것이다.

            override fun onTick(millisUntilFinished: Long) {
                updateRemainingTime(millisUntilFinished)
                updateSeekBar(millisUntilFinished)

            }

            override fun onFinish() {

                completeCountDown()

            }
        }

    // 가독성을 위해 onFinish()에 들어갈 내용을 모듈화
    private fun completeCountDown() {
        updateRemainingTime(0)
        updateSeekBar(0)

        soundPool.autoPause()

        bellSoundId?.let {
            soundPool.play(it, 1f, 1f, 0, 0, 1f)
        }
    }


    // 함수를 만들떄 보낼 파라미터를 통일시켜주는 것이 좋음 -> 가독성이 좋아짐
    private fun updateRemainingTime(remainMilis: Long) {
        val remainSeconds = remainMilis / 1000

        remainMinutesTextView.text = "%02d'".format(remainSeconds / 60)
        remainSecondTextView.text = "%02d".format(remainSeconds % 60)
    }

    private fun updateSeekBar(remainMillis: Long) {
        seekBar.progress = (remainMillis / 1000 / 60).toInt()

    }

}


주목해야될 부분

object -> 익명 클래스 객체를 이용한 SeekBar listener설정 부분


......


        seekBar.setOnSeekBarChangeListener(

            object : SeekBar.OnSeekBarChangeListener {

                override fun onProgressChanged(
                    seekBar: SeekBar?,
                    progress: Int,
                    fromUser: Boolean
                ) {
                    if (fromUser) {

                        updateRemainingTime(progress * 60 * 1000L)
                    }
                }

                override fun onStartTrackingTouch(seekBar: SeekBar?) {

                    stopCountDown()

                }

                override fun onStopTrackingTouch(seekBar: SeekBar?) {

                    seekBar ?: return


                    if (seekBar.progress == 0) {
                        stopCountDown()
                    } else {

                        startCountDown()
                    }
                }

......

seekBar에 setOnSeekBarChangeListener() 사용
setOnSeekBarChangeListener()메소드를 타고 들어가보면 파라미터로 OnSeekBarChangeLisener를 받는다는 것을 알 수 있다.

따라서 위의 코드는 OnSeekBarChangeListener 인터페이스를 불러서 구현한 후 setOnSeekBarChangeListener()의 파라미터에 넣는 과정인 것이다.

그리고 OnSeekBarChangeListener 인터페이스를 타고 들어가보면, SeekBar클래스의 내부에 정의되어 있으며,
아래와 같이 3개의 메소드를 구현하도록 되어 있다.

3개의 메소드들을 오버라이드 하여 구현하는 것으로 SeekBar에 대한 ChangeListener를 만들 수 있다.

////////////////////////

위의 앱의 코드에서 object : SeekBar.OnSeekBarChangeListener{} 부분은
object 선언을 이용한 익명객체 구현방식이다. ( class선언과 object선언의 차이 )

익명객체 구현방식이란 일회성으로 사용할 객체에 대해 이름 없이 만들어서 바로 사용하는 구현방식을 말한다.

object 구현할클래스 { 클래스 구현부 }
--> 일반적으로 이런 형태를 가진다. ( class로 구현했을 떄와 다르게 클래스명이 없다. -> 이 자리에서 바로 사용할 것이기 때문 )

위의 경우 OnSeekBarChangeListener 인터페이스를 구현한 익명객체를 만들어서
바로 setOnSeekBarChangeListener의 파라미터에 인자로 사용한 것이다.


메소드를 만들 때, 리턴부분만 존재할 경우ㅡ, 리턴을 "="을 사용하여 정의 가능


    private fun createCountDownTimer(initialMillis: Long): CountDownTimer{
     
        return object: CountDownTimer(initialMillis, 1000L){
            override fun onTick(millisUntilFinished: Long) {
		//TODO("Not yet implemented")
            }
            override fun onFinish() {
                //TODO("Not yet implemented")
            }
        }
    }

/////////////////////////////////////////////////////////////////////////    

    private fun createCountDownTimer(initialMillis: Long) =
        object : CountDownTimer(initialMillis, 1000L) {

            override fun onTick(millisUntilFinished: Long) {
             //TODO("Not yet implemented")

            }

            override fun onFinish() {
		//TODO("Not yet implemented")

            }
        }

위의 코드에서 두 메소드는 같은 내용을 가진 코드이다.

일반적으로 java에서는 이 위와 같이 return을 해주는데,
kotlin의 경우 리턴부분만 구현하면 될 때 아래와 같이 반환타입선언 없이 "=" 로 표현해줄 수도 있다.


가독성을 위해 모듈화

profile
ㅎㅎ
post-custom-banner

0개의 댓글