# 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. - Published: 2026-03-04 - Updated: 2026-04-29 - Author: SharpSkill - Tags: widgetkit, ios, app-intents, swift, widgets - Reading time: 14 min --- 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. > **Lo que cubre este artículo** > > 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. ```swift // InteractiveWidgetArchitecture.swift 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. ```swift // TaskTimelineProvider.swift 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) -> 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. > **App Group obligatorio** > > 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. ```swift // TaskDataManager.swift 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. ```swift // ToggleTaskIntent.swift 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. ## 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. ```swift // TaskWidgetView.swift 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. ```swift // ToggleWidgetView.swift 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. > **Limitaciones de los widgets interactivos** > > 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. ```swift // AnimatedTaskWidgetView.swift 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. ```swift // ConfigurableTaskWidget.swift 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 { 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. ```swift // TaskIntentWithFeedback.swift 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 { 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. ```swift // WidgetBestPractices.swift 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. > **Depuración de los widgets** > > 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:)` o `Toggle(intent:)` para la interactividad - ✅ Llamar a `WidgetCenter.shared.reloadTimelines` tras modificación - ✅ Añadir el `.containerBackground` obligatorio 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 --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/es/blog/ios/widgetkit-ios17-interactive-widgets-app-intents