Datas no Android com o MaterialDatePicker
Projeto de exemplo
Para este artigo, vou usar o projeto Eventos, um App Android para cadastrar eventos com um título e data do evento. Você pode acessar o código fonte do projeto neste repositório do GitHub ou baixá-lo caso queira simular os exemplos.
Estrutura do App
Na implementação do App, temos um RecyclerView que apresenta cada evento cadastrado. Para cadastrar o evento, clicamos no FAB (FloatingActionButton
), preenchemos o formulário com título e data e clicamos em Salvar.
O grande detalhe da implementação, é que o usuário é responsável em escrever a data com o formato esperado, o que pode ocasionar em dúvidas ou frustrações...
No caso desta implementação, um formato inesperado quebra o App! Uma das piores experiências para o usuário... Sendo assim, precisamos evitar ao máximo esse comportamento e podemos considerar as seguintes técnicas na regra de negócio:
- Realizar tratamentos com try-catch;
- Implementar validações para orientar o formato correto para o usuário.
Ambas as técnicas são comuns na maioria das linguagens, porém, dado que estamos no ambiente Android, podemos também utilizar um componente que auxilie na parte visual, o MaterialDatePicker:
Adicionando os componentes do Material Design
Para utilizar o MaterialDatePicker
, o projeto precisa da dependência dos componentes do Material Design:
dependencies {
// outras dependências
implementation 'com.google.android.material:material:1.3.0'
}
O projeto fornecido já tem a dependência configurada!
Após sincronizar, temos acesso ao MaterialDatePicker
.
Criando o MaterialDatePicker
Para utilizar o MaterialDatePicker
, temos uma abordagem similar ao AlertDialog, ou seja, um builder para nos auxiliar:
class ListaEventosActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding.recyclerview.adapter = adapter
configuraFAB()
val selecionadorDeData = MaterialDatePicker
.Builder.datePicker().build()
}
// restante do código
}
selecionadorDeData.show(supportFragmentManager, "MATERIAL_DATE_PICKER")
Com apenas esse código, o usuário é capaz de selecionar a data desejada e confirmar. Porém, o código não é o suficiente para obtermos a data selecionada.
Pegando a data do MaterialDatePicker
Para pegar a data selecionada do MaterialDatePicker
, precisamos adicionar um listener no dialog com a função addOnPositiveButtonClickListener()
que recebe uma expressão lambda com o valor da data em milisegundos:
selecionadorDeData
.addOnPositiveButtonClickListener { dataEmMilisegundos ->
Log.i("MaterialDatePicker", "data em milisegundos: $dataEmMilisegundos")
}
Então temos o seguinte resultado selecionando a data 23/02/2021:
2021-02-23 10:41:45.392 16915-16915/br.com.alura.eventos I/MaterialDatePicker: data em milisegundos: 1614038400000
A partir do valor em milisegundos, podemos converter para uma data com uma API disponível, como por exemplo, as classes do pacote java.time que oferece a Instant
capaz de fazer isso:
val data = Instant.ofEpochMilli(dataEmMilisegundos)
.atZone(ZoneId.of("America/Sao_Paulo"))
.withZoneSameInstant(ZoneId.ofOffset("UTC", ZoneOffset.UTC))
.toLocalDate()
Log.i("MaterialDatePicker", "data com LocalDate: $data")
Basicamente, criamos uma instância de Instant
por meio dos milisegundos, então configuramos o time zone da America São Paulo com deslocamento em UTC e convertemos para a API LocalDate
que facilita o trabalho de datas...
Depois de todo esse código assustador para fazer uma conversão, temos a data esperada:
2021-02-23 11:44:20.465 17940-17940/br.com.alura.eventos I/MaterialDatePicker: data com LocalDate: 2021-02-23
Com a data pronta, precisamos integrar a nossa solução com o fluxo do App.
Adicionado o MaterialDatePicker
no formulário
No formulário, precisamos chamar o MaterialDatePicker
ao clicar no campo de data, portanto, podemos adicionar todo o nosso código dentro do listener de clique do campo de data:
class FormEventoDialog(private val context: Context) {
fun show(
supportFragmentManager: FragmentManager,
quandoEventoCriado: (eventoCriado: Evento) -> Unit
) {
val binding = FormEventoBinding
.inflate(LayoutInflater.from(context))
binding.data.setOnClickListener {
val selecionadorDeData = MaterialDatePicker
.Builder.datePicker().build()
selecionadorDeData.show(supportFragmentManager, "MATERIAL_DATE_PICKER")
selecionadorDeData
.addOnPositiveButtonClickListener { dataEmMilisegundos ->
val data = Instant.ofEpochMilli(dataEmMilisegundos)
.atZone(ZoneId.of("America/Sao_Paulo"))
.withZoneSameInstant(ZoneId.ofOffset("UTC", ZoneOffset.UTC))
.toLocalDate()
Log.i("MaterialDatePicker", "data com LocalDate: $data")
}
}
// restante do código
}
}
Note que ao mover o código, precisamos também adicionar o FragmentManager
como parâmetro do método show()
da FormEventoDialog
. Na Activity, no listener do FAB, precisamos também enviar o FragmentManager
como argumento:
class ListaEventosActivity : AppCompatActivity() {
// restante do código
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding.recyclerview.adapter = adapter
configuraFAB()
}
private fun configuraFAB() {
binding.floatingActionButton.setOnClickListener {
FormEventoDialog(this)
.show(supportFragmentManager) { eventoCriado ->
dao.salva(eventoCriado)
adapter.atualiza(dao.eventos)
}
}
}
}
Esse código é o suficiente para testar o App e abrir o MaterialDatePicker
clicando no campo de data.
Por mais que funcione, a experiência do usuário não é bacana, pois precisamos de um segundo clique após focar no campo de data para apresentar o MaterialDatePicker
!
Melhorando a experiência de clique no campo de data
Para evitar esse comportamento, modificamos o TextInputEditText
para que não seja focável:
<?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="wrap_content">
<!-- restante do layout -->
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/data"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:focusable="false"
android:hint="Data" />
</androidx.constraintlayout.widget.ConstraintLayout>
Em seguida, precisamos pegar a data selecionada e preencher o campo assim que o usuário confirma:
selecionadorDeData
.addOnPositiveButtonClickListener { dataEmMilisegundos ->
val data = Instant.ofEpochMilli(dataEmMilisegundos)
.atZone(ZoneId.of("America/Sao_Paulo"))
.withZoneSameInstant(ZoneId.ofOffset("UTC", ZoneOffset.UTC))
.toLocalDate()
binding.data.setText(data.toString())
}
Observe que funciona, porém, ao salvar a nota nesse padrão, temos o mesmo problema de quebrar o App, pois a configuração do padrão esperado é dd/MM/yyyy
, ou seja, 23/02/2021
considerando esse exemplo.
Formatando data
Para formatar a data com um padrão diferente, podemos utilizar as APIs do próprio java.time também, a DateTimeFormatter
:
val formatador = DateTimeFormatter
.ofPattern("dd/MM/yyyy", Locale("pt-br"))
val dataFormatada = formatador.format(data)
binding.data.setText(dataFormatada)
E temos este resultado ao fazer o mesmo teste:
E agora podemos salvar sem problema algum:
Utilizando funções de extensão de LocalDate
Depois de finalizar a implementação, podemos simplificar a nossa solução utilizando algumas funções de extensão já adicionadas no projeto:
package br.com.alura.eventos.extensions
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.*
private val formatador = DateTimeFormatter
.ofPattern("dd/MM/yyyy", Locale("UTC"))
fun LocalDate.paraFormatoBrasileiro(): String = this.format(formatador)
fun String.paraLocalDate(): LocalDate = LocalDate.parse(this, formatador)
Ao invés de criar todo aquele código para formatar a data no padrão brasileiro, basta apenas chamar a função paraFormatoBrasileiro()
a partir da data
:
val data = Instant.ofEpochMilli(dataEmMilisegundos)
.atZone(ZoneId.of("America/Sao_Paulo"))
.withZoneSameInstant(ZoneId.ofOffset("UTC", ZoneOffset.UTC))
.toLocalDate()
binding.data.setText(data.paraFormatoBrasileiro())
Inclusive, podemos transformar em uma função de extensão, o código que converte de milisegundos para LocalData
:
fun Long.paraLocalDate(): LocalDate = Instant.ofEpochMilli(this)
.atZone(ZoneId.of("America/Sao_Paulo"))
.withZoneSameInstant(ZoneId.ofOffset("UTC", ZoneOffset.UTC))
.toLocalDate()
Essa mesma implementação poderia receber as configurações de time zone via parâmetro também.
Então temos um resultado bem mais simplificado:
selecionadorDeData
.addOnPositiveButtonClickListener { dataEmMilisegundos ->
val data = dataEmMilisegundos.paraLocalDate()
binding.data.setText(data.paraFormatoBrasileiro())
}
Para saber mais: Outras implementações com o MaterialDatePicker
Além desta implementação mais simples, o MaterialDatePicker
também oferece outros recursos, como a seleção de período:
Você pode obter mais informações sobre as possibilidades e implementações na página do material.io.