Time Pickers En Android

Los Time Pickers son componentes que te permiten seleccionar e ingresar valores de tiempo a partir de la especificación de horas, minutos o periodos de tiempo. Además de la elección para la franja del día (AM o PM).

Existen dos presentaciones visuales para la selección del tiempo: Dial e Input. En el primer caso se despliega un reloj circular, donde podrás seleccionar cada elemento del tiempo con toques en los números de guía.

Tipos de Time Picker
Tipos de Time Picker

Y la presentación del estilo Input facilita el ingreso de texto para cada componente del tiempo.

1. Componentes Para Usar El Time Picker En Android

En este tutorial veremos como implementar un Time Picker con la librería Jetpack Compose. La cual nos provee la función componible TimePicker() para materializar el modo dial:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TimePickerExample() {
    val timePickerState = rememberTimePickerState(
        initialHour = 10,
        initialMinute = 30,
    )
    TimePicker(state = timePickerState)
}

En su forma más sencilla recibe su estado del tipo TimePickerState producido con la función rememberTimePickerState().

Por otro lado, la función TimeInput() se encarga de producir un Time Picker en modo input:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TimePickerInputExample() {
    val timePickerState = rememberTimePickerState(
        initialHour = 10,
        initialMinute = 30,
    )
    TimeInput(state = timePickerState)
}

No obstante, ambos elementos por lo general van envueltos en un diálogo. Requerimiento que podemos satisfacer a través de TimePickerDialog().

Veamos su uso a través de un ejemplo que nos guíe con un propósito definido.


2. Configurar Un Proyecto Android Para Usar Time Picker

2.1 Crear Un Nuevo Proyecto En Android Studio

Lo primero será crear un nuevo proyecto en Android Studio con una plantilla Empty Activity.

Al presionar Next, se te pedirá información general para configurar tu proyecto. En este caso usa el nombre que desees para este proyecto y ubícalo donde te parezca mejor:

Presiona Finish y espera a que el sistema de construcción termine.

2.2 Añadir Dependencias Gradle

Para este proyecto necesitamos las dependencias de Compose como ya hemos venido haciendo en los tutoriales anteriores. Pero adicionalmente añadiremos a la librería de kotlinx.datetime.

dependencies {
    //...
    implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2")
}

Usaremos la librería de fecha y tiempo de Kotlin a fin de evitar aplicar Desugaring para la librería java.time.

2.3 Definir Punto De Entrada De La UI

Nuestro punto de entrada será la actividad MainActivity.kt preconstruida por Android Studio. En su método onCreate() invocaremos la función componible que representa la pantalla del ejemplo. A esta la llamaremos TimePickersScreen():

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            TimePickersTheme {
                TimePickersScreen()
            }
        }
    }
}

2.4 Ver Código Completo De Time Pickers En GitHub

Si quieres explorar el código completo de este proyecto, puedes encontrarlo en el repositorio de GitHub. ¡No olvides dejar una estrella ✨ para mostrar tu apoyo!


3. Implementación Del Time Picker En Android

Ejemplo de Time Pickers en Android
Ejemplo de Time Pickers en Android

Con el espacio establecido para trabajar, comenzaremos a explorar varias necesidades que te ayudaran a interiorizar el uso de Time Pickers en Android.

Para ello he preparado un ejercicio donde a partir de la selección de una fecha inicial y una final, calculamos la duración o intervalo entre ambos valores. El formato que usaremos para la presentación de los tiempos será el sistema de doce horas, donde los sufijos am y pm indican la franja del día.

A partir de este aplicativo probaremos distintas funcionalidades asociadas a los Time Pickers y al manejo de tiempos en Kotlin.


3.1 Seleccionar Hora Con Un Reloj Digital

La primera meta que cumpliremos será mostrar un Time Picker que permita seleccionar el tiempo desde una vista con forma de reloj. Una vez el usuario confirme el valor, mostraremos ese valor en un texto en pantalla (puedes ver el resultado en la imagen de arriba).

¿Cómo lo resolvemos?

  1. Creamos el contenido principal
  2. Crear estado del contenido
  3. Creamos el Diálogo de Time Picker que será mostrado
  4. Mostramos el Time Picker en la acción de click
  5. Actualizamos el estado del texto cuando el usuario confirme el tiempo

Pasemos a implementar cada tarea.

3.1.1 Crear Contenido Principal

Abrimos el archivo TimePickersScreen.kt y nos ubicamos en la función que comparte el mismo nombre. Debido a que tendremos el contenido principal y dos diálogos más, expresaremos la existencia de estos tres elementos de la siguiente forma:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TimePickersScreen() {

    var startPickerIsVisible by remember { mutableStateOf(false) }
    var endPickerIsVisible by remember { mutableStateOf(false) }

    TimePickersContent(
        state = state, // Lo veremos al crear el ViewModel
        onStartClick = { startPickerIsVisible = true },
        onEndClick = { endPickerIsVisible = true }
    )

    // StartTimePicker()

    // EndTimePicker()
}

Es necesario declarar los estados booleanos para visibilidad de ambos time pickers al mismo nivel del contenido principal. Esto con el fin de permitir disparar los eventos onStartClick y onEndClick para cambiar la visibilidad.

La imagen del ejemplo nos muestra un sencillo diseño para el contenido. Crearemos un componente Row con dos TextFields para mostrar las fechas y un Text para la duración. Con esto en mente, crea un nuevo archivo Kotlin llamado TimePickersState.kt y añade la siguiente implementación:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TimePickersContent(
    state: TimePickersState, // Vincular estado
    onStartClick: () -> Unit,
    onEndClick: () -> Unit
) {
    Scaffold( 
        topBar = {
            CenterAlignedTopAppBar(
                title = {
                    Text(stringResource(R.string.app_name))
                }
            )
        },
        content = { padding ->
            Row(
                modifier = Modifier
                    .padding(padding)
                    .padding(16.dp)
            ) {
                TimeTextField(
                    label = "Inicio",
                    text = state.startTimeString(), // Vincular estado
                    onClick = onStartClick // Vincular evento
                )

                MediumSpace()

                TimeTextField(
                    label = "Fin",
                    text = state.endTimeString(), // Vincular estado
                    onClick = onEndClick // Vincular evento
                )
            }

            MediumSpace()

            Text(text = "Duración: ${state.duration()}") // Vincular estado
        }
    )
}

Tenemos un campo de texto personalizado llamado TimeTextField el cual recibe el evento de click para vincularlo al leading icon:

@Composable
private fun RowScope.TimeTextField(
    label: String,
    text: String,
    onClick: () -> Unit
) {
    val focusRequester = remember { FocusRequester() }

    OutlinedTextField(
        modifier = Modifier
            .weight(1f)
            .focusRequester(focusRequester),
        label = {
            Text(label)
        },
        value = text,
        onValueChange = {},
        readOnly = true,
        leadingIcon = {
            IconButton(
                onClick = {
                    focusRequester.requestFocus()
                    onClick() // ← Muestra al Time Picker
                }
            ) {
                Icon(
                    imageVector = Icons.Filled.AccessTime,
                    contentDescription = null
                )
            }
        }
    )
}

3.1.2 Crear Estado De La Pantalla

Crea una nueva clase de datos para el estado y nómbrala TimePickersState. Añade los strings necesarios para mostrar las fechas y la duración:

data class TimePickersState(
    val startTimeHour: Int = 10,
    val startTimeMinute: Int = 0,
    val endTimeHour: Int = 11,
    val endTimeMinute: Int = 0
) {

    fun startTimeString(): String {
        return LocalTime(startTimeHour, startTimeMinute).as12HoursFormat()
    }

    fun endTimeString(): String {
        return LocalTime(endTimeHour, endTimeMinute).as12HoursFormat()
    }

    fun duration(): String {
        val startDuration = startTimeHour.hours + startTimeMinute.minutes
        val endDuration = endTimeHour.hours + endTimeMinute.minutes

        var duration = (endDuration - startDuration)

        if (duration.isNegative())
            duration += 24.hours

        return duration.toString()
    }
}

El objetivo de función duration() es tomar la hora y minuto de ambos tiempos y convertirlos a tipos Duration. Esta clase representa una unidad de tiempo en una escala comparable, por lo que nos facilita la resta entre el intervalo.

Para ello usamos las propiedades de extensión asHour y asMinute. Al sumar ambos elementos de cada tiempo tendremos la duración de cada uno. Esto permitirá encontrar la diferencia del intervalo y expresarlo con Duration.toString().

3.1.3 Crear Diálogo Con Time Picker

Lo siguiente es crear un archivo llamado CustomTimePickerDialog.kt. Su código tendrá la invocación de la función TimePickerDialog junto al componente TimePicker.

@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun CustomTimePickerDialog(
    currentHour: Int = currentHour(),
    currentMinute: Int = currentMinute(),
    onTimeSelected: (Int, Int) -> Unit,
    onDismiss: () -> Unit,
) {
    val timePickerState = rememberTimePickerState(
        initialHour = currentHour,
        initialMinute = currentMinute,
        is24Hour = false
    )

    TimePickerDialog(
        onDismissRequest = onDismiss,
        title = {
            Headline()
        },
        confirmButton = {
            TextButton(onClick = {
                onTimeSelected(timePickerState.hour, timePickerState.minute)
                onDismiss()
            }) {
                Text("Aceptar")
            }
        },
        dismissButton = {
            TextButton(onClick = onDismiss) {
                Text("Cancelar")
            }
        }
    ) {        
        TimePicker(state = timePickerState)        
    }
}

@Composable
private fun Headline() {
    Box(modifier = Modifier
        .fillMaxWidth()
        .padding(bottom = 20.dp)) {
        Text(
            text = "Selecciona la hora",
            style = MaterialTheme.typography.labelMedium,
            color = MaterialTheme.colorScheme.onSurfaceVariant
        )
    }
}

¿Cuál es la utilidad de los parámetros de nuestro componente CustomTimePickerDialog?

  • currentHour: Valor inicial de la hora que se mostrará
  • currentMinute: Valor inicial de los minutos que se mostrarán
  • onTimeSelected: Acción a ejecutar en el momento que se haga click en el botón de confirmación
  • onDismiss: Acción a ejecutar cuando se cierre el diálogo

Nota que el estado del time picker permite definir:

  • initialHour: Hora inicial mostrada. Le pasamos parámetro del componente
  • initialMinute: Minutos iniciales mostrados. Le pasamos parámetro del componente
  • is24Hour: Determinar si el formato mostrado es de 24 o 12 horas (aquí se muestra AM y PM). En nuestro caso usamos false.

3.1.4 Mostrar El Time Picker

Con el componente base creado, ahora es posible crear tanto el diálogo para selección de fecha inicial, como el de la fecha final. Es solo asociar los estados correspondientes:

@Composable
fun StartTimePicker(
    isVisible: Boolean,
    state: TimePickersState,
    onTimeSelected: (Int, Int) -> Unit,
    onDismiss: () -> Unit
) {
    if (isVisible) {
        CustomTimePickerDialog(
            currentHour = state.startTimeHour,
            currentMinute = state.startTimeMinute,
            onTimeSelected = onTimeSelected,
            onDismiss = onDismiss
        )
    }
}

@Composable
fun EndTimePicker(
    isVisible: Boolean,
    state: TimePickersState,
    onTimeSelected: (Int, Int) -> Unit,
    onDismiss: () -> Unit
) {

    if (isVisible) {
        CustomTimePickerDialog(
            currentHour = state.endTimeHour,
            currentMinute = state.startTimeMinute,
            onTimeSelected = onTimeSelected,
            onDismiss = onDismiss
        )
    }
}

Así que podemos ir a TimePickersExample e invocar ambos elementos:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TimePickersScreen(
    viewModel: TimePickersViewModel = viewModel() // En breve
) {
    val state by viewModel.state.collectAsStateWithLifecycle() // En breve

    var startPickerIsVisible by remember { mutableStateOf(false) }
    var endPickerIsVisible by remember { mutableStateOf(false) }

    TimePickersContent(
        state = state,
        onStartClick = { startPickerIsVisible = true },
        onEndClick = { endPickerIsVisible = true }
    )

    StartTimePicker(
        isVisible = startPickerIsVisible,
        state = state,
        onTimeSelected = viewModel::updateStartTime, // En breve
        onDismiss = { startPickerIsVisible = false }
    )

    EndTimePicker(
        isVisible = endPickerIsVisible,
        state = state,
        onTimeSelected = viewModel::updateEndTime, // En breve
        onDismiss = { endPickerIsVisible = false }
    )
}

Con eso completamos nuestra interfaz gráfica, pero aún necesitamos a nuestro ViewModel para entregarle la responsabilidad de manejar el estado y los eventos.

3.1.5 Actualizar Estado Con ViewModel

En el apartado anterior observaste que se expresó la necesidad de tener funciones updateStart*() para modificar los estados de las fechas al seleccionarse.

De esta forma es posible actualizar el estado general de nuestra pantalla que es colectado por collectAsStateWithLifecycle().

Teniendo eso en cuenta, crea un nuevo view model llamado TimePickersViewModel con la siguiente implementación:

class TimePickersViewModel : ViewModel() {

    private val _state = MutableStateFlow(TimePickersState())
    val state = _state.asStateFlow()

    fun updateStartTime(hour: Int, minute: Int) {
        _state.update { currentState ->
            currentState.copy(
                startTimeHour = hour,
                startTimeMinute = minute
            )
        }
    }

    fun updateEndTime(hour: Int, minute: Int) {
        _state.update { currentState ->
            currentState.copy(
                endTimeHour = hour,
                endTimeMinute = minute
            )
        }
    }
}

El código evidencia que updateStartTime() reemplaza el estado por una nueva copia con los valores seleccionados de startTimeHour y startTimeMinute. De la misma manera updateEndTime() actualiza los valores asociados a la hora final.

Ejecuta el ejemplo: prueba clickando el icono del campo de texto, selecciona el tiempo y confirma. Esto debe cambiar el campo de texto y la duración:


3.2 Permitir Ingreso Manual De La Hora

Lo siguiente será habilitar la modificación de ambos tiempos con el Time Picker estilo Input.

Para que CustomTimePickerDialog posea esta característica, precisamos de:

  1. Un estado booleano que determine el modo actual
  2. Un icono que al ser clickeado, alterne entre ambos modos y cambie su imagen basada en el estado mencionado
  3. Un condicional que evalúe el estado y decida invocar a TimePicker() o TimeInput()

Fundamentado en los pasos anteriores, ahora nuestro componente luciría de esta forma:

@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun CustomTimePickerDialog(
    currentHour: Int = currentHour(),
    currentMinute: Int = currentMinute(),
    onTimeSelected: (Int, Int) -> Unit,
    onDismiss: () -> Unit,
) {
    val timePickerState = rememberTimePickerState(
        initialHour = currentHour,
        initialMinute = currentMinute,
        is24Hour = false
    )
    var isDialType by remember { mutableStateOf(true) } // ← 1. Nuevo estado

    TimePickerDialog(
        onDismissRequest = onDismiss,
        title = {
            Headline()
        },
        modeToggleButton = { // ← 2. Icono de alternación
            IconButton(onClick = { isDialType = !isDialType }) {
                Icon(imageVector = toggleIconFor(isDialType), contentDescription = null)
            }
        },
        confirmButton = {
            TextButton(onClick = {
                onTimeSelected(timePickerState.hour, timePickerState.minute)
                onDismiss()
            }) {
                Text("Aceptar")
            }
        },
        dismissButton = {
            TextButton(onClick = onDismiss) {
                Text("Cancelar")
            }
        }
    ) {
        if (isDialType) { // ← 3. Condicional para invocación
            TimePicker(state = timePickerState)
        } else {
            TimeInput(state = timePickerState)
        }
    }
}

private fun toggleIconFor(isDialType: Boolean): ImageVector {
    return if (isDialType) {
        Icons.Outlined.Keyboard
    } else {
        Icons.Outlined.AccessTime
    }
}

Observa que el slot para el icono es soportado por el parámetro modeToggleButton de TimePickerDialog. Según se había planeado, en su interior creamos un IconButton que alterna al estado isDialType y a su vez elige el icono con toggleIconFor().

Seguido, modificamos la lambda de contenido para añadir un if que seleccione la función a invocar según el tipo actual.


3.4 Usar Formato De 24 Horas

Ejemplo de TimePicker con formato 24 horas

En el inicio viste que el sistema de horario del Time Picker está definido por la propiedad booleana TimePickerState.is24Hours. A la cual le asignamos false para ver nuestra UI con una presentación AM/PM.

Ahora, ¿Qué hacer si deseas el sistema 24 horas?

Evidentemente asignarle true. Por defecto el valor está dado por el valor en la App de ajustes del usuario en Android. Pero si lo deseas, permite que venga desde la firma como parámetro de la siguiente manera:

@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun CustomTimePickerDialog(
    currentHour: Int = currentHour(),
    currentMinute: Int = currentMinute(),
    is24Hour: Boolean = false, // ← Estado elevado
    onTimeSelected: (Int, Int) -> Unit,
    onDismiss: () -> Unit,
) {
    val timePickerState = rememberTimePickerState(
        initialHour = currentHour,
        initialMinute = currentMinute,
        is24Hour = is24Hour // ← Aplicamos
    )
    //...

De esta forma solo debes pasar true en el picker. Por ejemplo, el time picker de la hora final:

@Composable
fun EndTimePicker(
    isVisible: Boolean,
    state: TimePickersState,
    onTimeSelected: (Int, Int) -> Unit,
    onDismiss: () -> Unit
) {

    if (isVisible) {
        CustomTimePickerDialog(
            currentHour = state.endTimeHour,
            currentMinute = state.startTimeMinute,
            is24Hour = true, // ← Habilitamos formato 24 horas
            onTimeSelected = onTimeSelected,
            onDismiss = onDismiss
        )
    }
}

Conclusión

En este tutorial hemos explorado a fondo la implementación de Time Pickers en Android utilizando Jetpack Compose. Desde su configuración inicial hasta la creación de una interfaz funcional con TimePickerDialog, hemos cubierto los aspectos clave para integrar una selección de tiempo eficiente en una aplicación.

Al emplear rememberTimePickerState() junto con componentes como TimePicker y TimeInput, logramos crear una experiencia intuitiva para el usuario, permitiendo seleccionar y mostrar intervalos de tiempo de manera clara y precisa. Además, aprovechamos las capacidades de kotlinx.datetime para manejar la duración entre dos horarios sin depender de la API de java.time.

Con este conocimiento, ahora puedes adaptar y expandir la funcionalidad según las necesidades de tu aplicación, incorporando validaciones, formatos personalizados o incluso combinando el Time Picker con un Date Picker para ofrecer una experiencia completa de selección de fecha y hora.

¿Estás Creando Una App De tareas?

Te comparto una plantilla Android profesional con arquitectura limpia, interfaz moderna y funcionalidades listas para usar. Ideal para acelerar tu desarrollo.

Banner de plantilla de tareas 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!