Виджеты, Live Activities и App Intents в MeteoHealth#
Долгое время «хорошее приложение» означало хороший экран внутри приложения. Открыл, увидел, нажал, закрыл. Начиная с iOS 16–18 это определение устарело: пользователь всё чаще взаимодействует с вашим кодом, вообще не открывая приложение — с домашнего экрана, из Dynamic Island, голосом через Siri или одним тапом в Пункте управления.
Когда я делал MeteoHealth — приложение, которое связывает погоду, сон, пульс и самочувствие, — стало ясно, что самая частая пользовательская задача («записать стакан воды», «глянуть, как дела с восстановлением», «сколько прошло тренировки») не заслуживает полноценного запуска приложения. Так в проекте появились интерактивные виджеты, Live Activities в Dynamic Island, команды для Siri через App Intents и полноценное Watch-приложение с усложнениями. В этой статье — как это устроено на уровне кода и архитектуры, и какие решения себя оправдали, а какие пришлось переделывать.
Почему это не «фичи для галочки»#
Три года назад виджет был статичной картинкой, которая обновлялась по таймеру раз в 15–30 минут. Сегодня, начиная с iOS 17, у виджетов появились кнопки и переключатели, которые выполняют код прямо на месте, без открытия приложения — через фреймворк App Intents. iOS 18 добавил к этому третий канал — контролы (Controls) в Пункте управления, на экране блокировки и на боковой кнопке действия.
Для приложения вроде MeteoHealth это не украшательство, а способ убрать трение между «я подумал сделать X» и «X сделано». Записать воду, посмотреть прогноз риска на 24 часа, увидеть пульс во время тренировки — всё это работает быстрее, если не требует запуска приложения.
Интерактивные виджеты: AppIntent прямо на домашнем экране#
Ключевая идея интерактивных виджетов — кнопка или переключатель в виджете не открывает приложение, а напрямую выполняет структуру, соответствующую протоколу AppIntent. Система сама решает, запускать ли ваш процесс в фоне или использовать общий контейнер данных — с точки зрения кода вы просто описываете, что должно произойти.
import AppIntents
import WidgetKit
struct LogWaterIntent: AppIntent {
static var title: LocalizedStringResource = "Log a Glass of Water"
static var description = IntentDescription("Adds 250 ml to today's water intake")
@Parameter(title: "Amount (ml)", default: 250)
var amountML: Int
func perform() async throws -> some IntentResult {
try await HydrationStore.shared.addWater(milliliters: amountML)
WidgetCenter.shared.reloadTimelines(ofKind: "HydrationWidget")
return .result()
}
}А вот как этот интент используется прямо в SwiftUI-разметке виджета — без делегатов, без openURL, без промежуточного экрана:
struct HydrationWidgetView: View {
var entry: HydrationEntry
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Water today")
.font(.caption)
.foregroundStyle(.secondary)
Text("\(entry.totalML) ml")
.font(.title2.bold())
Button(intent: LogWaterIntent(amountML: 250)) {
Label("+250 ml", systemImage: "drop.fill")
}
.buttonStyle(.borderedProminent)
.tint(.cyan)
}
.padding()
}
}Важная деталь, на которой я споткнулся при первой реализации: если хранилище данных приложения и виджета не синхронизировано (в MeteoHealth это App Group + Core Data, а не отдельная база), состояние виджета «отстаёт» от приложения после нажатия кнопки. Явный вызов WidgetCenter.shared.reloadTimelines внутри perform() — не опция, а обязательный шаг: без него UI виджета не узнает, что данные изменились, до следующего планового обновления таймлайна.
Live Activities и Dynamic Island: тренировка, которая всегда на виду#
Live Activities решают другую задачу — не «быстрое действие», а «непрерывно обновляемое состояние события с ограниченным временем жизни». В MeteoHealth это тренировки: пока идёт бег или силовая, на экране блокировки и в Dynamic Island держится живой пульс, длительность и калории — без разблокировки телефона.
Начинается всё с описания состояния через ActivityAttributes:
import ActivityKit
struct WorkoutAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
var heartRate: Int
var elapsedSeconds: Int
var caloriesBurned: Int
}
var workoutType: String
}Дальше — запуск активности из основного приложения (обычно в момент, когда пользователь стартовал тренировку на Apple Watch или в самом приложении):
func startWorkoutActivity(type: String) {
let attributes = WorkoutAttributes(workoutType: type)
let initialState = WorkoutAttributes.ContentState(
heartRate: 0,
elapsedSeconds: 0,
caloriesBurned: 0
)
do {
let activity = try Activity<WorkoutAttributes>.request(
attributes: attributes,
content: .init(state: initialState, staleDate: nil),
pushType: .token
)
print("Started Live Activity: \(activity.id)")
} catch {
print("Failed to start Live Activity: \(error)")
}
}И, наконец, сама раскладка для Dynamic Island — отдельно компактное, минимальное и развёрнутое представление:
struct WorkoutLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: WorkoutAttributes.self) { context in
WorkoutLockScreenView(context: context)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Label("\(context.state.heartRate)", systemImage: "heart.fill")
}
DynamicIslandExpandedRegion(.trailing) {
Text(context.state.elapsedSeconds.formattedDuration)
}
DynamicIslandExpandedRegion(.bottom) {
Text("\(context.state.caloriesBurned) kcal")
}
} compactLeading: {
Image(systemName: "heart.fill")
} compactTrailing: {
Text("\(context.state.heartRate)")
} minimal: {
Image(systemName: "heart.fill")
}
}
}
}Практический нюанс: обновлять ContentState десятки раз в секунду — плохая идея. Live Activity ограничивает частоту обновлений, и слишком частые вызовы система просто скидывает или объединяет. В MeteoHealth пульс в Dynamic Island обновляется раз в несколько секунд локально (пока приложение или Watch-компаньон активны) — этого достаточно, чтобы ощущение «живых данных» сохранялось, но не перегружает систему.
App Intents и Siri: «Привет, Siri, запиши стакан воды»#
Тот же протокол AppIntent, что питает кнопки виджетов, работает и как основа для голосовых команд, Быстрых команд (Shortcuts) и подсказок Spotlight. Разница — в обёртке AppShortcutsProvider, которая объявляет системе, какими фразами интент вызывается на естественном языке.
struct LogWaterGlassIntent: AppIntent {
static var title: LocalizedStringResource = "Log a Glass of Water"
static var openAppWhenRun = false
func perform() async throws -> some IntentResult & ProvidesDialog {
try await HydrationStore.shared.addWater(milliliters: 250)
return .result(dialog: "Logged a glass of water")
}
}
struct MeteoHealthShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: LogWaterGlassIntent(),
phrases: [
"Log a glass of water in \(.applicationName)",
"Add water in \(.applicationName)"
],
shortTitle: "Log Water",
systemImageName: "drop.fill"
)
}
}Флаг openAppWhenRun = false здесь принципиален: без него Siri сначала открывает приложение, а уже потом выполняет действие — тот самый лишний шаг, от которого мы и пытаемся избавиться. С false вся команда выполняется «на лету»: пользователь говорит фразу, слышит подтверждение через ProvidesDialog, и приложение при этом даже не появляется на экране.
Controls iOS 18: быстрые действия в Пункте управления#
Третий канал появился с iOS 18 — контролы (Controls), которые живут в Пункте управления, на экране блокировки и могут быть назначены на боковую кнопку действия. Технически это ещё один слой поверх WidgetKit, но с другим шаблоном отображения — ControlWidgetButton или переключатель — и с более строгими требованиями к мгновенности реакции.
struct HydrationControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(
kind: "com.meteohealth.hydration-control"
) {
ControlWidgetButton(action: LogWaterIntent(amountML: 250)) {
Label("Log Water", systemImage: "drop.fill")
}
}
.displayName("Log Water")
.description("Quickly add a glass of water from Control Center")
}
}Обратите внимание: используется тот же LogWaterIntent, что и в виджете. Это не совпадение, а осознанное решение — один AppIntent можно переиспользовать во всех трёх поверхностях (виджет, Siri-команда, контрол), не дублируя бизнес-логику. Именно так App Intents превращаются из «фичи для Siri» в единый слой действий приложения.
Watch-приложение и усложнения: тот же язык интентов на запястье#
В MeteoHealth есть полноценное приложение для Apple Watch, а не просто зеркало телефона — с усложнениями (complications) для разных циферблатов, показывающими текущий прогноз риска для самочувствия или пульс. Усложнения на watchOS используют ту же связку WidgetKit + timeline-провайдер, что и виджеты на iPhone, поэтому большая часть кода таймлайна пишется один раз и работает на обеих платформах с минимальными отличиями в вёрстке под форм-факторы циферблата.
Быстрая запись данных с запястья (например, отметить симптом или воду сразу после тренировки) сделана через те же AppIntent, что и на iPhone — SwiftUI-код специфичен для платформы, а бизнес-логика и структуры интентов общие. Это ощутимо сократило поверхность для багов: если HydrationStore.shared.addWater работает правильно один раз, он работает правильно везде, где вызывается.
Что выбрать: виджет, Live Activity или контрол#
Эти три механизма закрывают разные сценарии, и путаница между ними — частая причина, почему фичи не «взлетают» у пользователей.
| Критерий | Виджет (WidgetKit) | Live Activity (ActivityKit) | Контрол (iOS 18 Controls) |
|---|---|---|---|
| Когда использовать | Постоянная сводка данных, которая меняется медленно | Активное событие с ограниченным временем (тренировка, таймер, доставка) | Одно быстрое действие в один тап |
| Где отображается | Домашний экран, экран блокировки, StandBy | Экран блокировки, Dynamic Island | Пункт управления, экран блокировки, боковая кнопка |
| Время жизни | Часы и дни, обновляется по расписанию | Ограничено — активность скрывается после длительного простоя без обновлений | Постоянно на месте, состояние подтягивается по запросу |
| Обновление данных | Timeline-провайдер + WidgetCenter.reloadTimelines | Push через ActivityKit или локальное обновление ContentState | AppIntent, выполняемый по нажатию |
| Интерактивность | Кнопки и переключатели через AppIntent (iOS 17+) | В основном отображение, минимум интерактива | Полноценная — кнопки и переключатели как основная функция |
Практическое правило, которым я пользуюсь: если данные нужно увидеть — это виджет; если нужно следить за процессом, пока он идёт — Live Activity; если нужно сделать одно действие максимально быстро — контрол.
Что я вынес из этого проекта#
Главный урок MeteoHealth — все три механизма имеют смысл только тогда, когда они опираются на общий, уже существующий слой бизнес-логики. Я не писал отдельную реализацию «записи воды» для виджета, отдельную для Siri и отдельную для контрола — везде используется один и тот же AppIntent, а вся логика хранения данных живёт в общем App Group, доступном и приложению, и виджету, и усложнению на Watch.
Второй урок — синхронизация состояния. WidgetKit и ActivityKit не узнают об изменениях сами по себе: WidgetCenter.shared.reloadTimelines и обновление ContentState нужно вызывать явно, и именно здесь чаще всего рождаются баги вида «нажал — а на экране ничего не изменилось».
И третий — экосистема вокруг приложения работает только там, где действие пользователя действительно короткое: стакан воды, взгляд на прогноз, старт тренировки. Как только логика внутри perform() начинает требовать сложного UI или множества шагов — это сигнал, что фича должна остаться в приложении, а не переезжать в виджет или на голосовую команду.



