Когато Apple представи App Intents на WWDC22, идеята беше съвсем проста: дайте на системата директен достъп до действията във вашето приложение, за да могат Siri, Spotlight, Shortcuts и Widgets да ги изпълняват, без изобщо да отварят интерфейса. Хубава идея — но честно казано, в първите версии беше малко тромаво. С iOS 26 рамката получи най-сериозното си обновление досега, и то се казва Interactive Snippets.
Това вече не са онези статични картончета за потвърждение, които всички помним. Това са пълноценни SwiftUI прозорци, които задържат потребителя във Spotlight или Shortcuts, докато той завършва цял работен поток.
В това ръководство ще разгледаме новия SnippetIntent протокол, метода reload(), верижните потвърждения чрез requestConfirmation и ще изградим завършен пример със SwiftUI. Целта ми е проста — след като прочетете статията, да можете да добавите интерактивен снипет към собственото си приложение в рамките на час. Може би два, ако сте перфекционист.
Какво представляват App Intents и защо iOS 26 променя правилата
App Intents е унифициран начин да изложите функционалността на приложението си пред операционната система. Един AppIntent е struct, който системата може да извика — от Siri чрез гласов вход, от Spotlight като резултат от търсене, от Shortcuts като стъпка в автоматизация, или от Apple Intelligence като контекстно предложение. Накратко: едно действие, много входни точки.
До iOS 18 App Intents можеха да връщат прости резултати или статични Snippet изгледи. Системата ги рисуваше в опростен формат — заглавие, текст, може би икона. Това вършеше работа за нещо като «покажи ми днешната задача», но беше задънена улица за всичко интерактивно. (А днес кой иска статичен изглед, наистина?)
В iOS 26 Apple въведе три ключови подобрения:
- SnippetIntent — нов протокол, който връща истински SwiftUI изглед, включително бутони, тогли и анимации.
- reload() — статичен метод, който позволява на снипета да се преизчертае при промяна на данните.
- requestConfirmation(actionName:snippetIntent:) — асинхронен метод, който верижно свързва снипети в магьоснически (wizard) потоци.
Резултатът? Можете да изградите цял мини-flow — например брояч на кофеин, бърза поръчка или пътеводител за настройка — който да живее изцяло в Spotlight или в листа на Siri, без потребителят да отваря приложението ви. Това е голямо нещо, особено ако държите на retention метрики извън собствения си UI.
Предварителни изисквания
- Xcode 26 или по-нов
- iOS 26 SDK (за SnippetIntent API)
- Swift 6.0+ със строга конкурентност
- Базови познания за SwiftUI и
async/await
Стъпка 1: Създаване на първи AppIntent
Преди да стигнем до интерактивните снипети, нека започнем с обикновен AppIntent. Той ще ни послужи като входна точка — потребителят ще го извика през Shortcuts или Spotlight, а той ще отвори нашия снипет.
import AppIntents
import SwiftUI
struct OpenCoffeeCounterIntent: AppIntent {
static let title: LocalizedStringResource = "Отвори броя на кафетата"
static let description = IntentDescription(
"Показва интерактивен брояч на дневния прием на кофеин."
)
// Указва, че интентът ще покаже снипет вместо обикновен резултат.
func perform() async throws -> some ShowsSnippetIntent {
.result(snippetIntent: CoffeeCounterSnippetIntent())
}
}
Обърнете внимание на типа на връщане: some ShowsSnippetIntent. Това казва на системата, че този интент не само ще върне стойност, но и ще покаже отделен SnippetIntent. Малък детайл, но е лесно да се пропусне първия път (говоря от опит).
Стъпка 2: Дефиниране на SnippetIntent
SnippetIntent е сърцето на цялата интерактивна функционалност. Той държи логиката за извличане на данни и връща SwiftUI изглед — и това е горе-долу всичко, което трябва да прави.
struct CoffeeCounterSnippetIntent: SnippetIntent {
static let title: LocalizedStringResource = "Брояч на кафе"
static let description = IntentDescription(
"Интерактивен брояч на дневния прием на кофеин."
)
// Зависимостта се регистрира в AppDependencyManager.
@Dependency var store: CoffeeStore
@MainActor
func perform() async throws -> some IntentResult & ShowsSnippetView {
.result(view: CoffeeCounterView(count: store.todayCount))
}
}
Три неща са важни тук:
- Връщаемият тип е
some IntentResult & ShowsSnippetView — комбинация от два протокола.
@Dependency позволява на интента да достъпи споделено състояние (модел, репозитори), без да го предава експлицитно.
- Маркираме
perform() с @MainActor, защото създаваме SwiftUI изглед.
Стъпка 3: Регистрация на зависимостите
За да работи @Dependency, трябва да регистрирате обекта в AppDependencyManager — обикновено при старт на приложението. Това е лесна стъпка, но ако я забравите, ще получите доста objaśняваща грешка по-късно.
@Observable
final class CoffeeStore: Sendable {
private(set) var todayCount: Int = 0
@MainActor
func increment() {
todayCount += 1
}
@MainActor
func decrement() {
todayCount = max(0, todayCount - 1)
}
}
@main
struct CoffeeApp: App {
init() {
AppDependencyManager.shared.add(dependency: CoffeeStore())
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Стъпка 4: SwiftUI изгледът на снипета
Изгледът е обикновен SwiftUI View, но бутоните вътре са специални — те задействат AppIntent, а не обикновени затваряния. Така системата знае кога точно да презареди снипета.
struct CoffeeCounterView: View {
let count: Int
var body: some View {
VStack(spacing: 16) {
Text("Днес: \(count) кафета")
.font(.title2.bold())
.contentTransition(.numericText())
HStack(spacing: 24) {
Button(intent: DecrementCoffeeIntent()) {
Image(systemName: "minus.circle.fill")
.font(.largeTitle)
}
Button(intent: IncrementCoffeeIntent()) {
Image(systemName: "plus.circle.fill")
.font(.largeTitle)
}
}
}
.padding()
}
}
Бутонните интенти
Това са малки AppIntent-и, чиято единствена работа е да мутират състоянието и да задействат reload():
struct IncrementCoffeeIntent: AppIntent {
static let title: LocalizedStringResource = "Увеличи кафето"
static let isDiscoverable = false // Не се показва в Shortcuts.
@Dependency var store: CoffeeStore
@MainActor
func perform() async throws -> some IntentResult {
store.increment()
try await CoffeeCounterSnippetIntent.reload()
return .result()
}
}
struct DecrementCoffeeIntent: AppIntent {
static let title: LocalizedStringResource = "Намали кафето"
static let isDiscoverable = false
@Dependency var store: CoffeeStore
@MainActor
func perform() async throws -> some IntentResult {
store.decrement()
try await CoffeeCounterSnippetIntent.reload()
return .result()
}
}
Маркирането на isDiscoverable = false е важно — тези интенти нямат смисъл извън снипета, така че не искаме да затрупваме Shortcuts с тях. Никой не иска да види «Увеличи кафето» като опция в Shortcuts. Серио.
Как работи reload() под капака
Когато извикате CoffeeCounterSnippetIntent.reload(), системата прави следното:
- Презарежда всички
@Parameter стойности (включително AppEntity заявки).
- Извиква наново
perform() на снипет интента.
- Преизчертава SwiftUI изгледа с новите данни.
Това означава, че perform() на SnippetIntent трябва да е лек и без странични ефекти. Той ще се изпълни многократно през живота на снипета. Цялата мутация на състоянието трябва да става в бутонните интенти — снипет интентът само чете и рендира. Това е разделение, което Apple очевидно е заимствала от unidirectional data flow патерните, и честно казано, работи много добре.
Верижни снипети с requestConfirmation
Истинската сила на iOS 26 идва от requestConfirmation(actionName:snippetIntent:). Този асинхронен метод позволява един интент да изчака потвърждение от потребителя, преди да продължи към следващия снипет.
struct OrderCoffeeFlowIntent: AppIntent {
static let title: LocalizedStringResource = "Поръчай кафе"
@MainActor
func perform() async throws -> some IntentResult {
// Стъпка 1: Избор на размер.
try await requestConfirmation(
actionName: .continue,
snippetIntent: SizePickerSnippetIntent()
)
// Стъпка 2: Избор на добавки.
try await requestConfirmation(
actionName: .continue,
snippetIntent: AddOnsSnippetIntent()
)
// Стъпка 3: Финално потвърждение.
try await requestConfirmation(
actionName: .placeOrder,
snippetIntent: ReviewOrderSnippetIntent()
)
return .result(dialog: "Поръчката е изпратена!")
}
}
Всеки снипет в потока има собствен SnippetIntent и собствен SwiftUI изглед. Потребителят може да върне назад, да отмени или да премине към следващата стъпка — всичко без да напуска Spotlight или Siri. Това е една от онези функции, които изглеждат малко магически първия път, когато ги видиш в действие.
Параметри и AppEntity
Ако снипет интентът ви се нуждае от данни, които идват отвън (например конкретна задача или продукт), използвайте @Parameter:
struct TaskDetailSnippetIntent: SnippetIntent {
static let title: LocalizedStringResource = "Детайл на задача"
@Parameter(title: "Задача")
var task: TaskEntity
@MainActor
func perform() async throws -> some IntentResult & ShowsSnippetView {
.result(view: TaskDetailView(task: task))
}
}
При всяко извикване на reload() системата ще опресни task чрез EntityQuery, така че винаги ще имате най-новите данни. Без ръчни refresh трикове, без manual invalidation — просто работи.
Тестване в Xcode 26
Xcode 26 предлага два основни начина за тестване на снипетите:
- Shortcuts приложение — създайте бърз ярлък, който извиква вашия
OpenCoffeeCounterIntent, и го стартирайте. Снипетът ще се появи директно в Shortcuts UI.
- Spotlight на macOS Tahoe — ако приложението ви има macOS таргет, въведете името на интента в Spotlight и натиснете Enter. Снипетът се рендира директно в резултатите.
За юнит тестване извикайте perform() ръчно и проверете типа на връщания резултат:
import Testing
@testable import CoffeeApp
@Test func snippetReturnsCurrentCount() async throws {
let store = CoffeeStore()
await store.increment()
await store.increment()
AppDependencyManager.shared.add(dependency: store)
let intent = CoffeeCounterSnippetIntent()
let result = try await intent.perform()
#expect(result != nil)
}
Добри практики
- Съхранявайте състоянието извън снипет интента. Използвайте
@Dependency за общ модел; никога не дръжте mutable state в самия SnippetIntent struct.
- Маркирайте помощните интенти с
isDiscoverable = false. Иначе ще задръстите Shortcuts със стотици вътрешни действия — не е забавна ситуация.
- Дръжте
perform() бърз. Той се изпълнява при всяко reload() — тежки I/O операции трябва да са кеширани в @Dependency обекта.
- Анимирайте промените. SwiftUI модификатори като
.contentTransition(.numericText()) правят броячите видимо живи в снипета.
- Тествайте с VoiceOver. Снипетите се извикват често чрез Siri — достъпността не е по избор, тя е изискване.
Често срещани грешки и решенията им
«@Dependency was used before being registered»
Уверете се, че AppDependencyManager.shared.add(dependency:) се извиква в init() на вашата App структура, преди системата да може да достигне до интента. Това е грешка номер едно при първия пуск.
Снипетът не се преизчертава при натискане на бутон
Проверете, че бутоните използват инициализатора Button(intent:), а не обикновен Button(action:). Само бутоните, свързани с интент, задействат reload(). Лесна за пропускане разлика.
Snippet се появява празен в Shortcuts
Това обикновено означава, че perform() хвърля грешка преди да върне изглед. Добавете print или breakpoint в perform(), за да диагностицирате.
Често задавани въпроси
Каква е разликата между AppIntent и SnippetIntent?
AppIntent е общ интерфейс за действие, което системата може да изпълни — то връща данни, диалог или просто завършва. SnippetIntent е специализиран подтип, който връща SwiftUI изглед и поддържа интерактивност чрез reload(). Винаги започвате с AppIntent, който потенциално връща SnippetIntent, ако искате визуален резултат.
Работят ли Interactive Snippets на iOS 17 и iOS 18?
Не. SnippetIntent и методът reload() са въведени с iOS 26 SDK. На по-стари версии можете да използвате стария IntentSnippetView протокол, но без интерактивност. Ако трябва да поддържате и двете, използвайте @available(iOS 26, *), за да изолирате новия код.
Мога ли да използвам Interactive Snippets в widgets?
Не директно. Widgets имат собствен модел за интерактивност чрез AppIntent бутони (от iOS 17). Interactive Snippets са замислени за Spotlight, Siri и Shortcuts. Една и съща AppIntent структура обаче може да се използва и в двата контекста — което е страхотно за code reuse.
Колко изгледа мога да верижно свържа с requestConfirmation?
Apple не налага твърд лимит, но препоръчва не повече от 3-4 стъпки в един поток. След това потребителското изживяване страда — ако ви трябват повече стъпки, помислете да отворите цялото приложение чрез OpenAppIntent. Понякога snippet просто не е правилният инструмент.
Как се ползва Apple Intelligence със снипетите?
В iOS 26 системата използва контекстни сигнали (време, локация, история на употреба), за да предлага интенти проактивно. Ако маркирате интент като isDiscoverable = true и предоставите богат IntentDescription, Apple Intelligence може да го предложи в Siri Suggestions или като подсказка в Spotlight.
Заключение
Interactive Snippets превръщат App Intents от прост мост към Shortcuts в пълноценна платформа за микро-преживявания. С SnippetIntent, reload() и requestConfirmation можете да изградите цели потоци, които живеят извън вашето приложение — точно там, където потребителите вече прекарват време: в Spotlight, Siri и Shortcuts.
Личният ми съвет? Започнете с прост брояч като примера по-горе, тествайте го в Shortcuts, после постепенно добавяйте верижни снипети и параметри. След няколко итерации ще имате функция, която изглежда сякаш е част от самата система — а не нещо, заключено в иконата на приложението ви. И това, в крайна сметка, е цялата идея.