Виджеты появились в iOS 14 в 2020 году и с тех пор стали обязательной частью любого серьёзного приложения. Пользователи ставят их на домашний экран, на экран блокировки, в стек — и ожидают, что информация там будет актуальной. Но внутри WidgetKit устроен совсем не так, как кажется на первый взгляд. Разберём, как это работает и как не наступить на стандартные грабли.
Виджет — это не View, это снимок
Главное, что нужно понять про WidgetKit: виджет не работает в реальном времени. Система не держит его живым процессом, который что-то опрашивает и обновляется. Вместо этого ваше приложение заранее готовит набор «снимков» — состояний, которые виджет покажет в разные моменты времени. Система сама решает, когда и какой снимок отобразить.
Это называется Timeline — временная шкала. Вы говорите системе: «В 10:00 покажи это, в 10:30 — вот это, в 11:00 — вот это». Система кэширует всё заранее и показывает нужный снимок без запуска вашего кода.
Почему так? Виджеты на домашнем экране — их могут быть десятки от разных приложений. Если каждый будет жить своим процессом и постоянно что-то делать — батарея умрёт за несколько часов. Apple намеренно сделала виджеты «тупыми снимками» ради энергоэффективности.
Три кита WidgetKit
Вся архитектура держится на трёх компонентах:
TimelineEntry — это одно состояние виджета в конкретный момент времени. Минимум, что в нём есть — дата. Всё остальное вы добавляете сами:
struct WeatherEntry: TimelineEntry {
let date: Date
let temperature: Double
let condition: String
}
TimelineProvider — поставщик данных. Именно он формирует шкалу времени. У него три метода:
placeholder — заглушка, пока данные ещё не загрузились. Должна возвращаться мгновенно, без сетевых запросов.
getSnapshot — один снимок для превью в галерее виджетов. Тоже должен быть быстрым.
getTimeline — основной метод. Здесь вы загружаете данные и формируете массив Entry с датами.
struct WeatherProvider: TimelineProvider {
func placeholder(in context: Context) -> WeatherEntry {
WeatherEntry(date: Date(), temperature: 22, condition: "Sunny")
}
func getSnapshot(in context: Context, completion: @escaping (WeatherEntry) -> Void) {
completion(WeatherEntry(date: Date(), temperature: 22, condition: "Sunny"))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<WeatherEntry>) -> Void) {
// Загружаем данные с сервера
fetchWeather { weather in
var entries: [WeatherEntry] = []
let now = Date()
// Создаём снимки на следующие 5 часов с шагом 1 час
for hour in 0..<5 {
let entryDate = Calendar.current.date(byAdding: .hour, value: hour, to: now)!
entries.append(WeatherEntry(
date: entryDate,
temperature: weather.hourly[hour].temp,
condition: weather.hourly[hour].condition
))
}
// После последнего снимка — обновить
let refreshDate = Calendar.current.date(byAdding: .hour, value: 5, to: now)!
let timeline = Timeline(entries: entries, policy: .after(refreshDate))
completion(timeline)
}
}
}
Widget View — SwiftUI-вью, которое отображает один Entry. Важно: здесь нельзя использовать большинство интерактивных элементов, нет @State, нет анимаций в старом смысле. Только статичный рендер данных из Entry.
Политики обновления
В Timeline вы указываете policy — что делать, когда снимки закончились:
.atEnd — обновиться сразу после последнего снимка
.after(date) — обновиться в конкретное время
.never — не обновляться (для статичного контента)
Но вот нюанс: система не гарантирует точное время обновления. Если батарея садится, если телефон не использовался — обновление может задержаться. Apple называет это «budgeting» — каждому виджету выдаётся бюджет обновлений в день, и если вы его израсходовали, новые обновления придут позже.
Для большинства приложений достаточно обновляться раз в 15-30 минут. Если ваш контент не меняется так часто — не просите частых обновлений. Система всё равно не даст.
Из основного приложения можно принудительно запросить обновление виджета:
WidgetCenter.shared.reloadAllTimelines()
// или конкретный виджет по kind:
WidgetCenter.shared.reloadTimelines(ofKind: "WeatherWidget")
Это полезно, когда пользователь что-то изменил в приложении — например, сменил город в погодном виджете.
Размеры и адаптивность
Виджеты бывают нескольких размеров:
systemSmall — 2×2 ячейки
systemMedium — 4×2 ячейки
systemLarge — 4×4 ячейки
systemExtraLarge — только на iPad, 8×4
accessoryCircular, accessoryRectangular, accessoryInline — для экрана блокировки (с iOS 16)
Виджет для экрана блокировки работает в монохромном режиме — система сама применяет нужный цвет и материал. Не пытайтесь рисовать там цветные иконки, это выглядит плохо.
Внутри View вы можете узнать текущий размер через environment:
struct WeatherWidgetView: View {
@Environment(\.widgetFamily) var family
var entry: WeatherEntry
var body: some View {
switch family {
case .systemSmall:
SmallWeatherView(entry: entry)
case .systemMedium:
MediumWeatherView(entry: entry)
default:
LargeWeatherView(entry: entry)
}
}
}
Лучше делать отдельные вью для каждого размера — попытки «адаптировать» один макет часто выглядят криво.
Интерактивность в iOS 17
До iOS 17 виджеты были полностью статичными. Тап открывал приложение — и всё. В iOS 17 появились две важные вещи:
Button и Toggle — теперь можно добавить кнопки прямо в виджет. Они работают через AppIntent:
struct CompleteTaskIntent: AppIntent {
static var title: LocalizedStringResource = "Complete Task"
@Parameter(title: "Task ID")
var taskId: String
func perform() async throws -> some IntentResult {
// Помечаем задачу выполненной
TaskManager.shared.complete(id: taskId)
return .result()
}
}
// В виджете:
Button(intent: CompleteTaskIntent(taskId: task.id)) {
Image(systemName: "checkmark.circle")
}
После выполнения Intent система автоматически запрашивает обновление Timeline — виджет перерисовывается.
Animated transitions — в iOS 17 виджеты могут анимировать переходы между состояниями через .contentTransition и withAnimation. Но это именно переход между снимками, а не живая анимация.
Deeplink и навигация
Тап по виджету открывает приложение. По умолчанию — просто на главный экран. Чтобы открыть конкретный раздел, используйте .widgetURL:
var body: some View {
WeatherView(entry: entry)
.widgetURL(URL(string: "myapp://weather/\(entry.cityId)"))
}
Для systemMedium и systemLarge можно сделать разные зоны нажатия через Link:
HStack {
Link(destination: URL(string: "myapp://today")!) {
TodayView()
}
Link(destination: URL(string: "myapp://tomorrow")!) {
TomorrowView()
}
}
В приложении обрабатываете URL через onOpenURL или scene(_:openURLContexts:).
Конфигурируемые виджеты
Пользователь может настраивать виджет через долгое нажатие → «Редактировать виджет». Это работает через AppIntentConfiguration (iOS 17+) или старый IntentConfiguration.
С AppIntent это выглядит так:
struct WeatherWidgetConfiguration: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Weather Widget"
@Parameter(title: "City", default: "Moscow")
var city: String
}
struct WeatherWidget: Widget {
var body: some WidgetConfiguration {
AppIntentConfiguration(
kind: "WeatherWidget",
intent: WeatherWidgetConfiguration.self,
provider: WeatherProvider()
) { entry in
WeatherWidgetView(entry: entry)
}
.configurationDisplayName("Погода")
.description("Текущая погода в вашем городе")
}
}
В Provider получаете конфигурацию через configuration в параметре context.
Частые ошибки
Сетевые запросы в placeholder. Система вызывает placeholder синхронно для отображения заглушки. Если там сделать URLSession — это либо зависнет, либо вернёт пустые данные. Всегда возвращайте хардкод.
Слишком частое обновление. Разработчики иногда ставят обновление каждую минуту. Система это проигнорирует и будет обновлять реже. Ориентируйтесь на реальную частоту изменения данных.
Тяжёлые вычисления в Provider. У Provider ограниченное время на работу. Если за отведённое время не вернуть Timeline — виджет покажет ошибку. Тяжёлую обработку делайте в основном приложении, в виджет передавайте уже готовые данные.
Игнорирование темной темы. Виджет живёт и в светлой, и в тёмной теме. Используйте семантические цвета (Color(.systemBackground), Color(.label)) вместо Color.white и Color.black.
Один большой Widget вместо нескольких. Лучше зарегистрировать несколько виджетов с разными kind и функциями, чем пихать всё в один с кучей настроек.
Шаринг данных между приложением и виджетом
Виджет живёт в отдельном процессе и не имеет доступа к данным основного приложения напрямую. Для обмена данными используйте App Groups:
- В Xcode включите App Groups для обоих таргетов (приложение и Extension)
- Используйте
UserDefaults(suiteName:) или общий файловый контейнер:
// В основном приложении — сохраняем:
let sharedDefaults = UserDefaults(suiteName: "group.com.yourapp")
sharedDefaults?.set(temperature, forKey: "currentTemp")
// В виджете — читаем:
let sharedDefaults = UserDefaults(suiteName: "group.com.yourapp")
let temp = sharedDefaults?.double(forKey: "currentTemp") ?? 0
Для больших объёмов данных — пишите JSON-файл в общий контейнер через FileManager.containerURL(forSecurityApplicationGroupIdentifier:).
Тестирование
Тестировать виджет в симуляторе — то ещё удовольствие. Несколько советов:
- В симуляторе обновления могут вести себя иначе, чем на реальном устройстве. Критичные вещи проверяйте на железе.
- Используйте
WidgetCenter.shared.reloadAllTimelines() из приложения для принудительного обновления при разработке.
- Добавьте в Provider флаг для preview — возвращайте мок-данные без сетевых запросов.
- В Xcode есть отдельная схема для запуска Widget Extension — запускайте именно её, чтобы дебажить Provider.
Что стоит реализовать
Если вы делаете мобильное приложение и думаете, нужен ли виджет — почти всегда нужен. Пользователи, у которых установлен виджет, открывают приложение на 40-60% чаще (по данным нескольких публичных кейсов). Это потому, что виджет постоянно держит приложение в поле зрения.
Хорошие кандидаты для виджетов: трекеры привычек, погода, курсы валют, задачи на день, прогресс в каком-либо деле, ближайшие события в календаре.
Плохие кандидаты: всё, что требует частого ввода данных, сложных взаимодействий или работает только «внутри» приложения.
Если вы заказываете разработку iOS-приложения — уточняйте заранее, входит ли виджет в объём работ. В REEXY, например, при заказе мобильной разработки этот вопрос обсуждается на этапе постановки задачи, чтобы потом не возникало сюрпризов по срокам и стоимости.
Итого
WidgetKit требует немного другого мышления по сравнению с обычной разработкой SwiftUI-приложений. Главное — принять, что виджет работает с заранее подготовленными снимками, а не в реальном времени. Если это понять с самого начала, большинство вопросов отпадут сами.
Начните с малого: один размер виджета, минимум данных, простой UI. Потом добавите конфигурацию, разные размеры, интерактивность через AppIntent. Попытка сделать всё сразу обычно заканчивается переписыванием с нуля.