wandres.dev
WIDGETS Y APP INTENTS · fuera de la app

Widgets interactivos: botones, toggles y sus límites

Desde iOS 17 un widget puede reaccionar a un toque sin abrir la app, pero el mecanismo no es un cierre que se ejecuta ni un gesto que se reconoce: es un intent que el sistema invoca en otro proceso y cuyo único efecto visible es una línea temporal nueva. Esta lección desmonta ese ciclo paso a paso, delimita con precisión qué se puede y qué no se puede hacer, y explica cómo diseñar para la latencia inevitable entre el toque y el redibujo.

⏱ 17 min

La interactividad de los widgets llegó con una restricción tan severa que conviene enunciarla antes que la API: no puedes ejecutar código arbitrario al tocar. No hay onTapGesture, no hay acción en forma de cierre, no hay estado local que cambie. Solo existen dos superficies —un botón y un interruptor— y ambas exigen lo mismo: un tipo que conforma AppIntent, declarado de antemano, que el sistema serializa, invoca en un proceso que él elige y ejecuta sin que tu vista esté presente. El toque no modifica la pantalla: modifica datos y provoca una recarga. Lo que ves cambiar es una vista nueva, dibujada después, que sustituye a la anterior.

🎯 Al terminar esta lección sabrás
  • Describir con precisión el ciclo completo que va del toque a la nueva línea temporal.
  • Escribir intents de widget correctos, rápidos e idempotentes, con parámetros bien tipados.
  • Delimitar los límites duros de la interactividad y reconocer qué patrones son inviables.
  • Diseñar para la latencia con contenido invalidable y con escritura optimista del estado.

Qué significa interactivo aquí

Las únicas dos vistas que aceptan interacción dentro de un widget son un botón construido con un intent y un interruptor asociado a un intent. Todo lo demás sigue igual que antes: no hay desplazamiento, no hay campos de texto, no hay gestos personalizados, no hay menús contextuales propios ni presentación de hojas. La superficie es deliberadamente diminuta porque el modelo de ejecución no admite más.

struct MarcarTarea: AppIntent {
    static let title: LocalizedStringResource = "Marcar tarea"

    @Parameter(title: "Identificador")
    var tareaID: String

    init() {}
    init(tareaID: String) { self.tareaID = tareaID }

    func perform() async throws -> some IntentResult {
        try Almacen.compartido.alternarCompletada(tareaID)
        return .result()
    }
}
// En la vista del widget
Button(intent: MarcarTarea(tareaID: entry.tarea.id)) {
    Image(systemName: entry.tarea.hecha ? "checkmark.circle.fill" : "circle")
}
.buttonStyle(.plain)

Toggle(isOn: entry.activo, intent: AlternarModo(sala: entry.sala)) {
    Text(entry.sala.nombre)
}
.toggleStyle(.button)

El inicializador vacío no es una formalidad: el sistema necesita poder construir el tipo por sí mismo para deserializar la invocación. Y los parámetros deben ser valores simples y serializables —cadenas, números, fechas, enumeraciones de app, entidades de app—, nunca objetos vivos ni referencias a tu grafo de dominio, porque lo que viaja al otro proceso es una descripción, no una instancia.

El ciclo de un toque

Al tocar, el sistema no llama a tu vista. Serializa el intent con sus parámetros, elige un proceso donde ejecutarlo —normalmente el de la extensión, a veces el de la app si el intent lo requiere—, ejecuta perform, espera a que termine y, solo entonces, pide una línea temporal nueva. La vista que aparece después es el resultado de ese nuevo archivo. Entre el toque y el redibujo hay, inevitablemente, un intervalo de cientos de milisegundos.

flowchart TB
a[La persona toca el boton] --> b[El sistema serializa el intent]
b --> c[Lo ejecuta en el proceso elegido]
c --> d[Perform escribe en el contenedor compartido]
d --> e{Termino sin error}
e -->|si| f[El sistema pide linea temporal nueva]
e -->|no| g[Se restaura la vista anterior]
f --> h[El proveedor lee el estado ya cambiado]
h --> i[Se dibuja y sustituye la vista]

Ese intervalo tiene una consecuencia de diseño ineludible: el estado que ves en pantalla durante la ejecución es todavía el viejo. Si no haces nada, el usuario toca y no pasa nada visible hasta que llega el dibujo nuevo, lo que se percibe como que el widget ignoró el toque. El sistema ofrece un paliativo específico para eso: marcar como invalidable el contenido que depende del resultado, de modo que se atenúe o se enmascare mientras la operación está en curso.

Text(entry.contador.formatted())
    .invalidatableContent()

Image(systemName: "checkmark.circle.fill")
    .invalidatableContent()

Los interruptores reciben además un trato especial y más generoso: el sistema aplica un cambio optimista inmediato al aspecto del control, de manera que el conmutador se mueve al instante y solo se corrige si la línea temporal posterior contradice esa suposición. Por eso un interruptor casi siempre se siente mejor que un botón que alterna un estado, y por eso conviene usar el tipo correcto en lugar de simular uno con el otro.

⚠️
Perform escribe, no dibuja

Dentro de perform no existe la vista. No puedes mostrar una alerta, animar nada, empujar una pantalla ni leer estado de interfaz. Lo único útil que puedes hacer es cambiar el estado compartido y devolver. Si el trabajo real es lento —una llamada de red, una sincronización—, escribe primero un estado pendiente que el widget pueda dibujar, encola el trabajo de verdad en la app o en una tarea en segundo plano, y devuelve de inmediato.

Los límites duros

El primero es el tiempo. La ejecución del intent dispone de un margen de unos pocos segundos, medido por el sistema, y agotarlo se traduce en una operación cancelada y una vista que revierte. Todo lo que no quepa ahí debe salir de perform. El segundo es la ausencia de entrada libre: no hay teclado, no hay dictado ni campos, de modo que cualquier flujo que requiera escribir algo obliga a abrir la app. El tercero es que el widget no navega: si la acción necesita continuar en una pantalla, hay que declararlo, ya sea abriendo la app al ejecutarse o adoptando el protocolo de continuación en primer plano.

struct ComprarAhora: AppIntent {
    static let title: LocalizedStringResource = "Comprar"
    static let openAppWhenRun = true      // el flujo continua en la app

    func perform() async throws -> some IntentResult {
        Rutas.compartido.destinoPendiente = .checkout
        return .result()
    }
}
🧩

Sí encaja

Alternar un booleano, marcar completado, incrementar un contador, cambiar de emisora, iniciar o pausar un temporizador, registrar un hábito.

⚠️

Encaja con cuidado

Acciones con red, pagos y cualquier cosa reversible con coste. Escribe un estado pendiente, delega el trabajo y confirma en la siguiente recarga.

No encaja

Escribir texto, elegir en una lista larga, autenticarse, mostrar diálogos, arrastrar, desplazar o cualquier flujo con varios pasos encadenados.

Hay dos límites más que se descubren tarde. Uno es el presupuesto: las recargas provocadas por interacción también se contabilizan, así que un widget con seis botones que la gente pulsa a menudo puede quedarse sin refrescos automáticos el resto del día. El otro es la reentrada: nada impide dos toques rápidos, ni que un intent se ejecute mientras otro sigue en curso, por lo que la escritura debe ser idempotente y explícita. Un intent que alterna un valor invita al desastre; uno que fija un valor concreto no.

// Fragil ante toques repetidos
try Almacen.compartido.alternarCompletada(id)

// Robusto: el resultado no depende de cuantas veces se ejecute
try Almacen.compartido.fijarCompletada(id, valor: nuevoValor)

Diseñar para la latencia y para el error

La regla que ordena todo lo anterior es escribir el estado visible antes de hacer el trabajo caro. Si el usuario marca una tarea, la marca debe quedar guardada en el contenedor compartido dentro del propio perform, aunque la sincronización con el servidor ocurra minutos después. El widget dibuja lo que hay en el contenedor, no lo que hay en el servidor, y esa separación entre verdad local y verdad remota es lo que hace que la interacción se sienta instantánea sin mentir.

El fallo merece el mismo cuidado que el éxito. Si perform lanza, el sistema descarta el cambio optimista y restaura la vista previa, pero el usuario no recibe explicación alguna. Un widget serio guarda también el error en el estado compartido y lo dibuja: un icono de reintento, una marca de pendiente, una línea de texto. Ese detalle convierte un fallo mudo en información, y de paso ofrece un camino de vuelta —tocar de nuevo o abrir la app— que sin él no existiría.

Por último, conviene tratar la superficie táctil como parte del diseño y no como un añadido. Las zonas de toque deben ser generosas porque el widget es pequeño y compite con el gesto de reorganizar la pantalla de inicio; los controles deben distinguirse visualmente de lo que es mero texto, porque en un widget nada invita a probar; y cada botón debe llevar su etiqueta de accesibilidad, ya que el intent no aporta ninguna descripción por sí mismo. Un widget interactivo mal etiquetado es, para un lector de pantalla, un rectángulo con dos zonas anónimas.

La interactividad del widget es programación distribuida con la piel de un botón

Lo que hace valioso este tema no es la API sino el modelo que impone, porque es el modelo que ya rige medio mundo del software y que la programación de interfaces suele ocultar. Aquí el toque no invoca una función: construye un mensaje, con un nombre estable y unos parámetros serializables, que viaja a otro proceso, se ejecuta sin tu presencia, puede llegar dos veces, puede fallar a mitad y solo se manifiesta como un cambio de estado que otro lector observará después. Eso es, punto por punto, la definición de una llamada remota, y por eso aparecen aquí de golpe todas las disciplinas de ese mundo: idempotencia, porque la entrega puede repetirse; contratos explícitos, porque el receptor no comparte tu memoria; escritura local primero, porque la latencia existe y el usuario no la perdona; reconciliación posterior, porque la verdad remota llegará tarde. Un equipo acostumbrado a cerrar toques con un bloque que muta una variable descubre aquí, en un botón de doce puntos, todo lo que llevaba años evitando. Y la recompensa es transferible: quien aprende a expresar sus acciones como intents idempotentes, con parámetros tipados y estado local optimista, acaba de escribir también la interfaz que necesitan Siri, los Atajos, el botón de acción, los controles del centro de control y cualquier agente automático que opere la app sin verla.

📝
Lo esencial

Solo hay dos superficies interactivas y ambas exigen un intent con inicializador vacío y parámetros serializables. El toque no dibuja: ejecuta en otro proceso, escribe estado compartido y provoca una línea temporal nueva. Marca como invalidable lo que depende del resultado, prefiere interruptores cuando el estado sea binario y haz que toda escritura fije valores en lugar de alternarlos. Saca de perform cualquier trabajo lento, guarda también el error y etiqueta cada control para accesibilidad.

⚔️ Un widget que aguanta el doble toque
  1. Convierte una acción de un solo paso de tu app en un intent con parámetros tipados e inicializador vacío, y expónla como botón en el widget.
  2. Reescribe la operación para que fije un valor explícito en lugar de alternarlo, y demuestra con una prueba que ejecutarla dos veces deja el mismo resultado.
  3. Añade contenido invalidable a las vistas que dependen del resultado y compara la sensación con la versión sin él.
  4. Introduce un fallo artificial en la escritura y diseña el estado de error visible junto con su camino de recuperación.
  5. Mide cuántas recargas provoca una sesión de uso intensivo del widget y decide si alguna de tus acciones debería abrir la app en lugar de ejecutarse en el sitio.