WidgetKit iOS 17+: Інтерактивні Віджети з App Intents
Повний посібник зі створення інтерактивних iOS-віджетів з WidgetKit та App Intents. Кнопки, перемикачі, анімації та найкращі практики для iOS 17+ у 2026 році.

iOS 17 здійснив революцію у WidgetKit, запровадивши нативну інтерактивність. Віджети більше не є статичними відображеннями: тепер вони можуть реагувати на дії користувача безпосередньо з головного екрана, не відкриваючи застосунок. Ця важлива еволюція ґрунтується на фреймворку App Intents і пропонує плавний, сучасний користувацький досвід.
Ця стаття представляє повне створення інтерактивних віджетів iOS 17+, від конфігурації проєкту до просунутих патернів з анімаціями та керуванням станом.
Архітектура Інтерактивних Віджетів
Інтерактивність віджетів iOS 17+ працює через фреймворк App Intents. На відміну від традиційних deep-посилань, які відкривали б застосунок, App Intents дозволяють виконувати код безпосередньо з віджета, після чого автоматично оновлюють відображення з новими даними.
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.
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<TaskEntry>) -> 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 у capabilities проєкту. UserDefaults або файли повинні використовувати цю спільну групу.
Менеджер Спільних Даних
Обмін даними між застосунком і віджетом вимагає спільного контейнера, доступного через App Group.
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 виконує цю дію у фоновому режимі, а потім автоматично оновлює віджет.
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 спричиняє негайне оновлення віджета після дії, гарантуючи миттєвий візуальний зворотний зв'язок.
Готовий до співбесід з iOS?
Практикуйся з нашими інтерактивними симуляторами, flashcards та технічними тестами.
View Віджета з Інтерактивними Кнопками
View віджета використовує стандартний компонент SwiftUI Button з intent як дію. iOS 17+ автоматично перехоплює ці взаємодії для виконання App Intent.
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.
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 керує цими анімаціями.
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.
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<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))
}
// Застосовує фільтри конфігурації
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 вимагає обробки випадків помилок та проміжних станів під час взаємодії.
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<Bool> {
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)
}
}Негайний візуальний зворотний зв'язок (зменшена прозорість, індикатор завантаження) повідомляє користувачу, що його дія зареєстрована.
Найкращі Практики та Оптимізації
Кілька патернів забезпечують продуктивні та надійні інтерактивні віджети.
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 для продуктивності батареї
- ✅ Тестувати на всіх підтримуваних розмірах віджетів
Починай практикувати!
Перевір свої знання з нашими симуляторами співбесід та технічними тестами.
Теги
Поділитися
Пов'язані статті

App Intents та Siri Shortcuts: розширена автоматизація iOS 2026
Повний посібник з App Intents та Siri Shortcuts для iOS 18+. Створення власних дій Siri, інтеграція Apple Intelligence та автоматизація Swift-застосунку у 2026.

Combine vs async/await у Swift: Шаблони Прогресивної Міграції
Повний посібник з міграції з Combine на async/await у Swift: прогресивні стратегії, шаблони мостування та співіснування парадигм у iOS-кодових базах.

Питання співбесід з доступності iOS у 2026: VoiceOver і Dynamic Type
Підготовка до співбесід з iOS із ключовими питаннями про доступність: VoiceOver, Dynamic Type, семантичні traits та аудити.