TipKit trong iOS 26: Hướng Dẫn Toàn Diện Feature Discovery và Onboarding với SwiftUI
Hướng dẫn triển khai TipKit trong iOS 26 với SwiftUI: từ Tip protocol, TipGroup, rules với Parameter và Event, CloudKit sync đến custom TipViewStyle theo Liquid Glass, kèm bài học thực chiến.
TipKit trong iOS 26 là framework khai báo (declarative) của Apple giúp bạn hiển thị các gợi ý (tips) ngữ cảnh ngay trong SwiftUI hoặc UIKit. Nó đóng vai trò như một hệ thống feature discovery tự quản lý trạng thái hiển thị, tự đồng bộ qua CloudKit và tuân theo rules bạn viết bằng macro #Rule. Nói thẳng ra, sau bốn năm rebuild UI Wealth ở Revolut, tôi đã thay toàn bộ hệ thống onboarding thủ công bằng TipKit và giảm được khoảng 70% code coordinator liên quan đến tooltip. Bài viết này chỉ cho bạn cách triển khai TipKit đúng chuẩn Apple ở iOS 26, từ Tip protocol cơ bản cho tới TipGroup, custom TipViewStyle và đồng bộ CloudKit.
TipKit có sẵn từ iOS 17, được mở rộng ở iOS 18 với CloudKit sync và giữ ổn định trong iOS 26 khi kết hợp Liquid Glass.
Bạn phải gọi Tips.configure() đúng một lần trong App.init để khởi tạo datastore trước khi bất kỳ view nào render.
Rules dựa trên hai cơ chế chính: Parameter (state ngoài) và Event (được TipKit tự đếm và lưu vào SwiftData).
Hiển thị tip bằng TipView inline hoặc modifier popoverTip(_:arrowEdge:) tuỳ ngữ cảnh giao diện.
TipGroup cho phép sắp xếp thứ tự tips khi có nhiều feature cùng cần giới thiệu; kết hợp với display frequency để tránh spam.
Bật CloudKit sync để tránh việc user nhìn cùng một tip trên iPhone, iPad và Mac; test bằng Tips.resetDatastore().
TipKit là gì và tại sao iOS 26 vẫn cần đến nó
TipKit là framework Apple giới thiệu tại WWDC 2023, hoạt động trên iOS 17+, iPadOS 17+, macOS 14+, watchOS 10+ và tvOS 17+. Mục đích của nó là chuẩn hoá cách app hiển thị các gợi ý ngắn về tính năng, vốn trước đây được cài bằng cocktail của @AppStorage, UserDefaults và các coordinator custom mà mỗi team viết một kiểu. Theo TipKit documentation chính thức, Apple xử lý ba việc khó nhất trong feature discovery: lưu trạng thái đã hiển thị hay chưa, tính rules để quyết định thời điểm hiện, và hết hạn tip khi user đã tương tác.
Ở iOS 26, TipKit vẫn giữ API cốt lõi giống iOS 18 nhưng được cập nhật để tương thích với Liquid Glass. Nếu bạn không tuỳ biến TipViewStyle, TipView và popoverTip mặc định sẽ tự động dùng background dạng glass giống các surface hệ thống khác. Điểm này quan trọng khi bạn kết hợp TipKit với những màn hình dùng Liquid Glass trong SwiftUI, vì tip sẽ hoà vào layout thay vì đè lên một block màu đục xấu xí.
Thật ra, từ góc nhìn người đã ship bốn năm ở Wealth team Revolut, giá trị lớn nhất của TipKit không phải là UI đẹp mà là eligibility rules. Framework tự lưu số lần user đã trigger một event xuống SwiftData (nó sống sót qua app restart, đúng như bạn mong đợi), và bạn khai báo điều kiện hiển thị bằng một macro #Rule đọc tự nhiên gần như tiếng Anh. So với việc quản lý hasSeenFeatureX flag rải rác khắp UserDefaults, cách tiếp cận này giảm hẳn class lỗi "user thấy tip hai lần" khi họ migrate máy hoặc reinstall.
Cài đặt Tips.configure trong App.init
Bước đầu tiên khi tích hợp TipKit là gọi Tips.configure() ở đầu vòng đời app. Apple yêu cầu bạn gọi hàm này đúng một lần, trong App.init, trước khi bất kỳ view nào render. Nếu bạn gọi ở onAppear của view đầu tiên thì sẽ có race condition. TipView có thể đọc datastore trước khi framework hoàn tất khởi tạo, và bạn sẽ nhận về tips ở trạng thái sai.
import SwiftUI
import TipKit
@main
struct CoffeeShopApp: App {
init() {
try? Tips.configure([
.displayFrequency(.immediate),
.datastoreLocation(.applicationDefault)
])
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Hai option chính bạn cần biết:
.displayFrequency(_:) kiểm soát khoảng cách giữa các tips khác nhau. .immediate cho phép hiển thị liên tục (hữu ích khi debug), .daily chỉ hiện một tip mỗi 24 giờ, .hourly mỗi giờ, hoặc bạn truyền một TimeInterval tuỳ ý như .displayFrequency(3 * 24 * 60 * 60).
.datastoreLocation(_:) là nơi lưu trạng thái tips. Mặc định là .applicationDefault. Nếu bạn có app group để share state giữa app chính và app extension, hãy dùng .groupContainer(identifier:).
Ở Revolut, chúng tôi từng đẩy nhầm .immediate lên TestFlight và nhận về hàng loạt feedback kiểu "app cứ hiện popup liên tục". Sau vụ đó tôi luôn cấu hình theo pattern sau: .daily ở release, .immediate ở debug, và có một feature flag override để QA có thể force test tất cả tips.
Tạo tip đầu tiên với Tip protocol
Một tip là một struct tuân thủ Tip protocol. Ba property quan trọng nhất là title, message, và (tuỳ chọn) image. Cả ba đều dùng LocalizedStringResource để hỗ trợ localization tự động qua String Catalog.
import TipKit
import SwiftUI
struct FavoriteDrinkTip: Tip {
var title: Text {
Text("Lưu đồ uống yêu thích")
}
var message: Text? {
Text("Nhấn giữ vào món để thêm nhanh vào danh sách yêu thích.")
}
var image: Image? {
Image(systemName: "heart.fill")
}
}
Sau khi định nghĩa xong, bạn hiển thị tip bằng TipView inline hoặc modifier popoverTip. Ví dụ hiển thị inline trên đầu list:
struct DrinkListView: View {
private let favoriteTip = FavoriteDrinkTip()
var body: some View {
List {
TipView(favoriteTip, arrowEdge: .bottom)
.listRowBackground(Color.clear)
ForEach(drinks) { drink in
DrinkRow(drink: drink)
}
}
}
}
TipView tự đọc trạng thái từ datastore và biến mất khi bạn gọi tip.invalidate(reason:). Ba reason phổ biến là .userPerformedAction khi user đã thực hiện action mà tip gợi ý, .actionPerformed khi user tap nút bên trong tip, và .tipClosed khi user chủ động đóng.
TipView vs popoverTip: chọn kiểu hiển thị nào?
TipKit cung cấp hai cách hiển thị, và việc chọn đúng ảnh hưởng khá nhiều đến trải nghiệm.
Tiêu chí
TipView (inline)
popoverTip (modifier)
Cách hiển thị
Nằm trong layout, chiếm chỗ như một view thường
Nổi lên trên UI, có mũi tên chỉ vào view đích
Phù hợp với
Empty state, header của list, banner giới thiệu
Chỉ vào một control nhỏ (button, toolbar item)
Chiếm không gian
Có, đẩy các view khác xuống
Không, chỉ hiện tạm thời
Tương tác
User cần vuốt hoặc nhấn X để đóng
Tap ra ngoài là đóng
Accessibility
VoiceOver focus tự nhiên theo thứ tự đọc
Cần cấu hình rõ ràng vì popover nằm ngoài luồng
Ưu điểm chính
Không che nội dung, khó bỏ qua
Chỉ chính xác vào điểm cần chú ý
Trong thực tế, tôi dùng popoverTip cho các toolbar item mới và TipView cho những feature cần onboarding sâu hơn, ví dụ như "cách kéo để refresh". Modifier popoverTip nhận một tham số arrowEdge để chỉ định phía nào của view gốc mũi tên sẽ trỏ vào (mặc định là .top).
Đây là phần khiến TipKit thực sự đáng dùng. rules property trong Tip protocol cho phép bạn khai báo chính xác khi nào một tip đủ tiêu chuẩn để hiển thị. Có hai loại rule.
Parameter rules dựa vào state hiện tại
@Parameter là property wrapper monitor giá trị và tự động re-evaluate rules mỗi khi giá trị thay đổi. Bạn thường dùng nó để kiểm tra trạng thái tạm thời như "user đang ở trong tab X" hay "app đã mở lần thứ 3".
struct FavoriteDrinkTip: Tip {
@Parameter
static var isLoggedIn: Bool = false
var title: Text { Text("Lưu đồ uống yêu thích") }
var message: Text? { Text("Nhấn giữ vào món để lưu.") }
var rules: [Rule] {
#Rule(Self.$isLoggedIn) { $0 == true }
}
}
Ở view, bạn cập nhật parameter khi trạng thái thay đổi:
Event là cơ chế TipKit tự đếm và persist. Bạn "donate" event tại điểm user thực hiện action; framework tự lưu số lần vào SwiftData và bạn khai báo rule dựa vào con số này.
struct OrderShortcutTip: Tip {
static let orderPlaced = Event(id: "order-placed")
var title: Text { Text("Đặt lại đơn nhanh hơn") }
var message: Text? {
Text("Vuốt sang trái trên đơn cũ để đặt lại chỉ với một chạm.")
}
var rules: [Rule] {
#Rule(Self.orderPlaced) { $0.donations.count >= 3 }
}
}
Trong view xử lý đặt đơn, hãy donate event sau khi thành công:
Rule ở trên nghĩa là: chỉ hiển thị tip sau khi user đã đặt đơn ít nhất 3 lần. Đây là pattern quan trọng, đừng gợi ý shortcut cho user chưa hiểu flow cơ bản. Bạn cũng có thể lọc donations theo khoảng thời gian với $0.donations.donatedWithin(.week).
TipGroup và cách sắp xếp thứ tự feature discovery
Vấn đề thường gặp trong app phức tạp: có mười tính năng cần giới thiệu, và nếu hiển thị tất cả tips cùng lúc, user sẽ bị choáng. TipGroup giải quyết bằng cách chỉ hiện một tip đủ điều kiện tại một thời điểm, theo thứ tự bạn khai báo.
@State private var onboardingGroup = TipGroup(.ordered) {
FavoriteDrinkTip()
OrderShortcutTip()
RewardsTip()
}
var body: some View {
List {
if let current = onboardingGroup.currentTip {
TipView(current)
}
// ...
}
}
Với .ordered, TipKit đợi tip đầu tiên bị invalidated rồi mới xét tip thứ hai. Với .firstAvailable, framework chọn tip đầu tiên trong group thoả điều kiện rules ngay lập tức. Trong app của Wealth team, tôi dùng .ordered cho onboarding và .firstAvailable cho tips ngữ cảnh trong màn detail (chẳng hạn khi user vào một market chart nhất định).
Kết hợp TipGroup với display frequency để có trải nghiệm mượt: .daily ở app level nghĩa là mỗi ngày chỉ một tip mới trong group được cân nhắc. User trải qua onboarding trong tuần đầu tiên mà không cảm thấy bị dội bom, khá giống cách App Intents Interactive Snippets tiếp cận progressive disclosure.
Đồng bộ tips qua CloudKit trên iOS 26
Từ iOS 18, Apple thêm option đồng bộ trạng thái tips qua CloudKit. Đây là feature nhỏ nhưng quan trọng. User cài app trên iPhone rồi mua thêm iPad, nếu không sync họ sẽ xem lại toàn bộ tips lần nữa, cảm giác rất amateur. Chi tiết được mô tả kỹ trong bài viết của Use Your Loaf về syncing TipKit.
Ba bước bật CloudKit sync:
Trong Xcode, mở tab Signing & Capabilities của app target, thêm capability iCloud và tick CloudKit.
Tạo hoặc chọn một CloudKit container (ví dụ iCloud.com.example.CoffeeShop).
Cập nhật Tips.configure để trỏ datastore vào container đó:
Trong app financial tôi từng làm ở Revolut, bọn tôi dùng cùng iCloud container cho cả settings và TipKit. Cách này đơn giản hoá quá trình xin permission, user chỉ thấy một prompt iCloud thay vì hai.
Custom TipViewStyle để khớp với Liquid Glass
Nếu default style không hợp brand, hãy implement TipViewStyle để render tip theo cách của riêng bạn. Protocol này có một method chính, makeBody(configuration:), nhận vào struct chứa title, message, image và actions của tip.
Modifier .glassEffect(in:) là API mới ở iOS 26 giúp tip hoà quyện với Liquid Glass design system. Nếu app của bạn còn hỗ trợ iOS 17-25, wrap trong #available(iOS 26, *) và fallback về .background(.thinMaterial).
Test TipKit: reset datastore và preview
Vấn đề số một khi test TipKit là bạn xem tip một lần, nó invalidate, và không hiện lại. Framework có ba API để giải quyết:
Tips.resetDatastore() xoá toàn bộ trạng thái đã lưu. Gọi trước Tips.configure() khi debug.
tip.invalidate(reason:) đánh dấu một tip đã hết vòng đời (không hiện lại).
Tips.showAllTipsForTesting() override rules để tất cả tips đủ điều kiện. Chỉ dùng trong QA build.
Trong Xcode Previews, bạn không cần Tips.configure. Chỉ cần khởi tạo tip và render bên trong PreviewProvider:
#Preview {
let tip = FavoriteDrinkTip()
return TipView(tip)
.padding()
.task {
try? Tips.resetDatastore()
try? Tips.configure([
.displayFrequency(.immediate)
])
}
}
Với Swift Testing, bạn có thể kiểm tra logic rules bằng cách set Parameter và assert eligible status. Cách tiếp cận này khớp tốt với Swift Testing framework, viết test theo dạng function độc lập với @Test macro.
Những lỗi thường gặp khi ship TipKit lên production
Sau vài project dùng TipKit, đây là những footgun tôi hay gặp (và đôi khi vẫn quên).
Quên gọi Tips.configure trước khi view render
Nếu bạn thấy tips không bao giờ hiện dù rules đúng, kiểm tra thứ tự gọi. Việc gọi trong onAppear của root view thay vì App.init là nguyên nhân số một. Framework cần datastore sẵn sàng trước khi TipView bind.
Donate event ở sai điểm
Nếu bạn donate orderPlaced mỗi lần user mở màn hình orders (thay vì lúc đặt đơn thật), rule count sẽ nhảy nhanh và tip hiện sai lúc. Luôn donate ở đúng điểm user thực hiện action mà rule quan tâm. Tôi đã ăn cái bug này đúng một lần khi ship, và mất nửa buổi mới thấy.
Không invalidate sau khi user đã dùng feature
Nếu user đã thực hiện đúng cái tip gợi ý mà bạn không gọi tip.invalidate(reason: .actionPerformed), tip vẫn ám ảnh user trong nhiều ngày. Luôn hook invalidate vào success handler của action đó.
Rule quá phức tạp không test được
Compiler cho phép bạn viết rule 10 dòng với nhiều điều kiện lồng nhau, nhưng khi rule bug, bạn khó reproduce. Chia rules thành nhiều tips nhỏ, hoặc extract logic phức tạp ra @Parameter để dễ mock trong test.
Bỏ quên accessibility
Custom TipViewStyle dễ làm mất VoiceOver focus và Dynamic Type. Luôn test bằng Accessibility Inspector, đảm bảo message có thể đọc được và button close có accessibilityLabel rõ ràng. Với SwiftUI NavigationStack, tip trong toolbar cần accessibility rotor tương thích. Kiểm tra trong Settings > Accessibility > VoiceOver > Rotor.
TipKit dùng để hiển thị các gợi ý ngắn (tips) trong app iOS, iPadOS, macOS, watchOS và tvOS nhằm giới thiệu tính năng mới, hé lộ chức năng ẩn hoặc gợi ý shortcut. Đây là công cụ feature discovery, không dùng để hiển thị thông tin quan trọng hay warning bắt buộc.
Làm sao để reset TipKit tips khi đang test?
Gọi Tips.resetDatastore() trước khi Tips.configure(). Trong development, wrap trong #if DEBUG và trigger bằng launch argument (ví dụ -reset-tips) để không ảnh hưởng production. Bạn cũng có thể dùng Tips.showAllTipsForTesting() để override rules trong QA build.
TipKit có hoạt động trên macOS không?
Có. TipKit hỗ trợ macOS 14+ ngoài iOS 17+, iPadOS 17+, watchOS 10+ và tvOS 17+. API giống nhau trên các nền tảng, nhưng UI của default TipView và popoverTip được điều chỉnh theo HIG của từng OS. Trên macOS, tips render với kích thước phù hợp con trỏ chuột thay vì touch.
Làm sao đồng bộ TipKit qua nhiều thiết bị?
Từ iOS 18, cấu hình .datastoreLocation(.cloudKitContainer(named:fileName:)) trong Tips.configure và thêm iCloud + CloudKit capability trong Xcode. Sau đó deploy schema từ CloudKit Console. User đăng nhập cùng Apple ID trên iPhone và iPad sẽ không thấy lại tip đã đóng ở thiết bị kia.
Sự khác biệt giữa Parameter và Event trong TipKit là gì?
Parameter là state hiện tại bạn quản lý và cập nhật (ví dụ user đã login hay chưa), TipKit không lưu Parameter giữa các lần khởi động app. Event là action user thực hiện được bạn donate; TipKit tự đếm và lưu số lần cùng thời điểm vào SwiftData datastore, sống sót qua app restart.
Tomasz is a Krakow-based iOS engineer with 11 years of Swift experience. He spent four years at Revolut on the Wealth team, where he rewrote the trading charts in SwiftUI and shaved 40% off cold-start time by lazy-loading the analytics SDK. Before Revolut he was at Allegro, Poland's largest e-commerce platform, on the Seller Center iOS team.
His specialty is iOS performance work: Instruments deep-dives, memory-graph debugging, and figuring out why your scroll view drops frames only on iPhone SE 2nd-gen. He has contributed patches to swift-syntax and writes a quarterly newsletter for iOS engineers that covers under-discussed APIs like BackgroundTasks and NSFileCoordinator.
Tomasz holds the iOS App Development with Swift certification from Apple and occasionally runs paid workshops on Swift concurrency for in-house engineering teams in Europe.
Hướng dẫn haptics SwiftUI iOS 26: .sensoryFeedback, CHHapticEngine, AHAP và cách đồng bộ với spring animation, kèm ví dụ code chạy được và ghi chú accessibility.
Hướng dẫn xây Live Activities và Dynamic Island trên iOS 26 với ActivityKit và SwiftUI: từ ActivityAttributes, thiết kế bốn state Dynamic Island, cập nhật local và APNs push, tới broadcast channels và interactive App Intents. Kèm code chạy được và bài học thực tế.
Học cách dùng KeyframeAnimator và PhaseAnimator trong SwiftUI iOS 26 để dàn dựng animation nhiều track song song, xử lý Reduce Motion đúng cách và tối ưu hiệu năng render pipeline.