티스토리 뷰

3가지의 중점 기술

  1. ConstraintLayout
  2. CountDownTimer
  3. SoundPool

ConstraintLayout

  • chain

  • seekbak

      <SeekBar
          android:layout_width="0dp"
          android:layout_height="wrap_content"
          android:max="60"
          app:layout_constraintBottom_toBottomOf="parent"
          app:layout_constraintEnd_toEndOf="parent"
          app:layout_constraintStart_toStartOf="parent"
          app:layout_constraintTop_toBottomOf="@id/remainSecondTextView"
          />

    android:max="60" → 최대 60까지 지정할 수 있도록 함.


MainActivity.kt

setOnSeekBarChangeListener는 3개의 인스턴스 메소드인

  1. onProgressChanged
  2. onStartTrackingTouch
  3. onStopTrackingTouch

를 override하여 사용해야 함. 예로 다음과 같은 코드임.

// 각각의 아이디대로 이벤트를 해줄것임
    private fun bindViews() {
        seekBar.setOnSeekBarChangeListener(
                        // 하나의 태그에 여러개의 메소드를 활용해야함으로 object로 넘겨줌
                        // 이때 오브젝트에는 사용되는 태그와 사용할 이벤트를 지정한 후
                        // 객체 형태로 각각의 메소드를 모두 override하게 됨
            object : SeekBar.OnSeekBarChangeListener {
                override fun onProgressChanged(p0: SeekBar?, p1: Int, p2: Boolean) {
                    TODO("Not yet implemented")
                }

                override fun onStartTrackingTouch(p0: SeekBar?) {
                    TODO("Not yet implemented")
                }

                override fun onStopTrackingTouch(p0: SeekBar?) {
                    TODO("Not yet implemented")
                }
            }
        )
    }

본 프로젝트에서는 아래와 같이 사용함

private fun bindViews() {
        seekBar.setOnSeekBarChangeListener(
            object : SeekBar.OnSeekBarChangeListener {
                @SuppressLint("SetTextI18n")
                override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
                    // Long 타입이니까 잘 파악해줘야함.
                    if (fromUser) {
                        updateRemainTime(progress * 60 * 1000L)
                    }
                }

                override fun onStartTrackingTouch(seekBar: SeekBar?) {
                    // 이 곳이 사용자가 새롭게 지정한 카운트 다운이 update 되는 것임
                    // 여기서 이제 새롭게 카운트 다운을 진행하는 순간에 null값이 아닐텐데
                    // null 값이 아니면 취소시키면 됨
                    currentCountDownTimer?.cancel()
                    currentCountDownTimer = null
                }

                override fun onStopTrackingTouch(seekBar: SeekBar?) {
                    // 인자로 받은 seekBar는 nullable하기 때문에 조정해야 함.
                    // nullable하지 않으면 카운트하는게 의미가 없기 때문에
                    // (사용자가 카운트가 감소하는 것을 봐야하기 때문에)
                    seekBar ?: return
                    // 위 코드가 null인지 확인하는 코드임.
                    currentCountDownTimer = createCountDownTimer(seekBar.progress * 1000 * 60)
                    // 이때 바로 아래에도 currentCountDownTimer만 입력하면 빨간줄 생김
                    // 왜냐하면 그 사이에 nullable 해줄 수 있기 때문에
                    // ?로 null 허용해주면 사라짐
                    currentCountDownTimer?.start()

                    // 이 때 tickingSoundId의 경우 null이면 안됨.
                    // 그래서 다음과 같이 하여 문제를 해결함
                    tickingSoundId?.let { soundId ->
                        soundPool.play(soundId, 1F, 1F, 0, -1, 1F)
                    }
                    // null 아니면 soundId로 인자를 넘겨서 실행한다는 의미를 갖고 있음
                }
            }
        )
    }

CountDownTimer

// 공식문서 왈
object : CountDownTimer(30000, 1000) {

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

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

위와 같은 방식으로 CountDownTimer는 onTick, onFinish 를 무조건 사용해야함. 오늘 실습은 아래와 같은 코드로 나옴. onTick은 countDownInterval마다 실행, onFinish는 다 끝나면 실행

private fun createCountDownTimer(initialMillis: Int) =
        object: CountDownTimer(initialMillis.toLong(), 1000L) {
            // countDownInterval 마다 실행
            override fun onTick(millisUntilFinished: Long) {
                updateRemainTime(millisUntilFinished)
                updateSeekBar(millisUntilFinished)
            }
            // initialMillis마다 실행
            override fun onFinish() {
                updateRemainTime(0)
                updateSeekBar(0)
            }

        }

    // initialMillis을 그대로 전달 받기
    // 아래 2개의 함수는 출력값이 없음
    // 그렇기 때문에 따로 출력 타입을 입력하지 않아도 됨.
    private fun updateRemainTime(initialMillis: Long) {
        val remainSeconds = initialMillis / 1000
        remainMinutesTextView.text = "%02d".format(remainSeconds / 60)
        remainSecondTextView.text = "%02d".format(remainSeconds % 60)
    }

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

SoundPool

SoundPool로 사운드 값을 전달함

        // SoundPool의 경우 바로 초기화 시킬 수 있는게 아니기 때문에 build를 해주어야 함.
        // 이거로 음성 전달 하는 것임
        private val soundPool = SoundPool.Builder().build()

사전에 미리 res폴더의 raw안에 timer_tickingtimer_bell 을 넣어줬기 때문에 이를 사용함. 이후 사운드를 사용할 method를 만들어주는데, 이는 다음과 같음

        // 물론 아래의 함수를 사용하기 전에는 tickingSoundId와 bellSoundId를 선언해야 함
        // null값을 허용하면 됨
        // 그리고 Int형임
        private fun initSound() {
        // 아래꺼 저장해야함
        tickingSoundId = soundPool.load(this, R.raw.timer_ticking, 1)
        bellSoundId = soundPool.load(this, R.raw.timer_bell, 1)
    }

아래와 같이 tickingSoundIdbellSoundId를 선언함.

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

또한 음악을 통제하기 위해서 사용하는 method는 onResumeonPause가 있음. 아래와 같이 써주면 앱이 동작하지 않으면, 소리가 작동하지 않음

// 사운드가 계속 나오는 것을 방지하기 위한 코드
    override fun onResume() {
        super.onResume()
        soundPool.autoResume() // 앱을 다시 키면 재개! (단 앱을 끄는게 아니라 홈으로 돌아왔을 때를 말하는 것임)
    }

    override fun onPause() {
        super.onPause()
        soundPool.autoPause() // 멈춰!
    }

추가적으로 앱이 완전히 작동을 멈췄을 때, onDestory를 사용하여 음원을 아에 없애야함. 용량을 많이 차지하기 때문에!!

override fun onDestory() {
        super.onDestory()
        soundPool.release() // 용량 삭제!
}

Design Layout

value 폴더의 colors.xml 파일에 다음과 같은 태그 파일을 추가

<color name="pomodoro_red">#FF8585</color>

해당 컬러 값을 activity_main.xml이라는 파일에 추가

<androidx.constraintlayout.widget.ConstraintLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        ...
        android:background="@color/pomodoro_red"
        ...
</androidx.constraintlayout.widget.ConstraintLayout>

이제 다시 앱을 실행시키면, 흰색 화면이 뜨고 다시 빨간색 화면으로 전환되는 것을 알 수 있음. 이유는 안드로이드의 생명주기상 onCreate가 시작될 때는 아직 View가 완성되지 않았기 때문임. 따라서 activity_maincolor 값을 넣어주면 안됨. 차라리 windowbackground를 변경하는 것이 일관성 측면에서는 좋음.

windowBackground를 건드릴려면, theme.xml 파일로 가서 다음과 같은 태그를 추가함.

<item name="android:windowBackground">@color/pomodoro_red</item>

텍스트 선을 맞추고 싶을 때

스크린샷 2021-12-02 오전 8.01.49.png

지금 위의 사진을 보면 TextView의 size를 다르게 했더니, 글자의 줄이 안 맞는 것을 확인할 수 있다. 이것을 맞추게 해주려면, 초를 나타내는 TextView의 top, bottom의 constraint를 없애고 다음과 같은 코드만 추가시키자.

app:layout_constraintBaseline_toBaselineOf="@id/remainMinutesTextView"

remainMinutesTextView라는 id를 갖는 TextView와 baseline을 같게 한다는 의미이다.

스크린샷 2021-12-02 오전 8.06.55.png

그러면 이제 이렇게 됨

추가적으로 손가락으로 tick 하는 부분 이미지를 바꿨음

<SeekBar
        android:thumb="@drawable/ic_thumb"
        android:tickMark="@drawable/drawable_tick_mark"/>

thumb의 경우 아이콘을 바꾸는 거고 tickMark의 경우 seekBar의 모양을 바꾸는 것임.

이후 하나를 추가해서 토마토를 연상토록 이미지 추가

<ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:src="@drawable/ic_img_tomato_stem"
        app:layout_constraintBottom_toTopOf="@id/remainMinutesTextView"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:ignore="ContentDescription" />

ImageView에서 tools:ignore="ContentDescription" 는 그냥 추가해줍시당 그래야 오류가 사라짐

마지막으로 00:00가 되어도 소리가 안사라지는 것을 확인할 수 있는데, 이를 없애기 위해서는 onStopTrackingTouch메소드 안에 currentCountDownTimer를 추가해주어야 한다. 근데 이건 onStartTrackingTouch와 쓰는 것이 비슷하므로 이를 추상화하여 다음과 같이 코드를 작성한다.

// onStartTrackingTouch 와 onStopTrackingTouch에 카운트 소리가 멈추는 것에 대한 추상화
    private fun stopCountDown() {
        // 이 곳이 사용자가 새롭게 지정한 카운트 다운이 update 되는 것임
        // 여기서 이제 새롭게 카운트 다운을 진행하는 순간에 null값이 아닐텐데
        // null 값이 아니면 취소시키면 됨
        currentCountDownTimer?.cancel()
        currentCountDownTimer = null
        soundPool.autoPause()
    }

MainActivity의 Full Code는 다음과 같다.

class MainActivity : AppCompatActivity() {

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

    private val remainSecondTextView: TextView by lazy {
        findViewById<TextView>(R.id.remainSecondTextView)
    }

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

    // SoundPool의 경우 바로 초기화 시킬 수 있는게 아니기 때문에 build를 해주어야 함.
    // 이거로 음성 전달 하는 것임
    private val soundPool = SoundPool.Builder().build()

    // 액티비트가 실행되자마자 바로 생성되는 것이 아니라, 사용자가 시간을 다시 지정하면서 되어야 하기 때문에
    // 처음에는 null값을 넣어주는 것임
    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()
        initSound()
    }

    // 사운드가 계속 나오는 것을 방지하기 위한 코드
    override fun onResume() {
        super.onResume()
        soundPool.autoResume() // 재개!
    }

    override fun onPause() {
        super.onPause()
        soundPool.autoPause() // 멈춰!
    }

    override fun onDestroy() {
        super.onDestroy()
        soundPool.release() // 파괴!
    }

    // 각각의 아이디대로 이벤트를 해줄것임
    private fun bindViews() {
        seekBar.setOnSeekBarChangeListener(
            object : SeekBar.OnSeekBarChangeListener {
                @SuppressLint("SetTextI18n")
                override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
                    // Long 타입이니까 잘 파악해줘야함.
                    if (fromUser) {
                        updateRemainTime(progress * 60 * 1000L)
                    }
                }

                override fun onStartTrackingTouch(seekBar: SeekBar?) {
                    stopCountDown()
                }

                override fun onStopTrackingTouch(seekBar: SeekBar?) {
                    // 인자로 받은 seekBar는 nullable하기 때문에 조정해야 함.
                    // nullable하지 않으면 카운트하는게 의미가 없기 때문에
                    // (사용자가 카운트가 감소하는 것을 봐야하기 때문에)
                    seekBar ?: return

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

    private fun initSound() {
        // 아래꺼 저장해야함
        tickingSoundId = soundPool.load(this, R.raw.timer_ticking, 1)
        bellSoundId = soundPool.load(this, R.raw.timer_bell, 1)
    }

    // onStopTrackingTouch에 많은 로직이 있어서 가독성을 위한 추상화
    private fun startCountDown() {
        // 위 코드가 null인지 확인하는 코드임.
        currentCountDownTimer = createCountDownTimer(seekBar.progress * 1000 * 60)
        // 이때 바로 아래에도 currentCountDownTimer만 입력하면 빨간줄 생김
        // 왜냐하면 그 사이에 nullable 해줄 수 있기 때문에
        // ?로 null 허용해주면 사라짐
        currentCountDownTimer?.start()

        // 이 때 tickingSoundId의 경우 null이면 안됨.
        // 그래서 다음과 같이 하여 문제를 해결함
        tickingSoundId?.let { soundId ->
            soundPool.play(soundId, 1F, 1F, 0, -1, 1F)
        }
        // null 아니면 soundId로 인자를 넘겨서 실행한다는 의미를 갖고 있음
    }

    // onStartTrackingTouch 와 onStopTrackingTouch에 카운트 소리가 멈추는 것에 대한 추상화
    private fun stopCountDown() {
        // 이 곳이 사용자가 새롭게 지정한 카운트 다운이 update 되는 것임
        // 여기서 이제 새롭게 카운트 다운을 진행하는 순간에 null값이 아닐텐데
        // null 값이 아니면 취소시키면 됨
        currentCountDownTimer?.cancel()
        currentCountDownTimer = null
        soundPool.autoPause()
    }

    private fun createCountDownTimer(initialMillis: Int) =
        object: CountDownTimer(initialMillis.toLong(), 1000L) {
            // countDownInterval 마다 실행
            override fun onTick(millisUntilFinished: Long) {
                updateRemainTime(millisUntilFinished)
                updateSeekBar(millisUntilFinished)
            }
            // initialMillis마다 실행
            override fun onFinish() {
                completeCountDown()
            }

        }

    // initialMillis을 그대로 전달 받기
    // 아래 2개의 함수는 출력값이 없음
    // 그렇기 때문에 따로 출력 타입을 입력하지 않아도 됨.
    private fun updateRemainTime(initialMillis: Long) {
        val remainSeconds = initialMillis / 1000
        remainMinutesTextView.text = "%02d'".format(remainSeconds / 60)
        remainSecondTextView.text = "%02d".format(remainSeconds % 60)
    }

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

    // onFinish의 가독성이 떨어져서 가독성을 위한 추상화
    private fun completeCountDown() {
        updateRemainTime(0)
        updateSeekBar(0)

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