WidgetKit iOS 17+: Widgets Interactivos con App Intents
Guía completa para crear widgets iOS interactivos con WidgetKit y App Intents. Botones, toggles, animaciones y mejores prácticas para iOS 17+ en 2026.

iOS 17 revolucionó WidgetKit al introducir interactividad nativa. Los widgets ya no son pantallas estáticas: ahora pueden responder a las acciones del usuario directamente desde la pantalla de inicio, sin abrir la aplicación. Esta evolución mayor se apoya en el framework App Intents y ofrece una experiencia de usuario fluida y moderna.
Este artículo presenta la creación completa de widgets interactivos iOS 17+, desde la configuración del proyecto hasta los patrones avanzados con animaciones y gestión de estado.
Arquitectura de los Widgets Interactivos
La interactividad de los widgets iOS 17+ funciona mediante el framework App Intents. A diferencia de los deep links tradicionales que abrirían la aplicación, los App Intents permiten ejecutar código directamente desde el widget, refrescando luego automáticamente la visualización con los nuevos datos.
import WidgetKit
import SwiftUI
import AppIntents
// La arquitectura se basa en tres componentes principales:
// 1. Widget Timeline Provider - proporciona los datos
// 2. Widget View - muestra la interfaz con Button/Toggle
// 3. App Intent - ejecuta la acción al pulsar
struct TaskWidget: Widget {
// Identificador único del widget
let kind: String = "TaskWidget"
var body: some WidgetConfiguration {
// StaticConfiguration para widgets sin parámetros
StaticConfiguration(
kind: kind,
provider: TaskTimelineProvider()
) { entry in
TaskWidgetView(entry: entry)
// Obligatorio para los App Intents
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Tareas")
.description("Gestiona tus tareas desde la pantalla de inicio.")
.supportedFamilies([.systemSmall, .systemMedium])
}
}El widget declara su configuración y especifica el provider que suministrará los datos. El atributo .containerBackground es obligatorio desde iOS 17 para los widgets interactivos.
Creación del Timeline Provider
El Timeline Provider determina cuándo y cómo se refresca el widget. Para los widgets interactivos, también debe reaccionar a los cambios provocados por los App Intents.
import WidgetKit
import SwiftUI
// Entry que representa el estado del widget en un momento dado
struct TaskEntry: TimelineEntry {
let date: Date
let tasks: [Task]
// Estado de carga para el feedback visual
var isLoading: Bool = false
}
// Modelo de datos compartido entre app y widget
struct Task: Identifiable, Codable {
let id: UUID
var title: String
var isCompleted: Bool
var priority: Priority
enum Priority: String, Codable {
case low, medium, high
}
}
struct TaskTimelineProvider: TimelineProvider {
// Placeholder mostrado durante la carga inicial
func placeholder(in context: Context) -> TaskEntry {
TaskEntry(
date: Date(),
tasks: [
Task(id: UUID(), title: "Tarea de ejemplo", isCompleted: false, priority: .medium)
]
)
}
// Snapshot para la galería de widgets
func getSnapshot(in context: Context, completion: @escaping (TaskEntry) -> Void) {
let entry = TaskEntry(
date: Date(),
tasks: TaskDataManager.shared.fetchTasks().prefix(3).map { $0 }
)
completion(entry)
}
// Timeline completa con política de refresco
func getTimeline(in context: Context, completion: @escaping (Timeline<TaskEntry>) -> Void) {
let tasks = TaskDataManager.shared.fetchTasks()
let entry = TaskEntry(date: Date(), tasks: Array(tasks.prefix(3)))
// Refresco en 15 minutos o tras una acción del usuario
let nextUpdate = Calendar.current.date(
byAdding: .minute,
value: 15,
to: Date()
) ?? Date()
let timeline = Timeline(
entries: [entry],
policy: .after(nextUpdate)
)
completion(timeline)
}
}El provider utiliza un TaskDataManager compartido para acceder a los datos. Este enfoque garantiza la sincronización entre la aplicación principal y el widget.
Para compartir datos entre la app y el widget, hay que configurar un App Group en las capabilities del proyecto. Los UserDefaults o archivos deben usar este grupo compartido.
El Gestor de Datos Compartidos
El intercambio de datos entre la aplicación y el widget requiere un contenedor común accesible mediante App Group.
import Foundation
final class TaskDataManager {
// Singleton para acceso global
static let shared = TaskDataManager()
// Identificador del App Group configurado en Xcode
private let appGroupID = "group.com.example.taskapp"
// UserDefaults compartido entre app y widget
private var sharedDefaults: UserDefaults? {
UserDefaults(suiteName: appGroupID)
}
private let tasksKey = "tasks"
private init() {}
// Recupera las tareas del almacenamiento compartido
func fetchTasks() -> [Task] {
guard let data = sharedDefaults?.data(forKey: tasksKey),
let tasks = try? JSONDecoder().decode([Task].self, from: data) else {
return []
}
return tasks
}
// Guarda con notificación al widget
func saveTasks(_ tasks: [Task]) {
guard let data = try? JSONEncoder().encode(tasks) else { return }
sharedDefaults?.set(data, forKey: tasksKey)
}
// Actualiza una tarea específica
func updateTask(_ task: Task) {
var tasks = fetchTasks()
if let index = tasks.firstIndex(where: { $0.id == task.id }) {
tasks[index] = task
saveTasks(tasks)
}
}
// Cambia el estado de completado
func toggleTaskCompletion(taskID: UUID) {
var tasks = fetchTasks()
if let index = tasks.firstIndex(where: { $0.id == taskID }) {
tasks[index].isCompleted.toggle()
saveTasks(tasks)
}
}
}Este gestor encapsula toda la lógica de persistencia y será utilizado tanto por la aplicación como por los App Intents del widget.
Creación del App Intent para la Interactividad
El App Intent define la acción ejecutada cuando el usuario interactúa con el widget. iOS ejecuta esta acción en segundo plano y luego refresca el widget automáticamente.
import AppIntents
import WidgetKit
// Intent para alternar el estado de una tarea
struct ToggleTaskIntent: AppIntent {
// Título mostrado en los atajos de Siri
static var title: LocalizedStringResource = "Alternar estado de la tarea"
// Descripción para accesibilidad
static var description = IntentDescription("Marca una tarea como completada o no completada.")
// Parámetro: ID de la tarea a modificar
@Parameter(title: "ID de la tarea")
var taskID: String
// Inicializador requerido por AppIntent
init() {}
// Inicializador con parámetro para creación desde la vista
init(taskID: UUID) {
self.taskID = taskID.uuidString
}
// Ejecución de la acción
func perform() async throws -> some IntentResult {
// Conversión del ID string a UUID
guard let uuid = UUID(uuidString: taskID) else {
return .result()
}
// Actualización de la tarea
TaskDataManager.shared.toggleTaskCompletion(taskID: uuid)
// Solicita el refresco del widget
WidgetCenter.shared.reloadTimelines(ofKind: "TaskWidget")
return .result()
}
}La llamada a WidgetCenter.shared.reloadTimelines provoca un refresco inmediato del widget tras la acción, asegurando un feedback visual instantáneo.
¿Listo para aprobar tus entrevistas de iOS?
Practica con nuestros simuladores interactivos, flashcards y tests técnicos.
Vista del Widget con Botones Interactivos
La vista del widget utiliza el componente Button estándar de SwiftUI con el intent como acción. iOS 17+ intercepta automáticamente estas interacciones para ejecutar el App Intent.
import SwiftUI
import WidgetKit
struct TaskWidgetView: View {
let entry: TaskEntry
// Adaptación al tamaño del widget
@Environment(\.widgetFamily) var family
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Encabezado con título y contador
headerView
// Lista de tareas con botones interactivos
ForEach(entry.tasks.prefix(tasksLimit)) { task in
TaskRowView(task: task)
}
Spacer(minLength: 0)
}
.padding()
}
// Número de tareas según el tamaño
private var tasksLimit: Int {
switch family {
case .systemSmall: return 2
case .systemMedium: return 3
default: return 4
}
}
private var headerView: some View {
HStack {
Text("Tareas")
.font(.headline)
.fontWeight(.bold)
Spacer()
// Insignia con el número de tareas restantes
let remaining = entry.tasks.filter { !$0.isCompleted }.count
Text("\(remaining)")
.font(.caption.bold())
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(remaining > 0 ? Color.orange : Color.green)
.clipShape(Capsule())
}
}
}
struct TaskRowView: View {
let task: Task
var body: some View {
// Botón con App Intent como acción
Button(intent: ToggleTaskIntent(taskID: task.id)) {
HStack(spacing: 12) {
// Indicador de completado
Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
.font(.title3)
.foregroundStyle(task.isCompleted ? .green : .secondary)
// Título de la tarea
Text(task.title)
.font(.subheadline)
.strikethrough(task.isCompleted)
.foregroundStyle(task.isCompleted ? .secondary : .primary)
.lineLimit(1)
Spacer()
// Indicador de prioridad
priorityIndicator
}
.padding(.vertical, 6)
.padding(.horizontal, 10)
.background(Color(.systemBackground).opacity(0.5))
.cornerRadius(8)
}
.buttonStyle(.plain)
}
@ViewBuilder
private var priorityIndicator: some View {
switch task.priority {
case .high:
Image(systemName: "exclamationmark.circle.fill")
.foregroundStyle(.red)
case .medium:
Image(systemName: "minus.circle.fill")
.foregroundStyle(.orange)
case .low:
EmptyView()
}
}
}La sintaxis Button(intent:) conecta directamente el botón con el App Intent. Al pulsarlo, iOS ejecuta perform() y luego refresca automáticamente el widget.
Toggle Interactivo para los Widgets
Para las acciones de tipo encendido/apagado, el componente Toggle ofrece una alternativa al botón con un estilo nativo de iOS.
import SwiftUI
import AppIntents
// Intent específico para Toggle con estado explícito
struct SetTaskCompletionIntent: AppIntent {
static var title: LocalizedStringResource = "Definir estado de la tarea"
@Parameter(title: "ID de la tarea")
var taskID: String
// Estado deseado: true = completada, false = no completada
@Parameter(title: "Completada")
var isCompleted: Bool
init() {}
init(taskID: UUID, isCompleted: Bool) {
self.taskID = taskID.uuidString
self.isCompleted = isCompleted
}
func perform() async throws -> some IntentResult {
guard let uuid = UUID(uuidString: taskID) else {
return .result()
}
var tasks = TaskDataManager.shared.fetchTasks()
if let index = tasks.firstIndex(where: { $0.id == uuid }) {
// Define el estado de manera explícita (no toggle)
tasks[index].isCompleted = isCompleted
TaskDataManager.shared.saveTasks(tasks)
}
WidgetCenter.shared.reloadTimelines(ofKind: "TaskWidget")
return .result()
}
}
struct TaskToggleRowView: View {
let task: Task
var body: some View {
HStack {
Text(task.title)
.font(.subheadline)
.strikethrough(task.isCompleted)
Spacer()
// Toggle interactivo con intent
Toggle(
isOn: task.isCompleted,
intent: SetTaskCompletionIntent(
taskID: task.id,
isCompleted: !task.isCompleted
)
)
.toggleStyle(.switch)
.labelsHidden()
}
.padding(.vertical, 4)
}
}Toggle ofrece una interacción más intuitiva para los estados binarios y se integra naturalmente en los diseños iOS.
Los widgets no pueden mostrar alertas, sheets ni navegación. Todas las acciones deben ser autónomas y actualizar directamente el estado visible.
Animaciones de Refresco y Transiciones
iOS 17+ permite animar las transiciones durante el refresco del widget tras una acción. El modificador .contentTransition controla estas animaciones.
import SwiftUI
import WidgetKit
struct AnimatedTaskRowView: View {
let task: Task
var body: some View {
Button(intent: ToggleTaskIntent(taskID: task.id)) {
HStack(spacing: 12) {
// Icono con animación de transición
Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
.font(.title3)
.foregroundStyle(task.isCompleted ? .green : .secondary)
// Animación del icono al cambiar
.contentTransition(.symbolEffect(.replace))
Text(task.title)
.font(.subheadline)
.strikethrough(task.isCompleted)
.foregroundStyle(task.isCompleted ? .secondary : .primary)
// Animación del texto
.contentTransition(.opacity)
Spacer()
}
.padding(.vertical, 6)
.padding(.horizontal, 10)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(task.isCompleted ? Color.green.opacity(0.1) : Color.clear)
)
// Animación del fondo
.animation(.easeInOut(duration: 0.3), value: task.isCompleted)
}
.buttonStyle(.plain)
}
}
// Widget con invalidación animada
struct AnimatedTaskWidget: Widget {
let kind: String = "AnimatedTaskWidget"
var body: some WidgetConfiguration {
StaticConfiguration(
kind: kind,
provider: TaskTimelineProvider()
) { entry in
AnimatedTaskWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Tareas Animadas")
.description("Widgets con animaciones suaves.")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
// Activación de las animaciones de contenido
.contentMarginsDisabled()
}
}
struct AnimatedTaskWidgetView: View {
let entry: TaskEntry
var body: some View {
VStack(alignment: .leading, spacing: 8) {
headerView
ForEach(entry.tasks) { task in
AnimatedTaskRowView(task: task)
}
Spacer(minLength: 0)
}
.padding()
}
private var headerView: some View {
HStack {
Text("Tareas")
.font(.headline.bold())
Spacer()
let completed = entry.tasks.filter(\.isCompleted).count
let total = entry.tasks.count
// Progreso animado
Text("\(completed)/\(total)")
.font(.caption.bold())
.foregroundStyle(.secondary)
.contentTransition(.numericText())
}
}
}Las animaciones .symbolEffect(.replace) y .numericText() crean transiciones suaves entre estados, mejorando significativamente la experiencia del usuario.
Widget Configurable con AppIntentConfiguration
Para los widgets personalizables por el usuario (filtros, categorías), AppIntentConfiguration sustituye a StaticConfiguration.
import WidgetKit
import SwiftUI
import AppIntents
// Configuración expuesta al usuario
struct TaskWidgetConfigurationIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Configuración de tareas"
static var description = IntentDescription("Personaliza la visualización de tareas.")
// Filtro por prioridad
@Parameter(title: "Prioridad", default: .all)
var priorityFilter: PriorityFilter
// Mostrar tareas completadas
@Parameter(title: "Mostrar completadas", default: true)
var showCompleted: Bool
// Número máximo de tareas
@Parameter(title: "Número de tareas", default: 3)
var maxTasks: Int
}
// Enum para el filtro de prioridad
enum PriorityFilter: String, AppEnum {
case all
case high
case medium
case low
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Prioridad"
static var caseDisplayRepresentations: [PriorityFilter: DisplayRepresentation] = [
.all: "Todas",
.high: "Alta",
.medium: "Media",
.low: "Baja"
]
}
// Provider adaptado a la configuración
struct ConfigurableTaskProvider: AppIntentTimelineProvider {
typealias Entry = TaskEntry
typealias Intent = TaskWidgetConfigurationIntent
func placeholder(in context: Context) -> TaskEntry {
TaskEntry(date: Date(), tasks: [])
}
func snapshot(for configuration: TaskWidgetConfigurationIntent, in context: Context) async -> TaskEntry {
let tasks = filteredTasks(for: configuration)
return TaskEntry(date: Date(), tasks: tasks)
}
func timeline(for configuration: TaskWidgetConfigurationIntent, in context: Context) async -> Timeline<TaskEntry> {
let tasks = filteredTasks(for: configuration)
let entry = TaskEntry(date: Date(), tasks: tasks)
let nextUpdate = Date().addingTimeInterval(15 * 60)
return Timeline(entries: [entry], policy: .after(nextUpdate))
}
// Aplica los filtros de configuración
private func filteredTasks(for config: TaskWidgetConfigurationIntent) -> [Task] {
var tasks = TaskDataManager.shared.fetchTasks()
// Filtrado por prioridad
if config.priorityFilter != .all {
let priority = Task.Priority(rawValue: config.priorityFilter.rawValue) ?? .medium
tasks = tasks.filter { $0.priority == priority }
}
// Filtrado de completadas si es necesario
if !config.showCompleted {
tasks = tasks.filter { !$0.isCompleted }
}
// Limitación del número
return Array(tasks.prefix(config.maxTasks))
}
}
// Widget con configuración del usuario
struct ConfigurableTaskWidget: Widget {
let kind: String = "ConfigurableTaskWidget"
var body: some WidgetConfiguration {
// AppIntentConfiguration para los widgets configurables
AppIntentConfiguration(
kind: kind,
intent: TaskWidgetConfigurationIntent.self,
provider: ConfigurableTaskProvider()
) { entry in
TaskWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Tareas Personalizadas")
.description("Filtra y personaliza tus tareas.")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}El usuario ahora puede configurar el widget mediante una pulsación larga, ofreciendo una experiencia personalizada sin código adicional en la aplicación.
Gestión de Errores y Estados de Carga
Una buena UX requiere gestionar los casos de error y los estados intermedios durante las interacciones.
import AppIntents
import WidgetKit
struct ToggleTaskWithFeedbackIntent: AppIntent {
static var title: LocalizedStringResource = "Alternar tarea con feedback"
@Parameter(title: "ID de la tarea")
var taskID: String
init() {}
init(taskID: UUID) {
self.taskID = taskID.uuidString
}
func perform() async throws -> some IntentResult & ReturnsValue<Bool> {
guard let uuid = UUID(uuidString: taskID) else {
// Devuelve un fallo silencioso
return .result(value: false)
}
// Simula una operación asíncrona (sincronización con servidor por ejemplo)
do {
try await Task.sleep(for: .milliseconds(100))
TaskDataManager.shared.toggleTaskCompletion(taskID: uuid)
WidgetCenter.shared.reloadTimelines(ofKind: "TaskWidget")
return .result(value: true)
} catch {
// Error: no actualizar el widget
return .result(value: false)
}
}
}
// Vista con estado de carga
struct TaskRowWithLoadingView: View {
let task: Task
@State private var isLoading = false
var body: some View {
Button(intent: ToggleTaskIntent(taskID: task.id)) {
HStack(spacing: 12) {
// Indicador condicional
Group {
if isLoading {
ProgressView()
.scaleEffect(0.8)
} else {
Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundStyle(task.isCompleted ? .green : .secondary)
}
}
.frame(width: 24, height: 24)
Text(task.title)
.font(.subheadline)
.strikethrough(task.isCompleted)
Spacer()
}
.padding(.vertical, 6)
.padding(.horizontal, 10)
.background(Color(.systemBackground).opacity(0.5))
.cornerRadius(8)
}
.buttonStyle(.plain)
.disabled(isLoading)
.opacity(isLoading ? 0.6 : 1.0)
}
}Un feedback visual inmediato (opacidad reducida, indicador de carga) informa al usuario de que su acción ha sido registrada.
Mejores Prácticas y Optimizaciones
Varios patrones aseguran widgets interactivos eficientes y fiables.
import WidgetKit
import SwiftUI
// 1. Invalidar siempre la caché tras una modificación
final class WidgetRefreshManager {
static func refreshAllWidgets() {
// Refresco de todos los widgets de la app
WidgetCenter.shared.reloadAllTimelines()
}
static func refreshWidget(kind: String) {
// Refresco de un widget específico
WidgetCenter.shared.reloadTimelines(ofKind: kind)
}
// Llamada desde la app tras una modificación de datos
static func notifyDataChanged() {
Task { @MainActor in
refreshAllWidgets()
}
}
}
// 2. Limitar la complejidad de las vistas
struct OptimizedWidgetView: View {
let entry: TaskEntry
var body: some View {
// Preferir vistas simples sin GeometryReader
VStack(alignment: .leading, spacing: 8) {
ForEach(entry.tasks.prefix(3)) { task in
// Componentes ligeros
minimalTaskRow(task)
}
}
.padding()
}
// Vista mínima para el rendimiento
@ViewBuilder
private func minimalTaskRow(_ task: Task) -> some View {
Button(intent: ToggleTaskIntent(taskID: task.id)) {
HStack {
Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
Text(task.title)
.lineLimit(1)
}
}
.buttonStyle(.plain)
}
}
// 3. Usar @AppStorage para los estados simples
struct QuickSettingsWidgetView: View {
// Acceso directo a los UserDefaults compartidos
@AppStorage("showCompletedTasks", store: UserDefaults(suiteName: "group.com.example.app"))
private var showCompleted = true
var body: some View {
// El estado persiste entre refrescos
Text(showCompleted ? "Mostrando todas" : "Ocultando completadas")
}
}
// 4. Pre-calcular los datos en el provider
struct OptimizedTaskEntry: TimelineEntry {
let date: Date
let tasks: [Task]
// Datos pre-calculados
let completedCount: Int
let pendingCount: Int
let highPriorityCount: Int
init(date: Date, tasks: [Task]) {
self.date = date
self.tasks = tasks
// Cálculos efectuados una sola vez
self.completedCount = tasks.filter(\.isCompleted).count
self.pendingCount = tasks.filter { !$0.isCompleted }.count
self.highPriorityCount = tasks.filter { $0.priority == .high && !$0.isCompleted }.count
}
}Estas optimizaciones garantizan widgets reactivos que no consumen excesivamente la batería.
Usar el scheme widget en Xcode para la depuración. La preview canvas permite probar los diferentes tamaños y estados sin instalación en el dispositivo.
Conclusión
WidgetKit iOS 17+ con App Intents transforma los widgets en verdaderas extensiones interactivas de las aplicaciones iOS. Esta arquitectura declarativa simplifica considerablemente el desarrollo ofreciendo una experiencia de usuario nativa y fluida.
Checklist Widget Interactivo iOS 17+
- ✅ Configurar un App Group para compartir datos
- ✅ Crear un Timeline Provider con refresco apropiado
- ✅ Implementar App Intents para cada acción
- ✅ Usar
Button(intent:)oToggle(intent:)para la interactividad - ✅ Llamar a
WidgetCenter.shared.reloadTimelinestras modificación - ✅ Añadir el
.containerBackgroundobligatorio para iOS 17+ - ✅ Implementar animaciones de transición suaves
- ✅ Gestionar los estados de carga y error
- ✅ Optimizar las vistas para el rendimiento de la batería
- ✅ Probar en todos los tamaños de widget compatibles
¡Empieza a practicar!
Pon a prueba tu conocimiento con nuestros simuladores de entrevista y tests técnicos.
Etiquetas
Compartir
Artículos relacionados

App Intents y Siri Shortcuts: automatización avanzada de iOS 2026
Guía completa de App Intents y Siri Shortcuts para iOS 18+. Crear acciones personalizadas para Siri, integrar Apple Intelligence y automatizar la app Swift en 2026.

Combine vs async/await en Swift: Patrones de Migración Progresiva
Guía completa para migrar de Combine a async/await en Swift: estrategias progresivas, patrones de puente y coexistencia de paradigmas en bases de código iOS.

Preguntas de entrevista sobre accesibilidad iOS en 2026: VoiceOver y Dynamic Type
Prepárate para entrevistas iOS con preguntas clave de accesibilidad: VoiceOver, Dynamic Type, traits semánticos y auditorías.