WWDC Quick Look 💓 By SwiftGGTeam
Customize feature discovery with TipKit

Customize feature discovery with TipKit

观看原视频

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 则展示第一个满足规则的 Tip
  • compassTips.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.titleconfiguration.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

评论

GitHub Issues · utterances