Giới Thiệu: Ứng Dụng Của Bạn Giờ Đây Sống Ngoài Chính Nó
Nếu bạn là iOS developer, chắc hẳn bạn đã từng trải qua cảm giác này: bỏ công bỏ sức xây dựng những tính năng cực kỳ tuyệt vời bên trong app, nhưng rồi... người dùng phải mở app lên mới sử dụng được. Hơi phí đúng không?
Với iOS 26, Apple đã thay đổi hoàn toàn cuộc chơi bằng Interactive Snippet Intents — cho phép bạn hiển thị giao diện SwiftUI tương tác trực tiếp trong Siri, Shortcuts và Spotlight, mà người dùng không cần mở app.
Đây không phải kiểu "gửi lệnh rồi ngồi chờ kết quả" như trước nữa. Người dùng có thể tương tác liên tục — bấm nút, chọn tùy chọn, xem kết quả cập nhật real-time — tất cả ngay trong Siri hoặc Shortcuts. Hãy hình dung thế này: bạn có một ứng dụng theo dõi caffeine, người dùng hỏi Siri "Hôm nay mình uống bao nhiêu caffeine?", và Siri không chỉ trả lời con số khô khan mà còn hiển thị một giao diện SwiftUI hoàn chỉnh với các nút "Single Shot", "Double Shot" để log ngay lập tức. Khá ấn tượng phải không?
Trong bài này, mình sẽ hướng dẫn bạn từng bước xây dựng Interactive Snippets — từ khái niệm cơ bản cho đến multistep wizard phức tạp — kèm đầy đủ code chạy được trên iOS 26 và Xcode 26.
Kiến Trúc Tổng Quan: Cách Interactive Snippets Hoạt Động
Trước khi nhảy vào code, hãy dành chút thời gian hiểu kiến trúc bên dưới đã. Interactive Snippets được xây dựng trên ba trụ cột chính:
- AppIntent: Struct thực thi hành động, chứa logic trong hàm
perform() - SnippetIntent: Protocol kế thừa từ
AppIntent, trả về một SwiftUI view tương tác - AppEntity & EntityQuery: Đại diện nhẹ cho data model, giúp hệ thống truy vấn dữ liệu
Luồng hoạt động thì diễn ra như sau:
- Người dùng kích hoạt intent (qua Siri, Shortcuts, hoặc Spotlight)
- Intent chính trả về một
ShowsSnippetIntent, chuyển tiếp đếnSnippetIntent SnippetIntent.perform()fetch dữ liệu và trả về SwiftUI view- Người dùng tương tác với view qua các nút
Button(intent:) - Mỗi nút bấm kích hoạt một
AppIntentriêng, cập nhật dữ liệu - Gọi
reload()để hệ thống chạy lạiperform()và render view mới - Chu kỳ lặp lại cho đến khi người dùng đóng snippet
Nghe có vẻ nhiều bước, nhưng thực tế khi code rồi bạn sẽ thấy nó khá tự nhiên.
Bước 1: Thiết Lập Data Model Với @Dependency
Điểm quan trọng nhất cần nắm ngay từ đầu: snippet view có thể được tạo lại nhiều lần. Vì vậy, bạn cần một data model chia sẻ (shared) để trạng thái được duy trì xuyên suốt lifecycle. Đây chính là lý do @Dependency trở nên cực kỳ quan trọng.
Hãy bắt đầu với một model đơn giản cho ứng dụng theo dõi caffeine:
import Foundation
import Observation
@Observable
class CaffeineStore {
var totalCaffeine: Double = 0.0
func log(_ amount: Double) {
totalCaffeine += amount
}
func formattedAmount() -> String {
String(format: "%.0f mg", totalCaffeine)
}
func reset() {
totalCaffeine = 0.0
}
}
Tiếp theo, đăng ký dependency này trong App struct khi khởi động:
import SwiftUI
import AppIntents
@main
struct CaffeinePalApp: App {
@State private var store = CaffeineStore()
init() {
ShortcutsProvider.updateAppShortcutParameters()
AppDependencyManager.shared.add(dependency: store)
}
var body: some Scene {
WindowGroup {
ContentView()
}
.environment(store)
}
}
Một lưu ý nhỏ mà quan trọng: AppDependencyManager.shared.add(dependency:) phải được gọi trong init() của App, trước khi bất kỳ intent nào có thể chạy. Mọi intent sau đó sẽ truy cập store thông qua @Dependency var store: CaffeineStore.
Bước 2: Tạo SnippetIntent — Trái Tim Của Interactive Snippet
Giờ thì đến phần thú vị rồi. Hãy xây dựng SnippetIntent — protocol đặc biệt trong iOS 26 yêu cầu perform() trả về ShowsSnippetView:
import AppIntents
import SwiftUI
struct CaffeineSnippetIntent: SnippetIntent {
static let title: LocalizedStringResource = "Caffeine Tracker"
static let isDiscoverable: Bool = false
@Dependency var store: CaffeineStore
@MainActor
func perform() async throws -> some IntentResult & ShowsSnippetView {
return .result(view: CaffeineSnippetView(store: store))
}
}
Có hai điểm cần ghi nhớ ở đây:
perform()sẽ được gọi nhiều lần trong suốt lifecycle của snippet — mỗi khi view cần refresh. Nên hàm này phải nhẹ và tuyệt đối không có side effects ngoài ý muốnisDiscoverable = falsengăn intent này xuất hiện trong Shortcuts app như một action độc lập — nó chỉ được dùng nội bộ thôi
Bước 3: Xây Dựng SwiftUI Snippet View
Đây là phần thú vị nhất — nhưng thành thật mà nói, cũng là nơi nhiều developer hay mắc lỗi nhất. Snippet View không hoạt động giống một SwiftUI view thông thường đâu. Đây là quy tắc vàng bạn cần nhớ:
Button(intent:label:)— hoạt động tương tác ✓Button { action } label: { ... }thông thường — KHÔNG tương tác được ✗TextFieldvới@State— KHÔNG tương tác được ✗Togglevới@State— KHÔNG tương tác được ✗
Nói ngắn gọn: toàn bộ mô hình tương tác được xây dựng trên App Intents framework, không phải SwiftUI state management truyền thống.
import SwiftUI
import AppIntents
struct CaffeineSnippetView: View {
let store: CaffeineStore
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Caffeine hôm nay:")
.font(.subheadline)
.foregroundStyle(.secondary)
Text(store.formattedAmount())
.font(.system(size: 48, weight: .bold))
.contentTransition(.numericText())
.frame(maxWidth: .infinity, alignment: .center)
.padding(.bottom, 8)
Text("Ghi nhanh:")
.font(.subheadline)
.foregroundStyle(.secondary)
HStack(spacing: 12) {
Button(intent: LogCaffeineIntent(amount: 63)) {
Label("Single", systemImage: "cup.and.saucer")
}
Button(intent: LogCaffeineIntent(amount: 126)) {
Label("Double", systemImage: "cup.and.saucer.fill")
}
Button(intent: LogCaffeineIntent(amount: 189)) {
Label("Triple", systemImage: "mug.fill")
}
}
.buttonStyle(.bordered)
}
.padding()
}
}
Để ý cách dùng .contentTransition(.numericText()) nhé — đây là cách duy nhất để tạo animation mượt khi giá trị số thay đổi trong snippet. Hệ thống sẽ tự động áp dụng transition animation dựa trên contentTransition API.
Bước 4: Action Intent — Xử Lý Tương Tác Và reload()
Mỗi nút trong snippet cần một AppIntent riêng để xử lý hành động. Và đây là nơi method reload() — tính năng mới trong iOS 26 — thực sự phát huy sức mạnh:
import AppIntents
struct LogCaffeineIntent: AppIntent {
static let title: LocalizedStringResource = "Log Caffeine"
static let isDiscoverable: Bool = false
@Parameter(title: "Lượng caffeine (mg)")
var amount: Int
@Dependency var store: CaffeineStore
init() {}
init(amount: Int) {
self.amount = amount
}
func perform() async throws -> some IntentResult {
store.log(Double(amount))
CaffeineSnippetIntent.reload()
return .result()
}
}
Vài điểm mấu chốt cần nhớ:
reload()là static method trênSnippetIntent— gọi nó để yêu cầu hệ thống chạy lạiperform()của snippet, refetchAppEntityparameters, và render lại view- Bạn bắt buộc phải khai báo custom
init(amount:). Nếu thiếu, bạn sẽ gặp build error khá khó chịu:Cannot convert value of type 'Int' to expected argument type 'IntentParameter<Int>'khi dùngButton(intent: LogCaffeineIntent(amount: 63)) isDiscoverable = falsevì intent này không có ý nghĩa gì khi dùng độc lập
Bước 5: Entry Point Intent — Cổng Vào Cho Siri và Shortcuts
Bước cuối cùng (cho phần cơ bản), bạn cần một intent chính làm điểm vào. Intent này được người dùng kích hoạt trực tiếp và trả về ShowsSnippetIntent:
import AppIntents
struct GetCaffeineIntent: AppIntent {
static let title: LocalizedStringResource = "Xem lượng caffeine"
@Dependency var store: CaffeineStore
func perform() async throws
-> some IntentResult & ReturnsValue<Double> & ShowsSnippetIntent
{
let amount = store.totalCaffeine
return .result(
value: amount,
snippetIntent: CaffeineSnippetIntent()
)
}
}
Để intent này xuất hiện tự động trong Siri và Shortcuts, bạn cần đăng ký nó qua AppShortcutsProvider:
import AppIntents
struct CaffeineShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: GetCaffeineIntent(),
phrases: [
"Xem caffeine trong \(.applicationName)",
"Mình uống bao nhiêu caffeine hôm nay \(.applicationName)"
],
shortTitle: "Caffeine Tracker",
systemImageName: "cup.and.saucer"
)
}
}
Vậy là xong phần cơ bản! Khi người dùng nói "Hey Siri, xem caffeine trong CaffeinePal", Siri sẽ hiển thị interactive snippet ngay lập tức, cho phép tương tác mà không cần mở app.
Nâng Cao: Multistep Wizard Với requestConfirmation
Interactive Snippets thực sự toả sáng khi bạn xây dựng luồng nhiều bước. Theo ý kiến cá nhân của mình, đây mới là tính năng "killer" thật sự. iOS 26 giới thiệu method requestConfirmation(actionName:snippetIntent:) — cho phép bạn chuỗi nhiều snippet lại với nhau, tạo thành một trải nghiệm tuần tự hoàn chỉnh.
Ví dụ thực tế nè: ứng dụng tạo postcard, nơi người dùng lần lượt chọn ảnh nền, ảnh chính, và nhập lời chúc:
import AppIntents
struct CreatePostcardIntent: AppIntent {
static let title: LocalizedStringResource = "Tạo Postcard Mới"
@Parameter(title: "Tiêu đề")
var title: String
@Dependency var dataStore: PostcardDataStore
@MainActor
func perform() async throws -> some IntentResult {
let postcard = PostcardDocument(title: title)
try await dataStore.addPostcard(postcard)
// Bước 1: Chọn ảnh nền
try await requestConfirmation(
actionName: .continue,
snippetIntent: BackgroundImageSnippetIntent(
postcard: postcard.toEntity
)
)
// Bước 2: Chọn ảnh chính
try await requestConfirmation(
actionName: .continue,
snippetIntent: FrontImageSnippetIntent(
postcard: postcard.toEntity
)
)
// Bước 3: Nhập lời chúc
try await requestConfirmation(
actionName: .continue,
snippetIntent: GreetingTextSnippetIntent(
postcard: postcard.toEntity
)
)
return .result()
}
}
Mỗi lời gọi requestConfirmation là bất đồng bộ — nó tạm dừng cho đến khi người dùng hoàn thành hành động xác nhận ở bước đó, rồi mới chạy tiếp bước kế. Mình thấy cách tiếp cận này khá thanh lịch — bạn tạo ra một wizard hoàn chỉnh chỉ với vài dòng code.
Xử Lý Input Phức Tạp: Text Và File
Vì TextField không hoạt động trong snippet (như đã nói ở trên), để thu thập text từ người dùng, bạn cần tạo một intent riêng với @Parameter bắt buộc:
struct SetGreetingIntent: AppIntent {
static let title: LocalizedStringResource = "Nhập Lời Chúc"
static let isDiscoverable: Bool = false
@Parameter(title: "Lời chúc")
var greetingText: String
@Parameter(title: "Postcard")
var postcard: PostcardEntity
@Dependency var dataStore: PostcardDataStore
func perform() async throws -> some IntentResult {
guard var doc = await dataStore.getPostcard(
by: postcard.id
) else {
throw PostcardError.notFound
}
doc.greetingText = greetingText
try await dataStore.updatePostcard(doc)
GreetingTextSnippetIntent.reload()
return .result()
}
}
Điều hay ho ở đây là: khi intent được kích hoạt mà greetingText chưa có giá trị, hệ thống sẽ tự động prompt người dùng nhập text. Bạn không cần tự build UI cho phần này.
Tương tự với file upload, bạn dùng IntentFile parameter — hệ thống tự hiển thị file picker:
struct SelectImageIntent: AppIntent {
static let title: LocalizedStringResource = "Chọn Ảnh"
static let isDiscoverable: Bool = false
@Parameter(
title: "File ảnh",
supportedContentTypes: [.jpeg, .png, .heic, .image]
)
var imageFile: IntentFile
@Parameter(title: "Postcard")
var postcard: PostcardEntity
@Dependency var dataStore: PostcardDataStore
func perform() async throws -> some IntentResult {
guard var doc = await dataStore.getPostcard(
by: postcard.id
) else {
throw PostcardError.notFound
}
doc.backgroundImageData = imageFile.data
try await dataStore.updatePostcard(doc)
BackgroundImageSnippetIntent.reload()
return .result()
}
}
iOS 26 Cải Tiến Mới Cho AppEntity
Ngoài Interactive Snippets, iOS 26 còn mang đến hai macro mới cho AppEntity mà mình nghĩ bạn nên biết:
@ComputedProperty — Giá Trị Tính Toán Từ Source of Truth
Thay vì lưu trữ giá trị trùng lặp trên entity, macro @ComputedProperty cho phép bạn derive giá trị trực tiếp từ nguồn dữ liệu gốc. Khá tiện lợi khi bạn muốn tránh data duplication:
struct PostcardEntity: AppEntity {
var id: UUID
var title: String
@ComputedProperty
var currentStage: PostcardStage {
// Tính toán stage dựa trên trạng thái hiện tại
// thay vì lưu trữ riêng
}
static var defaultQuery = PostcardQuery()
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(title)")
}
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Postcard"
}
@DeferredProperty — Giảm Chi Phí Khởi Tạo
Macro @DeferredProperty trì hoãn việc tải các property nặng cho đến khi thực sự cần. Điều này giảm đáng kể chi phí instantiation của entity — đặc biệt hữu ích khi entity chứa dữ liệu lớn như image data. Nếu entity của bạn "nhẹ nhàng" thì có thể bỏ qua, nhưng với những entity phức tạp thì đây là tính năng rất đáng dùng.
Best Practices Và Những Lỗi Thường Gặp
Sau khi tự tay build vài interactive snippet, mình rút ra được một số kinh nghiệm khá hữu ích.
Nên Làm
- Luôn fetch dữ liệu trong perform() — Đừng dựa vào property-level state. Mỗi lần
perform()chạy, hãy lấy dữ liệu mới nhất từ data store - Dùng @Dependency cho shared state — Đảm bảo tất cả intents tham chiếu cùng một instance của data model
- Giữ perform() nhẹ — Method này được gọi nhiều lần, nên tránh network calls nặng hoặc computation phức tạp
- Dùng contentTransition cho animation —
.contentTransition(.numericText())là cách tốt nhất để animate số thay đổi - Set isDiscoverable = false cho các intent phụ trợ — Chỉ intent chính mới cần hiển thị trong Shortcuts
Không Nên Làm
- Đừng dùng @State trong snippet view — State cục bộ sẽ bị mất mỗi khi view được tạo lại. Hãy dùng shared model thay thế
- Đừng dùng Button closure thông thường — Chỉ
Button(intent:label:)mới tương tác được trong snippet - Đừng quên custom init cho intent — Thiếu init sẽ gặp build error ngay khi truyền parameter vào
Button(intent:). Lỗi này mình từng mất kha khá thời gian debug - Đừng tạo side effects trong SnippetIntent.perform() — Method này chạy nhiều lần, nên side effects sẽ bị nhân lên (ví dụ: gửi analytics event mỗi lần render — sẽ rất rối)
Tư Duy Mới: State Machine Thay Vì View State
Đây có lẽ là thay đổi tư duy quan trọng nhất khi làm việc với Interactive Snippets. Thay vì nghĩ theo kiểu SwiftUI truyền thống — quản lý @State, @Binding, và reactive view updates — bạn cần chuyển sang nghĩ theo kiểu state machine được điều khiển bởi chuỗi intents.
Mỗi tương tác của người dùng không update view trực tiếp mà đi theo flow:
- Kích hoạt một
AppIntent - Intent cập nhật shared data model
- Gọi
SnippetIntent.reload() - Hệ thống chạy lại
SnippetIntent.perform() - View mới được render với dữ liệu cập nhật
Nếu bạn đã từng xây dựng interactive widgets, bạn sẽ thấy mô hình này khá quen thuộc. Về cơ bản, nó giống widget interactive hơn là một SwiftUI view thông thường — chỉ là mạnh mẽ hơn nhiều nhờ khả năng chain nhiều bước với requestConfirmation.
Tổng Kết: Khi Nào Nên Dùng Interactive Snippets?
Interactive Snippets không phải công cụ cho mọi tình huống. Hãy cân nhắc dùng chúng khi:
- Quick actions — Logging dữ liệu nhanh (caffeine, nước uống, bước chân), đánh dấu task hoàn thành
- Status dashboard — Hiển thị trạng thái real-time mà người dùng muốn check thường xuyên
- Multistep flows đơn giản — Tạo nội dung theo wizard (postcard, quick note, timer setup)
- Tích hợp Siri sâu — Khi bạn muốn cung cấp trải nghiệm voice-first hoàn chỉnh
Ngược lại, đừng cố nhét toàn bộ UI của app vào snippet — hệ thống có giới hạn về kích thước view và độ phức tạp. Biết khi nào nên dừng lại cũng là một kỹ năng quan trọng.
App Intents framework đang phát triển nhanh qua mỗi năm, và Interactive Snippets trong iOS 26 là bước tiến lớn nhất từ trước đến giờ. Apple đang gửi tín hiệu rõ ràng: tương lai của iOS là tích hợp hệ thống sâu — nơi tính năng tốt nhất của app không bị giới hạn bên trong chính app đó.
Câu Hỏi Thường Gặp (FAQ)
App Intents có yêu cầu iOS version tối thiểu nào?
App Intents framework yêu cầu tối thiểu iOS 16. Tuy nhiên, tính năng Interactive Snippets (bao gồm SnippetIntent, reload(), và requestConfirmation) là tính năng mới chỉ có từ iOS 26. Nếu app cần hỗ trợ iOS cũ hơn, bạn vẫn có thể dùng App Intents cơ bản và dùng #available check cho Interactive Snippets.
Interactive Snippets có hoạt động với UIKit không?
Snippet views phải được viết bằng SwiftUI — đây là yêu cầu bắt buộc vì hệ thống render view trong một process riêng biệt. Tuy nhiên, phần logic của AppIntent (data model, business logic) hoàn toàn không phụ thuộc vào SwiftUI hay UIKit. Nên vâng, bạn hoàn toàn có thể dùng Interactive Snippets trong app UIKit — chỉ cần viết snippet view bằng SwiftUI là được.
Sự khác biệt giữa Interactive Snippets và Interactive Widgets là gì?
Câu hỏi hay! Cả hai đều dùng Button(intent:) cho tương tác, nhưng khác nhau ở ngữ cảnh sử dụng. Interactive Widgets sống trên Home Screen/Lock Screen và cập nhật theo timeline. Interactive Snippets thì xuất hiện trong Siri, Shortcuts, và Spotlight — chúng là kết quả tạm thời của một intent và có thể chuỗi nhiều bước với requestConfirmation. Snippets linh hoạt hơn về luồng tương tác nhưng không persistent như widgets.
Tại sao @State không hoạt động trong Snippet View?
Snippet views được render trong process riêng của hệ thống, không phải trong process của app. Mỗi khi có tương tác, hệ thống tạo lại view instance mới thông qua SnippetIntent.perform(). Do đó, @State sẽ bị reset mỗi lần render. Giải pháp là dùng @Dependency để inject shared data model — dữ liệu sống trong app process và được truyền vào view qua intent.
Có thể dùng Interactive Snippets trong Control Center không?
Hiện tại thì chưa. Mặc dù Apple đã demo interactive snippet trong nhiều bối cảnh tại WWDC25, tài liệu chính thức xác nhận rằng Interactive Snippets hiện chỉ hỗ trợ trong Siri, Shortcuts app, và Spotlight. Control Center widgets vẫn sử dụng WidgetKit với ControlWidget API riêng. Tuy nhiên, điều này hoàn toàn có thể thay đổi trong các bản cập nhật tương lai.