Виджеты появились в 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:

  1. В Xcode включите App Groups для обоих таргетов (приложение и Extension)
  2. Используйте 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. Попытка сделать всё сразу обычно заканчивается переписыванием с нуля.