# WidgetKit iOS 17+: Інтерактивні Віджети з App Intents > Повний посібник зі створення інтерактивних iOS-віджетів з WidgetKit та App Intents. Кнопки, перемикачі, анімації та найкращі практики для iOS 17+ у 2026 році. - Published: 2026-03-04 - Updated: 2026-04-29 - Author: SharpSkill - Tags: widgetkit, ios, app-intents, swift, widgets - Reading time: 14 min --- iOS 17 здійснив революцію у WidgetKit, запровадивши нативну інтерактивність. Віджети більше не є статичними відображеннями: тепер вони можуть реагувати на дії користувача безпосередньо з головного екрана, не відкриваючи застосунок. Ця важлива еволюція ґрунтується на фреймворку App Intents і пропонує плавний, сучасний користувацький досвід. > **Що охоплює ця стаття** > > Ця стаття представляє повне створення інтерактивних віджетів iOS 17+, від конфігурації проєкту до просунутих патернів з анімаціями та керуванням станом. ## Архітектура Інтерактивних Віджетів Інтерактивність віджетів iOS 17+ працює через фреймворк App Intents. На відміну від традиційних deep-посилань, які відкривали б застосунок, App Intents дозволяють виконувати код безпосередньо з віджета, після чого автоматично оновлюють відображення з новими даними. ```swift // InteractiveWidgetArchitecture.swift import WidgetKit import SwiftUI import AppIntents // Архітектура базується на трьох головних компонентах: // 1. Widget Timeline Provider - постачає дані // 2. Widget View - відображає інтерфейс з Button/Toggle // 3. App Intent - виконує дію при дотику struct TaskWidget: Widget { // Унікальний ідентифікатор віджета let kind: String = "TaskWidget" var body: some WidgetConfiguration { // StaticConfiguration для віджетів без параметрів StaticConfiguration( kind: kind, provider: TaskTimelineProvider() ) { entry in TaskWidgetView(entry: entry) // Обов'язково для App Intents .containerBackground(.fill.tertiary, for: .widget) } .configurationDisplayName("Завдання") .description("Керуйте своїми завданнями з головного екрана.") .supportedFamilies([.systemSmall, .systemMedium]) } } ``` Віджет оголошує свою конфігурацію та вказує постачальника, який надаватиме дані. Атрибут `.containerBackground` обов'язковий з iOS 17 для інтерактивних віджетів. ## Створення Timeline Provider Timeline Provider визначає, коли і як віджет оновлюється. Для інтерактивних віджетів він також повинен реагувати на зміни, спричинені App Intents. ```swift // TaskTimelineProvider.swift import WidgetKit import SwiftUI // Entry, що представляє стан віджета у визначений момент struct TaskEntry: TimelineEntry { let date: Date let tasks: [Task] // Стан завантаження для візуального зворотного зв'язку var isLoading: Bool = false } // Модель даних, спільна між застосунком і віджетом 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, що відображається під час початкового завантаження func placeholder(in context: Context) -> TaskEntry { TaskEntry( date: Date(), tasks: [ Task(id: UUID(), title: "Приклад завдання", isCompleted: false, priority: .medium) ] ) } // Snapshot для галереї віджетів 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 з політикою оновлення func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { let tasks = TaskDataManager.shared.fetchTasks() let entry = TaskEntry(date: Date(), tasks: Array(tasks.prefix(3))) // Оновлення через 15 хвилин або після дії користувача let nextUpdate = Calendar.current.date( byAdding: .minute, value: 15, to: Date() ) ?? Date() let timeline = Timeline( entries: [entry], policy: .after(nextUpdate) ) completion(timeline) } } ``` Постачальник використовує спільний `TaskDataManager` для доступу до даних. Цей підхід гарантує синхронізацію між головним застосунком і віджетом. > **App Group обов'язковий** > > Для обміну даними між застосунком і віджетом потрібно налаштувати App Group у capabilities проєкту. UserDefaults або файли повинні використовувати цю спільну групу. ## Менеджер Спільних Даних Обмін даними між застосунком і віджетом вимагає спільного контейнера, доступного через App Group. ```swift // TaskDataManager.swift import Foundation final class TaskDataManager { // Singleton для глобального доступу static let shared = TaskDataManager() // Ідентифікатор App Group, налаштований у Xcode private let appGroupID = "group.com.example.taskapp" // UserDefaults, спільний між застосунком і віджетом private var sharedDefaults: UserDefaults? { UserDefaults(suiteName: appGroupID) } private let tasksKey = "tasks" private init() {} // Отримує завдання зі спільного сховища func fetchTasks() -> [Task] { guard let data = sharedDefaults?.data(forKey: tasksKey), let tasks = try? JSONDecoder().decode([Task].self, from: data) else { return [] } return tasks } // Зберігає зі сповіщенням віджета func saveTasks(_ tasks: [Task]) { guard let data = try? JSONEncoder().encode(tasks) else { return } sharedDefaults?.set(data, forKey: tasksKey) } // Оновлює конкретне завдання func updateTask(_ task: Task) { var tasks = fetchTasks() if let index = tasks.firstIndex(where: { $0.id == task.id }) { tasks[index] = task saveTasks(tasks) } } // Перемикає стан виконання func toggleTaskCompletion(taskID: UUID) { var tasks = fetchTasks() if let index = tasks.firstIndex(where: { $0.id == taskID }) { tasks[index].isCompleted.toggle() saveTasks(tasks) } } } ``` Цей менеджер інкапсулює всю логіку персистентності та використовуватиметься як застосунком, так і App Intents віджета. ## Створення App Intent для Інтерактивності App Intent визначає дію, яка виконується, коли користувач взаємодіє з віджетом. iOS виконує цю дію у фоновому режимі, а потім автоматично оновлює віджет. ```swift // ToggleTaskIntent.swift import AppIntents import WidgetKit // Intent для перемикання стану завдання struct ToggleTaskIntent: AppIntent { // Заголовок, що відображається у швидких командах Siri static var title: LocalizedStringResource = "Перемкнути стан завдання" // Опис для доступності static var description = IntentDescription("Позначає завдання як виконане або невиконане.") // Параметр: ID завдання для зміни @Parameter(title: "ID завдання") var taskID: String // Обов'язковий ініціалізатор для AppIntent init() {} // Ініціалізатор з параметром для створення з view init(taskID: UUID) { self.taskID = taskID.uuidString } // Виконання дії func perform() async throws -> some IntentResult { // Конвертація рядкового ID в UUID guard let uuid = UUID(uuidString: taskID) else { return .result() } // Оновлення завдання TaskDataManager.shared.toggleTaskCompletion(taskID: uuid) // Запит на оновлення віджета WidgetCenter.shared.reloadTimelines(ofKind: "TaskWidget") return .result() } } ``` Виклик `WidgetCenter.shared.reloadTimelines` спричиняє негайне оновлення віджета після дії, гарантуючи миттєвий візуальний зворотний зв'язок. ## View Віджета з Інтерактивними Кнопками View віджета використовує стандартний компонент SwiftUI `Button` з intent як дію. iOS 17+ автоматично перехоплює ці взаємодії для виконання App Intent. ```swift // TaskWidgetView.swift import SwiftUI import WidgetKit struct TaskWidgetView: View { let entry: TaskEntry // Адаптація до розміру віджета @Environment(\.widgetFamily) var family var body: some View { VStack(alignment: .leading, spacing: 8) { // Заголовок з назвою та лічильником headerView // Список завдань з інтерактивними кнопками ForEach(entry.tasks.prefix(tasksLimit)) { task in TaskRowView(task: task) } Spacer(minLength: 0) } .padding() } // Кількість завдань залежно від розміру private var tasksLimit: Int { switch family { case .systemSmall: return 2 case .systemMedium: return 3 default: return 4 } } private var headerView: some View { HStack { Text("Завдання") .font(.headline) .fontWeight(.bold) Spacer() // Бейдж з кількістю завдань, що залишилися 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 { // Кнопка з App Intent як дією Button(intent: ToggleTaskIntent(taskID: task.id)) { HStack(spacing: 12) { // Індикатор виконання Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle") .font(.title3) .foregroundStyle(task.isCompleted ? .green : .secondary) // Назва завдання Text(task.title) .font(.subheadline) .strikethrough(task.isCompleted) .foregroundStyle(task.isCompleted ? .secondary : .primary) .lineLimit(1) Spacer() // Індикатор пріоритету 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() } } } ``` Синтаксис `Button(intent:)` напряму з'єднує кнопку з App Intent. При дотику iOS виконує `perform()`, а потім автоматично оновлює віджет. ## Інтерактивний Toggle для Віджетів Для дій типу увімк/вимк компонент `Toggle` пропонує альтернативу кнопці з нативним стилем iOS. ```swift // ToggleWidgetView.swift import SwiftUI import AppIntents // Специфічний intent для Toggle з явним станом struct SetTaskCompletionIntent: AppIntent { static var title: LocalizedStringResource = "Встановити стан завдання" @Parameter(title: "ID завдання") var taskID: String // Цільовий стан: true = виконано, false = не виконано @Parameter(title: "Виконано") 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 }) { // Встановлює стан явно (не 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 з intent Toggle( isOn: task.isCompleted, intent: SetTaskCompletionIntent( taskID: task.id, isCompleted: !task.isCompleted ) ) .toggleStyle(.switch) .labelsHidden() } .padding(.vertical, 4) } } ``` Toggle забезпечує більш інтуїтивну взаємодію для бінарних станів і природно інтегрується в дизайни iOS. > **Обмеження інтерактивних віджетів** > > Віджети не можуть відображати alerts, sheet або навігацію. Усі дії повинні бути автономними та оновлювати видимий стан безпосередньо. ## Анімації Оновлення та Переходи iOS 17+ дозволяє анімувати переходи під час оновлення віджета після дії. Модифікатор `.contentTransition` керує цими анімаціями. ```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) { // Іконка з анімацією переходу Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle") .font(.title3) .foregroundStyle(task.isCompleted ? .green : .secondary) // Анімація іконки при зміні .contentTransition(.symbolEffect(.replace)) Text(task.title) .font(.subheadline) .strikethrough(task.isCompleted) .foregroundStyle(task.isCompleted ? .secondary : .primary) // Анімація тексту .contentTransition(.opacity) Spacer() } .padding(.vertical, 6) .padding(.horizontal, 10) .background( RoundedRectangle(cornerRadius: 8) .fill(task.isCompleted ? Color.green.opacity(0.1) : Color.clear) ) // Анімація фону .animation(.easeInOut(duration: 0.3), value: task.isCompleted) } .buttonStyle(.plain) } } // Віджет з анімованою інвалідацією 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("Анімовані Завдання") .description("Віджети з плавними анімаціями.") .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) // Активація анімацій вмісту .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("Завдання") .font(.headline.bold()) Spacer() let completed = entry.tasks.filter(\.isCompleted).count let total = entry.tasks.count // Анімований прогрес Text("\(completed)/\(total)") .font(.caption.bold()) .foregroundStyle(.secondary) .contentTransition(.numericText()) } } } ``` Анімації `.symbolEffect(.replace)` та `.numericText()` створюють плавні переходи між станами, значно покращуючи користувацький досвід. ## Налаштовуваний Віджет з AppIntentConfiguration Для віджетів, що налаштовуються користувачем (фільтри, категорії), AppIntentConfiguration замінює StaticConfiguration. ```swift // ConfigurableTaskWidget.swift import WidgetKit import SwiftUI import AppIntents // Конфігурація, доступна користувачу struct TaskWidgetConfigurationIntent: WidgetConfigurationIntent { static var title: LocalizedStringResource = "Конфігурація завдань" static var description = IntentDescription("Налаштуйте відображення завдань.") // Фільтр за пріоритетом @Parameter(title: "Пріоритет", default: .all) var priorityFilter: PriorityFilter // Показати виконані завдання @Parameter(title: "Показати виконані", default: true) var showCompleted: Bool // Максимальна кількість завдань @Parameter(title: "Кількість завдань", default: 3) var maxTasks: Int } // Enum для фільтра пріоритету enum PriorityFilter: String, AppEnum { case all case high case medium case low static var typeDisplayRepresentation: TypeDisplayRepresentation = "Пріоритет" static var caseDisplayRepresentations: [PriorityFilter: DisplayRepresentation] = [ .all: "Усі", .high: "Високий", .medium: "Середній", .low: "Низький" ] } // Постачальник, адаптований до конфігурації 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)) } // Застосовує фільтри конфігурації private func filteredTasks(for config: TaskWidgetConfigurationIntent) -> [Task] { var tasks = TaskDataManager.shared.fetchTasks() // Фільтрація за пріоритетом if config.priorityFilter != .all { let priority = Task.Priority(rawValue: config.priorityFilter.rawValue) ?? .medium tasks = tasks.filter { $0.priority == priority } } // Фільтрація виконаних за необхідності if !config.showCompleted { tasks = tasks.filter { !$0.isCompleted } } // Обмеження кількості return Array(tasks.prefix(config.maxTasks)) } } // Віджет з конфігурацією користувача struct ConfigurableTaskWidget: Widget { let kind: String = "ConfigurableTaskWidget" var body: some WidgetConfiguration { // AppIntentConfiguration для налаштовуваних віджетів AppIntentConfiguration( kind: kind, intent: TaskWidgetConfigurationIntent.self, provider: ConfigurableTaskProvider() ) { entry in TaskWidgetView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) } .configurationDisplayName("Персоналізовані Завдання") .description("Фільтруйте та персоналізуйте свої завдання.") .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) } } ``` Користувач тепер може налаштувати віджет довгим натисканням, отримуючи персоналізований досвід без додаткового коду в застосунку. ## Обробка Помилок та Стани Завантаження Хороший UX вимагає обробки випадків помилок та проміжних станів під час взаємодії. ```swift // TaskIntentWithFeedback.swift import AppIntents import WidgetKit struct ToggleTaskWithFeedbackIntent: AppIntent { static var title: LocalizedStringResource = "Перемкнути завдання зі зворотним зв'язком" @Parameter(title: "ID завдання") 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 { // Повертає тиху помилку return .result(value: false) } // Симулює асинхронну операцію (наприклад, синхронізація з сервером) do { try await Task.sleep(for: .milliseconds(100)) TaskDataManager.shared.toggleTaskCompletion(taskID: uuid) WidgetCenter.shared.reloadTimelines(ofKind: "TaskWidget") return .result(value: true) } catch { // Помилка: не оновлювати віджет return .result(value: false) } } } // View зі станом завантаження struct TaskRowWithLoadingView: View { let task: Task @State private var isLoading = false var body: some View { Button(intent: ToggleTaskIntent(taskID: task.id)) { HStack(spacing: 12) { // Умовний індикатор 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) } } ``` Негайний візуальний зворотний зв'язок (зменшена прозорість, індикатор завантаження) повідомляє користувачу, що його дія зареєстрована. ## Найкращі Практики та Оптимізації Кілька патернів забезпечують продуктивні та надійні інтерактивні віджети. ```swift // WidgetBestPractices.swift import WidgetKit import SwiftUI // 1. Завжди інвалідувати кеш після модифікації final class WidgetRefreshManager { static func refreshAllWidgets() { // Оновлення всіх віджетів застосунку WidgetCenter.shared.reloadAllTimelines() } static func refreshWidget(kind: String) { // Оновлення конкретного віджета WidgetCenter.shared.reloadTimelines(ofKind: kind) } // Виклик з застосунку після модифікації даних static func notifyDataChanged() { Task { @MainActor in refreshAllWidgets() } } } // 2. Обмежити складність view struct OptimizedWidgetView: View { let entry: TaskEntry var body: some View { // Надавати перевагу простим view без GeometryReader VStack(alignment: .leading, spacing: 8) { ForEach(entry.tasks.prefix(3)) { task in // Легкі компоненти minimalTaskRow(task) } } .padding() } // Мінімальна view для продуктивності @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. Використовувати @AppStorage для простих станів struct QuickSettingsWidgetView: View { // Прямий доступ до спільних UserDefaults @AppStorage("showCompletedTasks", store: UserDefaults(suiteName: "group.com.example.app")) private var showCompleted = true var body: some View { // Стан зберігається між оновленнями Text(showCompleted ? "Показую всі" : "Приховую виконані") } } // 4. Попередньо обчислювати дані в постачальнику struct OptimizedTaskEntry: TimelineEntry { let date: Date let tasks: [Task] // Попередньо обчислені дані let completedCount: Int let pendingCount: Int let highPriorityCount: Int init(date: Date, tasks: [Task]) { self.date = date self.tasks = tasks // Обчислення виконуються один раз self.completedCount = tasks.filter(\.isCompleted).count self.pendingCount = tasks.filter { !$0.isCompleted }.count self.highPriorityCount = tasks.filter { $0.priority == .high && !$0.isCompleted }.count } } ``` Ці оптимізації забезпечують реактивні віджети, які надмірно не споживають батарею. > **Налагодження віджетів** > > Використовуйте схему widget у Xcode для налагодження. Canvas preview дозволяє тестувати різні розміри та стани без встановлення на пристрій. ## Висновок WidgetKit iOS 17+ з App Intents перетворює віджети на справжні інтерактивні розширення застосунків iOS. Ця декларативна архітектура значно спрощує розробку, пропонуючи нативний та плавний користувацький досвід. ### Чек-лист Інтерактивного Віджета iOS 17+ - ✅ Налаштувати App Group для обміну даними - ✅ Створити Timeline Provider з відповідним оновленням - ✅ Реалізувати App Intents для кожної дії - ✅ Використовувати `Button(intent:)` або `Toggle(intent:)` для інтерактивності - ✅ Викликати `WidgetCenter.shared.reloadTimelines` після модифікації - ✅ Додати обов'язковий `.containerBackground` для iOS 17+ - ✅ Реалізувати плавні анімації переходу - ✅ Обробляти стани завантаження та помилок - ✅ Оптимізувати view для продуктивності батареї - ✅ Тестувати на всіх підтримуваних розмірах віджетів --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/uk/blog/ios/widgetkit-ios17-interactive-widgets-app-intents