Nesse tutorial vamos aprender a fazer um Timer (Pomodoro) simples com as funções de start, pause e reset usando a classe CountDownTimer para realizar a contagem regressiva.
O que você vai aprender:
- Fazer a base de um contador que pode ser reutilizado em diferentes projetos
- Como utilizar a classe CountDownTimer
- Como formatar em horas um valor
- Utilizar biblioteca externa para customizar Seekbar
- Criar DialogFragment costumizado que também passe dados para uma activity
- Criar shapes com drawable e vetores
- Fazer um progressbar circular
Vamos começar adicionando a Lib do Bubble Seekbar:
//Bubble Seekbar
implementation 'com.xw.repo:bubbleseekbar:3.4-lite'
Deixaremos criar as strings resources que vamos usar na Parte 1:
<resources>
<string name="txt_timer">Timer</string>
<string name="start_pomodoro">START POMODORO</string>
<string name="title_breaks">Number of breaks:</string>
<string name="title_time">Time (minute):</string>
<string name="title_dialog">Set Time</string>
<string name="action_start">START</string>
</resources>
Agora precisamos adicionar um botão no layout do 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"
tools:context=".MainActivity">
<Button
android:id="@+id/btn_start_pomodoro"
android:layout_width="200dp"
android:layout_height="wrap_content"
android:text="@string/start_pomodoro"
android:textStyle="bold"
android:background="@drawable/bg_btn_main"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
Para deixar no botão ais elegante vamos fazer um shape costumizado. Crie um novo Drawable com nome: bg_btn.xml
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="0"
android:startColor="#F0F0F0"
android:endColor="#E9E7E7"/>
<padding
android:bottom="8dp"
android:top="8dp"
android:left="8dp"
android:right="8dp"/>
<stroke
android:width="2dp"
android:color="@color/colorPrimary"/>
</shape>
Para que nosso botão possa chamar o Dialog Personalizado vamos criar nosso layout seekbar_dialog.xml. Aqui usaremos nosso Bubble Seekbar:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginBottom="16dp"
android:layout_marginTop="16dp"
android:text="@string/title_time"/>
<com.xw.repo.BubbleSeekBar
android:id="@+id/seekbar_time"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
app:bsb_bubble_color="@color/colorPrimary"
app:bsb_bubble_text_color="#FFFFFF"
app:bsb_max="50"
app:bsb_min="1"
app:bsb_progress="1"
app:bsb_second_track_color="@color/colorPrimary"
app:bsb_section_count="5"
app:bsb_section_text_position="bottom_sides"
app:bsb_show_progress_in_float="false"
app:bsb_show_section_mark="true"
app:bsb_show_section_text="true"
app:bsb_show_thumb_text="true"
app:bsb_track_color="#D8D8D8"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginBottom="16dp"
android:layout_marginTop="16dp"
android:text="@string/title_breaks"/>
<com.xw.repo.BubbleSeekBar
android:id="@+id/seekbar_breaks"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
app:bsb_bubble_color="@color/colorPrimary"
app:bsb_bubble_text_color="#FFFFFF"
app:bsb_max="10"
app:bsb_min="0"
app:bsb_progress="0"
app:bsb_second_track_color="@color/colorPrimary"
app:bsb_section_count="5"
app:bsb_section_text_position="bottom_sides"
app:bsb_show_progress_in_float="false"
app:bsb_show_section_mark="true"
app:bsb_show_section_text="true"
app:bsb_show_thumb_text="true"
app:bsb_track_color="#D8D8D8"/>
</LinearLayout>
Agora falta criar a classe CostumSeekbar para atribuir ao nosso seekbar_dialog. Essa classe será responsável por pegar os valores dos seekbars na função “onProgressChanged” da classe BubbleSeekbar.OnProgressChangedListener e manda-los para a activity do Timer:
import android.app.AlertDialog
import android.app.Dialog
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import androidx.fragment.app.DialogFragment
import com.xw.repo.BubbleSeekBar
class CustomSeekbar: DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val view = LayoutInflater.from(activity)
.inflate(R.layout.seekbar_dialog, null)
val alert = AlertDialog.Builder(activity)
val seekbarTime: BubbleSeekBar = view.findViewById(R.id.seekbar_time)
val seekbarBreaks:BubbleSeekBar = view.findViewById(R.id.seekbar_breaks)
var time: Int = 1
var breaks: Int = 0
seekbarTime.setOnProgressChangedListener(
object : BubbleSeekBar.OnProgressChangedListener{
override fun onProgressChanged(progress: Int, progressFloat: Float) {
time = progress
}
override fun getProgressOnActionUp(progress: Int, progressFloat: Float) {
}
override fun getProgressOnFinally(progress: Int, progressFloat: Float) {
}
})
seekbarBreaks.setOnProgressChangedListener(
object : BubbleSeekBar.OnProgressChangedListener{
override fun onProgressChanged(progress: Int, progressFloat: Float) {
breaks = progress
}
override fun getProgressOnActionUp(progress: Int, progressFloat: Float) {
}
override fun getProgressOnFinally(progress: Int, progressFloat: Float) {
}
})
with(alert){
setView(view)
setTitle(getString(R.string.title_dialog))
setPositiveButton(getString(R.string.action_start)){ _, _ -> onPositiveClick(time, breaks)}
}
return alert.create()
}
private fun onPositiveClick(time: Int, breaks: Int) {
val i = Intent(activity!!.baseContext, Timer::class.java)
i.putExtra("TIME", time)
i.putExtra("BREAKS", breaks)
startActivity(i)
}
}
Ainda falta criar a activity Timer, mas antes já vamos colocar o botão na MainActivity chamando o DialogFragment:
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Button
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val btn_start_pomodoro:Button = findViewById(R.id.btn_start_pomodoro)
btn_start_pomodoro.setOnClickListener {
val mySeekbarDialog = CustomSeekbar()
mySeekbarDialog.show(supportFragmentManager, "")
}
}
}
Agora crie uma Activity Empty Timer. Com nosso Timer criado o erro na classe CustomSeekbar vai sumir. No layout do Timer teremos um progressbar circular, então vamos criar os Drawables necessários para isso.
Crie o Drawable progressbar_circle_shape.xml que representa o background do progressbar:
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="ring"
android:innerRadiusRatio="2.5"
android:thickness="8dp"
android:useLevel="false">
<solid
android:color="#D8D8D8"/>
</shape>
Crie o Drawable progressbar_circle.xml que é o shape que mostra o progresso no progressbar:
<?xml version="1.0" encoding="utf-8"?>
<rotate xmlns:android="http://schemas.android.com/apk/res/android"
android:fromDegrees="270"
android:toDegrees="270">
<shape
android:innerRadiusRatio="2.5"
android:shape="ring"
android:thickness="7dp"
android:useLevel="true">
<gradient
android:angle="0"
android:endColor="@color/colorPrimary"
android:startColor="@color/colorPrimary"
android:type="sweep"
android:useLevel="false"/>
</shape>
</rotate>
Precisamos também adicionar nossos 3 vetores (ic_play, ic_pause, ic_replay) através do Vector Asset disponível no Android Studio:

Seguiremos para a edição do nosso activity_timer.xml:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 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=".Timer">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/txt_timer"
android:layout_centerHorizontal="true"
android:textSize="35sp"
android:textStyle="bold"/>
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="250dp"
android:layout_height="250dp"
android:layout_centerInParent="true"
android:background="@drawable/progressbar_circle_shape"
android:indeterminate="false"
android:max="100"
android:progress="100"
android:progressDrawable="@drawable/progressbar_circle"
android:textAlignment="center"/>
<TextView
android:id="@+id/txt_total_breaks"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0/10"
android:textSize="18sp"
android:layout_centerHorizontal="true"
android:layout_marginBottom="8dp"
android:layout_above="@+id/txt_time_play"/>
<TextView
android:id="@+id/txt_time_play"
android:layout_width="136dp"
android:layout_height="69dp"
android:layout_centerInParent="true"
android:text="10:00"
android:textSize="45sp"
android:textStyle="bold"
android:textAlignment="center"/>
<ImageView
android:id="@+id/img_replay"
android:layout_width="30dp"
android:layout_height="30dp"
android:src="@drawable/ic_replay"
android:layout_centerHorizontal="true"
android:layout_below="@+id/txt_time_play"/>
<ImageView
android:id="@+id/img_play"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_centerHorizontal="true"
android:src="@drawable/ic_play"
android:layout_below="@+id/progressBar"/>
</RelativeLayout>
Para terminar só falta configurarmos no Timer com contagem regressiva usando a classe do Android CountDownTimer que tem as funções:
- “onTick” = que fará a contagem em milésimos de segundos
- “onFinish” = que será executada ao termino da contagem
Também temos nossa função “updateCountDownText” que fará a conversão do valor recebido para minutos e segundos direto no TextView do Timer. Além das funções de “startTimer“, “pauseTimer” e “resetTimer” na contagem:
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.os.CountDownTimer
import android.view.View
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import java.util.*
class Timer : AppCompatActivity() {
var txt_time: TextView? = null
var txt_breaks: TextView? = null
var img_play:ImageView? = null
var img_replay: ImageView? = null
var progressbar: ProgressBar? = null
var timerLength: Int? = null
var breaksCount: Int = 0
var totalBreaks: Int? = null
//CountDownTimer Control
private enum class TimerStatus{
STARTED, STOPPED
}
//When start activity start playing
private var timerStatus = TimerStatus.STARTED
var timeLeftInMillis:Long = 1 * 60000
lateinit var countDownTimer: CountDownTimer
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_timer)
init()
getData()
img_play!!.setOnClickListener {
if (timerStatus == TimerStatus.STARTED){
pauseTimer()
}else{
startTimer()
}
}
img_replay!!.setOnClickListener {
resetTimer()
}
//start with time and progressbar complete
updateCountDownText()
setProgressBarValues()
}
fun init(){
txt_time = findViewById(R.id.txt_time_play)
txt_breaks = findViewById(R.id.txt_total_breaks)
progressbar = findViewById(R.id.progressBar)
img_play = findViewById(R.id.img_play)
img_replay = findViewById(R.id.img_replay)
}
fun getData(){
timerLength = intent.getIntExtra("TIME", 1)
totalBreaks = intent.getIntExtra("BREAKS", 0)
if (totalBreaks == 0){
txt_breaks!!.visibility = View.GONE
}else{
txt_breaks!!.setText(breaksCount.toString() + "/" + totalBreaks.toString())
}
timeLeftInMillis = (timerLength!! * 60 * 1000).toLong()
startTimer()
}
private fun setProgressBarValues(){
progressbar!!.setMax(timeLeftInMillis.toInt() / 1000)
progressbar!!.setProgress(timeLeftInMillis.toInt() / 1000)
}
private fun startTimer() {
countDownTimer = object : CountDownTimer(timeLeftInMillis, 1000){
override fun onTick(millisUntilFinished: Long) {
timeLeftInMillis = millisUntilFinished
img_play!!.setImageResource(R.drawable.ic_pause)
updateCountDownText()
progressbar!!.setProgress((millisUntilFinished / 1000).toInt())
}
override fun onFinish() {
timerStatus = TimerStatus.STOPPED
resetTimer()
}
}.start()
timerStatus = TimerStatus.STARTED
}
private fun pauseTimer(){
countDownTimer.cancel()
timerStatus = TimerStatus.STOPPED
img_play!!.setImageResource(R.drawable.ic_play)
}
private fun resetTimer(){
pauseTimer()
timeLeftInMillis = (timerLength!! * 60 * 1000).toLong()
setProgressBarValues()
updateCountDownText()
}
private fun updateCountDownText(){
val minutes: Int = (timeLeftInMillis.toInt() / 1000) / 60
val seconds: Int = (timeLeftInMillis.toInt() / 1000) % 60
val timeLeftFormatted: String =
java.lang.String.format(Locale.getDefault(),
"%02d:%02d", minutes, seconds)
txt_time!!.setText(timeLeftFormatted)
}
}
Bom, assim terminamos a parte 1 do nosso tutorial! Na parte 2 faremos a configuração dos intervalos, fazendo assim, nosso app funcionar como um pomodoro!
Até a próxima e caso tenha dúvidas você também ver o vídeo do tutorial no canal do Dev Mundi lincado abaixo: