Seleccionar Tiempo Con TimePicker En Android

Usa el TimePicker en Android para seleccionar la hora del día a partir de controles que proveen hora y minuto. Podrás elegir entre modos de 24 horas o en el sistema de 12 horas AM/PM.

Al igual que el DatePicker, Google nos recomienda envolver este view en un diálogo del tipo TimePickerDialog. Y a su vez, este diálogo lo recubrimos con un fragmento DialogFragment para encapsular ciclo de vida y mejorar la flexibilidad de UI.

En este tutorial del TimePicker verás cómo crear, mostrar y procesar eventos del widget. Para ello te guiaremos con el siguiente ejemplo:

Esta App permite seleccionar una fecha inicial y final para luego determinar la duración temporal, acotada por ambos límites. Puedes descargar el proyecto Android Studio en Kotlin desde el siguiente enlace:

Crear TimePicker En Android

En primera instancia, nuestra necesidad de desplegar un selector de tiempo nace de presionar dos TextViews ubicados en nuestro layout, para determinar el rango temporal.

Mostrar TimePicker desde click en TextView
Mostrar TimePicker desde click en TextView

Para replicar este diseño, copia y pega la siguiente composición 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:padding="16dp"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/time_selection_label"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Seleccionar fechas"
        android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/to_label"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hasta"
        android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="@+id/guideline"
        app:layout_constraintTop_toTopOf="@+id/from_label" />

    <TextView
        android:id="@+id/from_label"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:text="Desde"
        android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
        app:layout_constraintEnd_toStartOf="@+id/guideline"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/time_selection_label" />

    <TextView
        android:id="@+id/from_time"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textAppearance="@style/TextAppearance.MaterialComponents.Headline5"
        android:textColor="@color/light_blue_800"
        app:layout_constraintEnd_toStartOf="@+id/guideline"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/from_label"
        tools:text="10:00 p. m." />

    <TextView
        android:id="@+id/to_time"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textAppearance="@style/TextAppearance.MaterialComponents.Headline5"
        android:textColor="@color/light_blue_800"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="@+id/guideline"
        app:layout_constraintTop_toBottomOf="@+id/to_label"
        tools:text="12:00 p. m." />

    <TextView
        android:id="@+id/duration"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="24dp"
        android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/from_time"
        tools:text="2 Horas" />

    <androidx.constraintlayout.widget.Guideline
        android:id="@+id/guideline"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        app:layout_constraintGuide_percent="0.5" />

</androidx.constraintlayout.widget.ConstraintLayout>

Ambos requieren el registro de un observador OnClickListener, por lo que invocamos setOnClickListener() desde las referencias.

El cuerpo de la lambda de ambos contendrá la llamada a un método que en el futuro mostrará al TimePicker:

class MainActivity : AppCompatActivity() {
    private lateinit var startTime: TextView
    private lateinit var endTime: TextView
    private lateinit var duration: TextView

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

        duration = findViewById(R.id.duration)

        setUpStartTime()

        setUpEndTime()
    }

    private fun setUpStartTime() {
        startTime = findViewById(R.id.from_time)
        startTime.text = LocalTime.now().format(formatter)
        startTime.setOnClickListener {
            showStartTimePicker()
        }
    }

    private fun showStartTimePicker() {
       
    }


    private fun setUpEndTime() {
        endTime = findViewById(R.id.to_time)
        endTime.text = LocalTime.now().plusHours(2).format(formatter)
        endTime.setOnClickListener {
            showEndTimePicker()
        }
    }

    private fun showEndTimePicker() {
        
    }



}

Como ves, los métodos setUp*() toman la referencia de los textos e inicializan sus valores con el método LocalTime.now(). En el caso de setUpEndTime(), añadimos 2 horas al tiempo actual con plusHours().

Añadir Soporte Para LocalTime

Como ves, usaremos a LocalTime de java.time para facilitar el procesamiento del tiempo. Sin embargo, para soportarla en versiones inferiores al nivel 26 del SDK, debes añadir las siguientes líneas en tu archivo build.gradle:

android {
    defaultConfig {
        multiDexEnabled true
    }

    compileOptions {
        coreLibraryDesugaringEnabled true
    }
}

dependencies {
    coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:[ultima-version]'
}

Con esta estructura codificada, pasemos a explorar la receta para crear el selector de tiempo.

1. Crear DialogFragment Para TimePickerDialog

El primer paso será que crees una clase llamada TimePickerFragment que extienda de DialogFragment. En su interior sobrescribe al método onCreateDialog() y pasa como retorno la creación de una instancia de TimePickerDialog:

class TimePickerFragment : DialogFragment() {

    private var hour: Int
    private var minute: Int

    init {
        val now = LocalTime.now()
        hour = now.hour
        minute = now.minute
    }


    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {        
        return TimePickerDialog(requireActivity(), null, hour, minute, false)
    }   
}

Usaremos dos propiedades mutables para almacenar la hora y minuto del tiempo que se pasarán en la construcción del TimePickerDialog. Y en el bloque de inicio asignaremos los valores enteros del tiempo actual.

Por otro lado, los parámetros pasados al constructor público de TimePickerDialog tienen el siguiente propósito:

  • context: Contexto en el que vivirá el diálogo
  • themeResId: El ID del recurso de estilo que deseas aplicar al diálogo. Más adelante lo usaremos.
  • listener: Observador OnTimeSetListener que notificará el momento en que se seleccione una fecha. Por el momento es null, pero ahora asignaremos una propiedad para que cumpla con este rol.
  • hourOfDay: Representa la hora con que se inicializará el TimePicker
  • minute: Representa los minutos iniciales del TimePicker
  • is24HourView: Si es modo 24 horas o AM/PM

En nuestro ejemplo usaremos el formato AM/PM, por lo que usamos false en el último parámetro del constructor.

Pero si quisieras usar el formato que el usuario ha declarado en sus ajustes de preferencias, puedes averiguarlo con DateFormat.is24hourView(). Con este resultado podrás maniobrar la lógica al construir el TimePicker.

2. Mostrar TimePicker

Como ya es sabido, usamos el método show() desde la instancia de TimePickerFragment en los métodos show*():

private fun showStartTimePicker() {    
    showDialog()
}

private fun showEndTimePicker() {
    showDialog()
}

private fun showDialog() {
    TimePickerFragment().show(supportFragmentManager, "time-picker")
}

3. Procesar Cambio De Tiempo

Hasta aquí el selector de tiempo se despliega al clickear en los text views para el intervalo. Sin embargo, lo que deseamos es actualizar su propiedad text para materializar la interacción:

Actualizar TextView desde TimePicker
Actualizar TextView desde TimePicker

¿Cómo hacerlo?

Habíamos hablado que el tipo OnTimeSetListener habilita al TimePickerDialog para notificar cambios en el tiempo. Por lo que añadiremos una propiedad llamada timeObserver al fragmento y la pasaremos al constructor:

class TimePickerFragment : DialogFragment() {
    private var timeObserver: OnTimeSetListener? = null


    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        return TimePickerDialog(requireActivity(), timeObserver, hour, minute, false)
    }    
}

Ahora bien, la idea es crear un método de utilidad newInstance() en un objeto compañero del fragmento. Esto permitirá aislar la construcción y la asignación de una escucha que venga como parámetro:

companion object {   
    fun newInstance(observer: OnTimeSetListener): TimePickerFragment {

        return TimePickerFragment().apply {
            timeObserver = observer
        }
    }
}

Luego, desde nuestra actividad, modificamos a showTimePicker() para que invoque el método de fabricación y encadene la asignación de la fecha que es emitida desde el evento:

private fun showStartTimePicker() {
    showDialog { _, hour, minute ->
        val currentTime = LocalTime.of(hour, minute)
        
        if (isValidStartTime(currentTime)) {
            startTime.setTime(currentTime)
        }
    }
}

private fun showEndTimePicker() {
    showDialog{ _, hour, minute ->
        val currentTime = LocalTime.of(hour, minute)

        if (isValidEndTime(currentTime)) {
            endTime.setTime(currentTime)
        }
    }
}

private fun showDialog(observer: OnTimeSetListener) {
    TimePickerFragment.newInstance(observer)
        .show(supportFragmentManager, "time-picker")
}

OnTimeSetListener posee un solo método abstracto llamado onTimeSet() que recibe el TimePicker, la hora y los minutos. Por esta razón es posible convertir la clase anónima en una lambda con dichos parámetros.

Una vez notificado el evento de cambio de tiempo, validaremos si el valor es asignable al tiempo. Para ello tenemos dos métodos de validación:

private fun isValidStartTime(time: LocalTime): Boolean {
    return time < endTime.getTime()
}

private fun isValidEndTime(time: LocalTime): Boolean {
    return time > startTime.getTime()
}

En consecuencia, creamos dos métodos para obtener y asignar la fecha a los TextViews a través de dos funciones de extensión alojadas en un nuevo archivo llamado TimeExt.kt:

import android.widget.TextView
import java.time.Duration
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.util.*

private val formatter = DateTimeFormatter.ofPattern("hh:mm a", Locale.ENGLISH)

fun TextView.getTime(): LocalTime {
    return LocalTime.parse(text, formatter)
}

fun TextView.setTime(time: LocalTime) {
    text = time.toTimeText()
}

fun LocalTime.toTimeText(): String {
    return format(formatter)
}

infix fun LocalTime.hoursBetween(end: LocalTime): Double {
    return Duration.between(this, end).toMinutes() / 60.0
}

Las anteriores funciones cumplen con las siguientes tareas:

  • getTime(): Obtiene el texto del TextView y lo parsea con LocalTime.parse()
  • setTime(): Formatea el objeto LocalTime entrante y lo asigna al TextView
  • toTimeText(): Formatea un objeto LocalTime a String
  • hoursBetween(): Función de infijo que calcula la cantidad de minutos entre dos LocalTime con Duration.between(). Se divide en 60.0 para obtener decimales en la duración.

Con este código ya tienes un funcionamiento básico del DatePickerFragment.

Calcular Duración

Nuestra App de ejemplo incluye el cálculo de la duración, por lo que tendremos este paso adicional. La idea es proyectar la cantidad de horas contenidos en el intervalo:

Duración entre dos tiempos
Cálculo de diferencia en horas

El cálculo consiste en la sustracción del tiempo inicial del tiempo final cada vez que hay un cambio. Este resultado será mostrado en el tercer TextView con el id duration:

private fun updateDuration() {
    val start = startTime.getTime()
    val end = endTime.getTime()

    val hours = start hoursBetween end

    duration.text = "%.1f Horas".format(hours)
}

Al obtener la diferencia entre ambos tiempos, formateamos el resultado a un solo decimal en el TextView de duración.

¡Muy bien!

Habiendo terminado la inducción sobre el uso del TimePicker, pasemos a ver algunas configuraciones extras sobre este.

Pasar Argumentos A DatePickerFragment

Si te fijas en la aparición del TimePickerDialog verás que el valor inicial es siempre la hora actual. Para mejorar esta situación, pasaremos como valor inicial el último valor seleccionado en cada TextView:

Mostrar TimePicker con valores iniciales
TimePicker con valores iniciales

Asignaremos los valores iniciales vía argumentos del fragmento. Lo que significa que modificaremos al método newInstance() para que reciba la hora y minutos:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    arguments?.let { args ->
        hour = args.getInt(HOUR_ARG)
        minute = args.getInt(MINUTE_ARG)
    }
}

companion object {
    private const val MINUTE_ARG: String = "args.minute"
    private const val HOUR_ARG: String = "args.hour"

    fun newInstance(
        hour: Int, minute: Int, observer: OnTimeSetListener
    ): TimePickerFragment {

        val args = Bundle().apply {
            putInt(HOUR_ARG, hour)
            putInt(MINUTE_ARG, minute)
        }

        return TimePickerFragment().apply {
            timeObserver = observer
            arguments = args
        }
    }
}

De esta forma podrás modificar los componentes del tiempo a través el diálogo y personalizar al selector. Por ejemplo, para el tiempo inicial, obtenemos con getTime() el valor actual del TextView y lo pasamos al método:

private fun showStartTimePicker() {
    val time = startTime.getTime()
    showDialog(time.hour, time.minute) { _, hour, minute ->
        val currentTime = LocalTime.of(hour, minute)
        if (isValidStartTime(currentTime)) {
            startTime.setTime(currentTime)
            updateDuration()
        }
    }
}

private fun showDialog(initialHour: Int, initialMinute: Int, observer: OnTimeSetListener) {
    TimePickerFragment.newInstance(initialHour, initialMinute, observer)
        .show(supportFragmentManager, "time-picker")
}

Cambiar Tema Del TimePicker

Personalizar los colores y elementos del selector de fechas se puede realizar desde recursos de estilo aplicados en themes.xml o con el parámetro del constructor de TimePickerDialog visto en el inicio:

Cambiar colores de TimePicker
Cambiar de color del TimePicker

Ambos casos requieren la temificación basada en uno cualquier estilo base del framework como ThemeOverlay.MaterialComponents.TimePicker.

Si deseas aplicarlo a todos los diálogos, entonces aplícalo con el atributo android:timePickerDialogTheme en tu tema de App (niveles mayores al SDK 21):

<?xml version="1.0" encoding="utf-8"?>
<resources>

    <style name="Theme.TimePickerEnAndroid" parent="Theme.MaterialComponents.DayNight.DarkActionBar">        

        <item name="android:timePickerDialogTheme">@style/ThemeOverlay.App.TimePicker</item>
    </style>

    <style name="ThemeOverlay.App.TimePicker" parent="ThemeOverlay.MaterialComponents.TimePicker">
        <item name="colorPrimary">@color/light_blue_900</item>
        <item name="colorSecondary">@color/light_blue_800</item>
    </style>
</resources>

O aplícalo individualmente desde el constructor en onCreateDialog():

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
    //DateFormat.is24HourFormat()
    val dialog = TimePickerDialog(
        requireActivity(),
        R.style.ThemeOverlay_App_TimePicker,
        timeObserver,
        hour,
        minute,
        false
    )
    return dialog
}

Más Recursos Android

Únete Al Discord De Develou

Si tienes problemas con el código de este tutorial, preguntas, recomendaciones o solo deseas discutir sobre desarrollo Android conmigo y otros desarrolladores, únete a la comunidad de Discord de Develou y siéntete libre de participar como gustes. ¡Te espero!