2024 App Services
WWDC24 · 15 min · App Services
Customize feature discovery with TipKit
Focused on feature discovery, the TipKit framework makes it easy to display tips in your app. Now you can group tips so features are discovered in the ideal order, make tips reusable with custom tip identifiers, match the look and feel to your app, and sync tips using CloudKit. Learn how you can use the latest advances in TipKit to help people discover everything your app has to offer.
Watch at developer.apple.com ↗Chapters
Code shown on screen · 14 snippets
Create new tips
// Create new tips
struct ShowLocationTip: Tip {
var title: Text {
Text("Show your location")
}
var message: Text? {
Text("Tap the compass to highlight your current location on the map.")
}
var image: Image? {
Image(systemName: "location.circle")
}
} Create new tips
// Create new tips
struct ShowLocationTip: Tip {
var title: Text {
Text("Show your location")
}
var message: Text? {
Text("Tap the compass to highlight your current location on the map.")
}
var image: Image? {
Image(systemName: "location.circle")
}
}
struct RotateMapTip: Tip {
var title: Text {
Text("Reorient the map")
}
var message: Text? {
Text("Tap and hold on the compass to rotate the map back to 0° North.")
}
var image: Image? {
Image(systemName: "hand.tap")
}
} Show popover tips
// Show popover tips
struct MapCompassControl: View {
let showLocationTip = ShowLocationTip()
let rotateMapTip = RotateMapTip()
var body: some View {
CompassDial()
.popoverTip(showLocationTip)
.popoverTip(rotateMapTip)
.onTapGesture {
showCurrentLocation()
}
.onLongPressGesture(minimumDuration: 0.1) {
reorientMapHeading()
}
}
} Create a TipGroup
// Create a TipGroup
struct MapCompassControl: View {
var compassTips: TipGroup(.ordered) {
ShowLocationTip()
RotateMapTip()
}
var body: some View {
CompassDial()
.popoverTip(compassTips.currentTip)
.onTapGesture {
showCurrentLocation()
}
.onLongPressGesture(minimumDuration: 0.1) {
reorientMapHeading()
}
}
} Show TipGroup tips on different views
// Show TipGroup tips on different views
struct MapControlsStack: View {
var compassTips: TipGroup(.ordered) {
ShowLocationTip()
RotateMapTip()
}
var body: some View {
VStack {
ShowLocationButton()
.popoverTip(compassTips.currentTip as? ShowLocationTip)
RotateMapButton()
.popoverTip(compassTips.currentTip as? RotateMapTip)
}
}
} Invalidate tips
// Invalidate tips
struct MapCompassControl: View {
var compassTips: TipGroup(.ordered) {
showLocationTip
rotateMapTip
}
var body: some View {
CompassDial()
.popoverTip(compassTips.currentTip)
.onTapGesture {
showLocationTip.invalidate(reason: .actionPerformed)
showCurrentLocation()
}
.onLongPressGesture(minimumDuration: 0.1) {
rotateMapTip.invalidate(reason: .actionPerformed)
reorientMapHeading()
}
}
} Create a tip
// Create a tip
struct ButlerForkTip: Tip {
var title: Text {
Text("Butler Fork is now available")
}
var message: Text? {
Text("To see key trail info, tap Big Cottonwood Canyon on the map.")
}
var actions: [Action] {
Action(title: "Go there now")
}
var rules: [Rule] {
#Rule(Region.bigCottonwoodCanyon.didVisitEvent) {
$0.donations.count > 3
}
}
} Show a TipView
// Show a TipView
struct ButlerForkTip: Tip {
var title: Text {
Text("Butler Fork is now available")
}
var message: Text? {
Text("To see key trail info, tap Big Cottonwood Canyon on the map.")
}
var actions: [Action] {
Action(title: "Go there now")
}
var rules: [Rule] {
#Rule(Region.bigCottonwoodCanyon.didVisitEvent) {
$0.donations.count > 3
}
}
}
struct TrailList: View {
var trails: [Trail]
var body: some View {
ScrollView {
let butlerForkTip = ButlerForkTip()
TipView(butlerForkTip) { _ in
highlightButlerForkTrail()
}
ListSection(title: "Trails", trails: trails)
}
}
} Create a reusable tip
// Create a reusable tip
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
}
}
} Show a TipView
// Show a TipView
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
}
}
}
struct TrailList: View {
var trails: [Trail]
let newTrail: Trail
var body: some View {
ScrollView {
let newTrailTip = NewTrailTip(newTrail: newTrail)
TipView(newTrailTip) { _ in
highlightTrail(newTrailTip)
}
ListSection(title: "Trails", trails: trails)
}
}
} Create a custom TipViewStyle
// Create a custom TipViewStyle
struct NewTrailTipViewStyle: TipViewStyle {
func makeBody(configuration: Configuration) -> some View {
let tip = configuration.tip as! NewTrailTip
TrailImage(imageName: tip.newTrail.heroImage)
.frame(maxHeight: 150)
.overlay {
VStack {
configuration.title.font(.title)
configuration.message.font(.subheadline)
}
}
}
}
extension NewTrailTipViewStyle {
struct TrailImage: View {
let imageName: String
var body: some View {
Image(imageName)
.resizable()
.aspectRatio(contentMode: .fill)
}
}
} Apply a TipViewStyle
// Apply a TipViewStyle
struct NewTrailTipViewStyle: TipViewStyle {
func makeBody(configuration: Configuration) -> some View {
let tip = configuration.tip as! NewTrailTip
TrailImage(imageName: tip.newTrail.heroImage)
.frame(maxHeight: 150)
.overlay {
VStack {
configuration.title.font(.title)
configuration.message.font(.subheadline)
}
}
}
}
extension NewTrailTipViewStyle {
struct TrailImage: View {
let imageName: String
var body: some View {
Image(imageName)
.resizable()
.aspectRatio(contentMode: .fill)
}
}
}
struct TrailList: View {
var trails: [Trail]
let newTrail: Trail
var body: some View {
ScrollView {
let newTrailTip = NewTrailTip(newTrail: newTrail)
TipView(newTrailTip) { _ in
highlightTrail(newTrailTip)
}
.tipViewStyle(NewTrailTipViewStyle())
ListSection(title: "Trails", trails: trails)
}
}
} Add the tip's action handler
// Apply a TipViewStyle
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)
}
}
}
}
}
extension NewTrailTipViewStyle {
struct TrailImage: View {
let imageName: String
var body: some View {
Image(imageName)
.resizable()
.aspectRatio(contentMode: .fill)
}
}
}
struct TrailList: View {
var trails: [Trail]
let newTrail: Trail
var body: some View {
ScrollView {
let newTrailTip = NewTrailTip(newTrail: newTrail)
TipView(newTrailTip) { _ in
highlightTrail(newTrailTip)
}
.tipViewStyle(NewTrailTipViewStyle())
ListSection(title: "Trails", trails: trails)
}
}
} Add CloudKit sync for tips
// Add CloudKit sync for tips
@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)")
}
}
} Resources
Related sessions
-
15 min