Highlight
TipKit 在 iOS 17 发布时解决了”怎么展示 Tip”的问题,但实际用下来你会发现:多个 Tip 可能同时弹出、同一个 Tip 结构没法复用给不同内容、换台设备 Tip 状态又重置了。今年的更新直击这些痛点。
核心内容
你在 app 里加了三个新功能,每个都配了一条 Tip。用户第一次打开 app,三条 Tip 同时弹出来,互相遮挡,用户直接全部关掉——你的引导策略当场失效。这是 TipKit 去年最被诟病的场景:多个 Tip 缺乏编排能力,谁先谁后没有保证。
今年 Apple 给 TipKit 加了 TipGroup。你可以把一组相关的 Tip 放进同一个 Group,指定 .ordered 顺序优先级,前一条被 invalidate 之后才展示下一条。也可以用 .firstAvailable,哪个先满足展示条件就先出。这样同一时刻最多只展示一条 Tip,用户体验立刻可控。
但 TipGroup 只解决了”顺序”问题。另一个常见痛点是:你有 10 条新内容(比如 10 条新路线),想给每条内容展示一次 Tip,难道要写 10 个 Tip 结构体?自定义 Identifier 就是来解这个问题的。你在一个 Tip 结构体里重写 id 属性,绑定到具体内容的标识符,同一个结构体就能被反复实例化,每条内容各有各的展示状态。再加上 TipViewStyle 让你彻底接管 Tip 的视觉表现,CloudKit 同步让状态跨设备一致——四项能力叠加,TipKit 从”能展示”升级为”能编排”。
详细内容
TipGroup:控制多个 Tip 的展示顺序
给同一个控件挂两条 popoverTip,它们可能同时弹出。用 TipGroup 改写后,只有当前 Tip 被 invalidate 后,下一条才会出现(02:41):
struct MapCompassControl: View {
@State
var compassTips: TipGroup(.ordered) {
ShowLocationTip()
RotateMapTip()
}
var body: some View {
CompassDial()
.popoverTip(compassTips.currentTip)
.onTapGesture {
showCurrentLocation()
}
.onLongPressGesture(minimumDuration: 0.1) {
reorientMapHeading()
}
}
}
关键点:
TipGroup(.ordered)表示按声明顺序依次展示;.firstAvailable则展示第一个满足规则的 TipcompassTips.currentTip返回 Group 中当前应该展示的那条 Tip.ordered模式下,前一条 Tip 必须 invalidate 后,下一条才有可能展示- 如果不同 Tip 需要挂在不同控件上,可以用
currentTip as? ShowLocationTip做类型转换(03:15) - 用户实际使用了功能后,手动调用
invalidate(reason: .actionPerformed)让当前 Tip 失效,Group 自动推进到下一条(03:50)
自定义 Identifier:让同一个 Tip 结构体复用给不同内容
默认情况下,Tip 的标识符就是类型名。这意味着同一个 Tip 类型只能对应一条展示记录。如果你有动态内容(新文章、新路线、新功能项),每条内容都需要独立的展示状态。做法是重写 id 属性(06:45):
struct NewTrailTip: Tip {
let newTrail: Trail
var title: Text {
Text("\(newTrail.name) is now available")
}
var message: Text? {
Text("To see key trail info, tap \(newTrail.region) on the map.")
}
var actions: [Action] {
Action(title: "Go there now")
}
var id: String {
"NewTrailTip-\(newTrail.id)"
}
var rules: [Rule] {
#Rule(newTrail.region.didVisitEvent) {
$0.donations.count > 3
}
}
}
关键点:
id基于newTrail.id拼接,每条路线对应一个独立标识符- 标识符不同 = 展示状态独立,Tip 为路线 A 被 dismiss 后,仍然可以为路线 B 展示
rules里也绑定了newTrail.region,让每条 Tip 的展示条件跟内容绑定- ID 应基于稳定的具体值(用户 ID、内容 ID),不要用易变的值
TipViewStyle:完全自定义 Tip 的外观
默认 Tip 样式是系统标准卡片。如果你的 app 有品牌视觉要求,可以用 TipViewStyle 自定义布局(08:55):
struct NewTrailTipViewStyle: TipViewStyle {
func makeBody(configuration: Configuration) -> some View {
let tip = configuration.tip as! NewTrailTip
let highlightTrailAction = configuration.actions.first!
TrailImage(imageName: tip.newTrail.heroImage)
.frame(maxHeight: 150)
.onTapGesture { highlightTrailAction.handler() }
.overlay {
VStack {
configuration.title.font(.title)
HStack {
configuration.message.font(.subheadline)
Spacer()
Image(systemName: "chevron.forward.circle")
.foregroundStyle(.white)
}
}
}
}
}
关键点:
configuration.tip转型为具体 Tip 类型后,可以访问自定义属性(如newTrail.heroImage)- 使用
configuration.title和configuration.message而不是tip.title,这样外部对 TipView 添加的 modifier 仍然生效 configuration.actions拿到 Tip 定义中的 Action,调用handler()触发 TipView 闭包里的回调- 应用样式:
.tipViewStyle(NewTrailTipViewStyle())(09:20) - UIKit/AppKit 应用可用
viewStyle属性设置样式
CloudKit 同步:跨设备共享 Tip 状态
用户在 iPhone 上关掉了某条 Tip,换到 iPad 又看到同一条——这个体验很糟糕。配置 CloudKit 同步后,Tip 的展示状态、事件捐赠(donations)、展示次数都会在设备间同步(11:38):
@main
struct TipKitTrails: App {
var body: some Scene {
WindowGroup {
ContentView()
.task {
await configureTips()
}
}
}
func configureTips() async {
do {
try Tips.configure([
.cloudKitContainer(.named("iCloud.com.apple.TipKitTrails.tips")),
.displayFrequency(.weekly)
])
}
catch {
print("Unable to configure tips: \(error)")
}
}
}
关键点:
- Xcode 项目需开启 iCloud 能力,添加 CloudKit 服务并创建容器
- 同时开启 Background Modes 的 Remote Notifications,让 TipKit 在后台处理远程变更
Tips.configure()里传入.cloudKitContainer(.named(...))即可启用同步- 同步内容包括:Tip 展示状态、事件和参数值、展示次数和持续时长
- 某些 Tip 如果需要跨平台独立(iPhone 和 iPad 各展示一次),可以用
UIDevice拼出平台相关的 ID - 测试时
resetDatastore()会同时清除本地数据和 CloudKit 记录
核心启发
-
做什么:用 TipGroup 编排功能引导流程。把首次引导的 Tip 按
.ordered分组,用户完成一步才展示下一步,避免信息过载。为什么值得做:用户同时看到多条 Tip 时大概率全部关掉,分组后一次一条,完成率会明显提升。怎么开始:找到 app 中同一个界面挂了多条popoverTip的地方,改成TipGroup(.ordered)+currentTip。 -
做什么:用自定义 Identifier 让内容驱动的 Tip 可复用。电商新商品、阅读 app 新文章、地图 app 新路线——凡是”同一结构、不同内容”的 Tip 场景,都适合用重写
id的方式复用一个 Tip 结构体。为什么值得做:避免为每条新内容写一个独立的 Tip 类型,代码量和维护成本都大幅下降。怎么开始:选一个你目前为每种内容写了独立 Tip 的模块,合并为一个带let content: ContentType属性和var id: String { "Tip-\(content.id)" }的通用 Tip。 -
做什么:用 TipViewStyle 把 Tip 做成品牌化的内容卡片。如果你的 Tip 承载了富内容(图片、链接、快捷操作),默认样式撑不住,自定义 Style 可以把 Tip 变成一张可交互的内容推荐卡。为什么值得做:Tip 不只是系统级提示框,它可以是你 app 内容分发的一部分,让引导和内容体验融为一体。怎么开始:实现
TipViewStyle协议的makeBody,用configuration.tip拿自定义属性渲染布局,用configuration.actions处理交互。 -
做什么:多设备 app 第一时间开启 CloudKit 同步。Tip 状态跨设备不一致是用户最常见的抱怨之一,后期补加同步需要处理已存在的本地状态冲突。为什么值得做:Day 1 就开启同步,用户不会在 iPad 上重复看到 iPhone 上已经关掉的 Tip,事件捐赠也会跨设备累计,规则判断更准确。怎么开始:在 Xcode 项目里加 iCloud + CloudKit 容器 + Background Modes Remote Notifications,
Tips.configure里加上.cloudKitContainer选项。
关联 Session
- Make features discoverable with TipKit — TipKit 去年的入门 Session,讲解如何创建 Tip、设置展示规则和控制频率
- What’s new in SwiftData — TipKit 底层基于 SwiftData 持久化,了解 SwiftData 新特性有助于理解 TipKit 的存储机制
- Use CloudKit Console to monitor and optimize database activity — 开启 CloudKit 同步后,用 Console 监控和优化数据库活动
- Bring your app’s core features to users with App Intents — App Intents 与 TipKit 都服务于功能发现,前者暴露系统级入口,后者做应用内引导
评论
GitHub Issues · utterances