WidgetKit en Swift: Widget, TimelineProvider, TimelineEntry y actualización programada

WidgetKit permite añadir widgets a la pantalla de inicio y a la pantalla de bloqueo de iOS. A diferencia de las extensiones de hoy en día, los widgets no son vistas interactivas en tiempo real, sino instantáneas programadas que el sistema renderiza y muestra cuando lo necesita. Entender este modelo es clave para implementar widgets correctamente.

La estructura básica: Widget, TimelineProvider y TimelineEntry

Un widget se compone de tres piezas: la entrada con los datos en un momento dado (TimelineEntry), el proveedor que decide qué entradas generar y cuándo (TimelineProvider), y la vista que renderiza cada entrada.

import WidgetKit
import SwiftUI

// 1. Entrada: los datos de una snapshot concreta
struct EntradaClima: TimelineEntry {
    let date: Date
    let temperatura: Double
    let ciudad: String
    let icono: String
}

// 2. Proveedor: genera las entradas del timeline
struct ProveedorClima: TimelineProvider {
    // Vista placeholder mientras se cargan datos reales
    func placeholder(in context: Context) -> EntradaClima {
        EntradaClima(date: Date(), temperatura: 22.0, ciudad: "Madrid", icono: "sun.max")
    }

    // Snapshot para la galería de widgets
    func getSnapshot(in context: Context, completion: @escaping (EntradaClima) -> Void) {
        let entrada = EntradaClima(date: Date(), temperatura: 22.0, ciudad: "Madrid", icono: "sun.max")
        completion(entrada)
    }

    // Timeline completo: array de entradas y política de recarga
    func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
        Task {
            let clima = await obtenerClima()
            let ahora = Date()
            let entradas = (0..<12).map { hora in
                EntradaClima(
                    date: ahora.addingTimeInterval(Double(hora) * 3600),
                    temperatura: clima.temperatura,
                    ciudad: clima.ciudad,
                    icono: clima.icono
                )
            }
            let timeline = Timeline(entries: entradas, policy: .atEnd)
            completion(timeline)
        }
    }
}

// 3. Vista del widget
struct VistaClima: View {
    var entry: EntradaClima

    var body: some View {
        VStack {
            Image(systemName: entry.icono)
                .font(.largeTitle)
            Text("(entry.temperatura, format: .number.precision(.fractionLength(1)))°")
                .font(.title2.bold())
            Text(entry.ciudad)
                .font(.caption)
        }
        .containerBackground(.blue.gradient, for: .widget)
    }
}

// 4. Definición del widget
struct ClimaWidget: Widget {
    let kind: String = "ClimaWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: ProveedorClima()) { entry in
            VistaClima(entry: entry)
        }
        .configurationDisplayName("Clima")
        .description("Muestra la temperatura actual.")
        .supportedFamilies([.systemSmall, .systemMedium])
    }
}

TimelineReloadPolicy: cuándo recargar

La política de recarga determina cuándo el sistema pedirá un nuevo timeline:

// Recargar cuando se consuman todas las entradas del timeline
let timeline = Timeline(entries: entradas, policy: .atEnd)

// Recargar en una fecha específica, independientemente de las entradas
let maniana = Calendar.current.date(byAdding: .day, value: 1, to: Date())!
let timeline = Timeline(entries: entradas, policy: .after(maniana))

// No recargar hasta que la app lo solicite explícitamente
let timeline = Timeline(entries: entradas, policy: .never)

Para recargar desde la app principal:

import WidgetKit

// Recargar todos los widgets
WidgetCenter.shared.reloadAllTimelines()

// Recargar solo un tipo específico
WidgetCenter.shared.reloadTimelines(ofKind: "ClimaWidget")

App Group: compartir datos entre app y widget

El widget se ejecuta en un proceso separado y no puede acceder directamente a los datos de la app principal. La solución es un App Group compartido:

// En la app principal: guardar datos en el grupo compartido
let userDefaults = UserDefaults(suiteName: "group.com.miempresa.miapp")
userDefaults?.set(temperatura, forKey: "temperatura_actual")
userDefaults?.set(ciudad, forKey: "ciudad_actual")

// En el widget: leer los mismos datos
func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
    let ud = UserDefaults(suiteName: "group.com.miempresa.miapp")
    let temperatura = ud?.double(forKey: "temperatura_actual") ?? 0
    let ciudad = ud?.string(forKey: "ciudad_actual") ?? "Desconocida"

    let entrada = EntradaClima(date: Date(), temperatura: temperatura, ciudad: ciudad, icono: "cloud")
    let timeline = Timeline(entries: [entrada], policy: .after(Date().addingTimeInterval(3600)))
    completion(timeline)
}

Deep linking con widgetURL y Link

Para que el widget abra la app en un contexto específico al tocarlo, usa widgetURL (para systemSmall) o Link (para tamaños que pueden tener múltiples zonas tocables):

// Widget pequeño: una sola URL de destino
struct VistaTarea: View {
    var entry: EntradaTarea

    var body: some View {
        VStack(alignment: .leading) {
            Text(entry.titulo)
            Text(entry.fecha, style: .relative)
                .font(.caption)
        }
        .widgetURL(URL(string: "miapp://tarea/(entry.id)")!)
        .containerBackground(.white, for: .widget)
    }
}

// Widget mediano: varias zonas tocables independientes
struct VistaListaTareas: View {
    var entry: EntradaListaTareas

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            ForEach(entry.tareas.prefix(3)) { tarea in
                Link(destination: URL(string: "miapp://tarea/(tarea.id)")!) {
                    Text(tarea.titulo).lineLimit(1)
                }
            }
        }
        .containerBackground(.white, for: .widget)
    }
}

Relevance en Smart Stacks

Para que el sistema muestre tu widget en el momento más apropiado dentro de una Smart Stack, proporciona información de relevancia:

struct EntradaReunion: TimelineEntry {
    let date: Date
    let reunion: Reunion
    var relevance: TimelineEntryRelevance? {
        // Relevancia alta cuando la reunión empieza en menos de 30 min
        let minutosHastaReunion = reunion.inicio.timeIntervalSinceNow / 60
        if minutosHastaReunion < 30 && minutosHastaReunion > 0 {
            return TimelineEntryRelevance(score: 1.0, duration: 30 * 60)
        }
        return TimelineEntryRelevance(score: 0.3)
    }
}

Resumen

WidgetKit sigue un modelo de snapshots programadas, no de vistas en tiempo real. El TimelineProvider genera entradas con datos y fechas; la política de recarga controla cuándo el sistema pide nuevos datos; los App Groups sincronizan datos entre la app y el widget; y widgetURL o Link conectan los widgets con rutas concretas de la app. Entender este modelo de datos inmutables y actualizaciones discretas es el primer paso para crear widgets que el sistema gestione de forma eficiente.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP