TimelineView en SwiftUI: vistas que se actualizan con el paso del tiempo
Arturo Rivas Arias
SwiftUI suele actualizar una vista cuando cambia alguno de los datos de los que depende. Cambia un @State, se modifica un @Observable, llega un nuevo valor a través de un Binding, y el cuerpo (variable calculada body) de la vista se vuelve a reevaluar. Ese modelo encaja muy bien con la mayoría de interfaces, pero no siempre basta.
Hay vistas cuyo contenido cambia aunque el estado de la aplicación no haya cambiado realmente. Un reloj, una cuenta atrás, un indicador que representa el progreso de un proceso temporal, una animación paso a paso o una visualización que depende del instante actual no necesitan necesariamente almacenar un nuevo valor cada segundo. Lo que necesitan es que SwiftUI vuelva a evaluar la vista siguiendo una línea de tiempo.
Ahí es donde entra en juego TimelineView.
Qué es TimelineView
TimelineView es una vista de SwiftUI que reevalúa su contenido según una planificación temporal. En lugar de decir “redibuja cuando cambie este estado”, le decimos “redibuja en estos momentos”.
La estructura básica es muy sencilla:
TimelineView(.everyMinute) { context in
Text(context.date, format: .dateTime.hour().minute())
}
El primer parámetro es el schedule, es decir, la estrategia que define cuándo se producen las actualizaciones. El o bbloque de código o closure recibe un TimelineView.Context, que contiene la fecha asociada a la actualización actual y una propiedad cadence que indica la frecuencia esperada de refresco.
La diferencia conceptual es importante. TimelineView no es un temporizador metido dentro de una vista. Es una forma declarativa de decirle a SwiftUI que el contenido depende del tiempo.
Por qué no usar siempre un Timer
Durante años, la solución habitual para este tipo de casos ha sido usar un Timer, un DispatchSourceTimer o un Task con un bucle y llamadas a Task.sleep. Ese enfoque puede ser válido cuando queremos ejecutar lógica, disparar una petición o coordinar trabajo fuera de la interfaz.
Pero si el único objetivo es que una vista se represente de forma distinta según el momento actual, un temporizador suele introducir más complejidad de la necesaria.
struct BadCountdownView: View {
@State private var now = Date()
let deadline: Date
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
Text(remainingTime(from: now, to: deadline))
.onReceive(timer) { value in
now = value
}
}
}
Este código funciona, pero obliga a almacenar now como estado solo para forzar una actualización. La fecha actual no es realmente estado de dominio de la pantalla; es una dependencia temporal. Con TimelineView, podemos expresar esa intención de forma más directa:
struct CountdownBadge: View {
let deadline: Date
var body: some View {
TimelineView(.periodic(from: .now, by: 1)) { context in
Text(remainingTime(from: context.date, to: deadline))
.monospacedDigit()
.contentTransition(.numericText())
}
}
private func remainingTime(from date: Date, to deadline: Date) -> String {
let seconds = max(0, Int(deadline.timeIntervalSince(date)))
let minutes = seconds / 60
let rest = seconds % 60
return "\(minutes):\(rest.formatted(.number.precision(.integerLength(2))))"
}
}
Ahora la vista no necesita mutar estado cada segundo. SwiftUI recibe una planificación periódica y vuelve a calcular el contenido con la fecha correspondiente. Consejor: .contentTransition(.numericText()) genera transiciones numéricas más elegantes y .monospacedDigit() hace que todos los caracteres numéricos tengan el mismo ancho por lo que la anchura total de la vista de texto siempre es la misma, así como la posición de cada dígito.
Los schedules integrados
SwiftUI incluye varias planificadores listos para usar. No todas sirven para lo mismo, y escoger el adecuado evita actualizaciones innecesarias.
.everyMinute
.everyMinute actualiza la vista al comienzo de cada minuto. Es útil para interfaces que no necesitan precisión de segundos: una hora relativa, una fecha en una cabecera, un widget visual sencillo o una etiqueta del tipo “actualizado a las 10:42”.
struct LastSyncLabel: View {
let lastSync: Date
var body: some View {
TimelineView(.everyMinute) { context in
Text(lastSync, format: .relative(presentation: .named))
.foregroundStyle(.secondary)
}
}
}
Aunque context.date no aparezca directamente en el Text, la vista se reevalúa cada minuto. Eso permite que Foundation vuelva a calcular la representación relativa de lastSync sin necesidad de un Timer propio.
.periodic(from:by:)
.periodic(from:by:) permite definir una frecuencia concreta. Es la opción adecuada cuando necesitamos una actualización cada cierto número de segundos, minutos u horas.
struct BuildStatusTicker: View {
let startedAt: Date
var body: some View {
TimelineView(.periodic(from: startedAt, by: 1)) { context in
let elapsed = Int(context.date.timeIntervalSince(startedAt))
Label("Compilando · \(elapsed)s", systemImage: "hammer")
.monospacedDigit()
}
}
}
Este ejemplo no almacena el tiempo transcurrido. Lo calcula a partir de startedAt y de la fecha que proporciona el contexto. La vista sigue siendo una función del estado inicial y del tiempo.
.animation
.animation está pensada para contenido visual que cambia de forma continua. No se trata de lanzar una animación concreta como haríamos con withAnimation, sino de recibir actualizaciones frecuentes para generar una representación distinta a partir del tiempo.
struct ServerPulseView: View {
var body: some View {
TimelineView(.animation) { context in
let time = context.date.timeIntervalSinceReferenceDate
let scale = 1 + sin(time * 2) * 0.08
let opacity = 0.55 + cos(time * 2) * 0.25
Circle()
.fill(.green.gradient)
.frame(width: 18, height: 18)
.scaleEffect(scale)
.opacity(opacity)
.accessibilityLabel("Servidor activo")
}
}
}
Aquí no hay ningún @State llamado scale u opacity. Ambos valores derivan del tiempo. Ese es uno de los usos más interesantes de TimelineView: animaciones paso a paso en las que el progreso no se guarda, sino que se calcula. También puede configurarse con un intervalo mínimo o pausarse cuando no queremos seguir actualizando:
struct ActivityHalo: View {
let isRunning: Bool
var body: some View {
TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: !isRunning)) { context in
let value = context.date.timeIntervalSinceReferenceDate
let rotation = Angle.degrees(value.truncatingRemainder(dividingBy: 4) / 4 * 360)
RoundedRectangle(cornerRadius: 24)
.strokeBorder(.blue.gradient, lineWidth: 4)
.rotationEffect(rotation)
}
}
}
Pausar el planificador es especialmente importante cuando el efecto no está visible, cuando la pantalla entra en un modo inactivo o cuando el usuario ha desactivado algunas animaciones.
La importancia de context.cadence
Siguiendo con el punto anterior, TimelineView.Context no solo proporciona una fecha. También incluye cadence, que describe la frecuencia con la que SwiftUI espera actualizar la vista.
Este detalle es fácil de pasar por alto, pero es relevante en dispositivos con restricciones de energía o en situaciones en las que el sistema decide reducir la frecuencia de actualización. En Apple Watch, por ejemplo, la cadencia puede bajar cuando el usuario baja la muñeca. En otras plataformas, SwiftUI también puede ajustar el ritmo según el contexto.
Una vista bien diseñada no debería asumir siempre actualizaciones en vivo. Puede adaptar el nivel de detalle:
struct PreciseClockView: View {
var body: some View {
TimelineView(.animation) { context in
let style: Date.FormatStyle = if context.cadence == .live {
.dateTime.hour().minute().second().secondFraction(.fractional(2))
} else {
.dateTime.hour().minute().second()
}
Text(context.date, format: style)
.monospacedDigit()
}
}
}
Cuando la cadencia es alta, la vista muestra fracciones de segundo. Si SwiftUI reduce la frecuencia, la interfaz simplifica la representación. En lugar de pelear contra el sistema, la vista se adapta.
Schedules personalizados
Aunque los schedules integrados cubren muchos casos, TimelineView también permite crear planificadores propios conformando un tipo a TimelineSchedule.
Esto resulta útil cuando las actualizaciones no siguen un intervalo constante. Por ejemplo, una pantalla con valors bursátiles podría querer actualizar ciertos marcadores en momentos concretos del día, o una app de productividad podría refrescar una vista al inicio de cada bloque de trabajo.
struct WorkBlockSchedule: TimelineSchedule {
func entries(from startDate: Date, mode: Mode) -> [Date] {
let calendar = Calendar.current
let startOfDay = calendar.startOfDay(for: startDate)
let hours = [9, 11, 13, 16]
return hours.compactMap { hour in
calendar.date(byAdding: .hour, value: hour, to: startOfDay)
}
.filter { $0 >= startDate }
}
}
extension TimelineSchedule where Self == WorkBlockSchedule {
static var workBlocks: WorkBlockSchedule { WorkBlockSchedule() }
}
Y se puede usar como cualquier otro schedule:
struct WorkBlockHeader: View {
var body: some View {
TimelineView(.workBlocks) { context in
Text("Bloque actualizado a las \(context.date.formatted(date: .omitted, time: .shortened))")
.font(.headline)
}
}
}
Este tipo de planificación personalizada tiene sentido cuando el tiempo forma parte de la propia semántica de la interfaz. Si lo que necesitamos es ejecutar una operación de negocio en segundo plano, probablemente este no sea el sitio adecuado.
TimelineView no sustituye a la lógica de aplicación
Una confusión habitual es pensar que TimelineView sirve para cualquier cosa que ocurra cada cierto tiempo. No exactamente.
TimelineView vive en la capa de vista. Su trabajo es recalcular una representación visual. No debería convertirse en el mecanismo que refresca datos provenientes del servidor, sincroniza bases de datos, dispara analíticas o ejecuta tareas de negocio periódicas.
Para esos casos, normalmente encajan mejor otras herramientas: .task, AsyncSequence, Timer, BackgroundTasks, notificaciones del sistema, Combine o una capa de servicios separada.
Una regla práctica puede ser esta: si el resultado principal de la actualización es cambiar cómo se ve algo, TimelineView es una buena candidata. Si el resultado principal es ejecutar trabajo, guardar datos o refrescar datos desde el servidor, probablemente no.
Un ejemplo más completo: progreso temporal sin estado intermedio
Imaginemos una app que muestra el progreso de una sesión de concentración. Tenemos una fecha de inicio y una fecha de fin. La barra debe avanzar con el paso del tiempo, pero no necesitamos almacenar el progreso en un @State.
struct FocusSessionProgress: View {
let start: Date
let end: Date
var body: some View {
TimelineView(.periodic(from: start, by: 1)) { context in
let progress = progress(at: context.date)
VStack(alignment: .leading, spacing: 8) {
ProgressView(value: progress)
Text(progress, format: .percent.precision(.fractionLength(0)))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
}
private func progress(at date: Date) -> Double {
let total = end.timeIntervalSince(start)
guard total > 0 else { return 1 }
let elapsed = date.timeIntervalSince(start)
return min(max(elapsed / total, 0), 1)
}
}
Este patrón es muy limpio porque el progreso es un valor calculado a partir de otros. Las únicas entradas reales de la vista son start, end y el tiempo actual que proporciona TimelineView.
Rendimiento y consumo
Como cualquier API que actualiza la interfaz con frecuencia, TimelineView debe usarse con criterio. Una vista con .animation puede reevaluarse muchas veces por segundo, así que conviene evitar trabajo intenso dentro del bloque de ejecución. No es buena idea ni hacer cálculos pesados, ni acceder a disco, ni crear formateadores complejos repetidamente o lanzar procesos desde el body. El closure debería limitarse a. como mucho, calcular valores ligeros y eso sí, a construir la vista.
También conviene elegir la cadencia mínima necesaria. Si texto solo cambia cada minuto, .everyMinute es mejor que .periodic(from:by:) con un segundo. Si una animación no necesita 60 actualizaciones por segundo, animation(minimumInterval:paused:) puede reducir carga de la CPU y de la GPU.
Cuándo merece la pena usar TimelineView
TimelineView encaja especialmente bien en estos escenarios:
- Relojes y fechas relativa
- Cuentas atrás visibles
- Indicadores de progreso basados en fechas
- Animaciones paso a paso
- Efectos visuales que dependen del tiempo
- Vistas que deben adaptarse a una cadencia variable
No es una API para reemplazar todos los usos de Timer, sino una herramienta específica para interfaces dependientes del tiempo.
Conclusión
TimelineView es una de esas piezas de SwiftUI que se entienden mejor cuando dejamos de pensar en temporizadores y empezamos a pensar en dependencias declarativas. Una vista puede depender de datos, de estado, del entorno y también del tiempo.
Cuando el tiempo forma parte de la representación visual, TimelineView permite expresar esa relación sin introducir estado artificial, sin acoplar la vista a un Timer y sin convertir una animación o una cuenta atrás en lógica imperativa.
Usada con los parámetros adecuados, es una herramienta sencilla pero muy potente para construir interfaces que se mantienen vivas, se actualizan de forma natural y respetan mejor el modelo declarativo de SwiftUI.