Streaming is available in most browsers,
and in the Developer app.
-
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.
Chapters
- 0:00 - Introduction
- 1:18 - Tip groups
- 5:12 - Reusable tips with custom identifiers
- 8:25 - Custom tip styles
- 10:48 - Sync tips with CloudKit
Resources
Related Videos
WWDC23
-
Download
Hi, my name is Jake and I am really excited to share some of the new ways you can customize TipKit to help people learn about your app's new and undiscovered features.
TipKit is a framework that makes it easy to show tips in your app. It can teach someone about a brand-new feature or show a faster way to accomplish a task. TipKit lets you easily create tips and automatically manages their display states and history to ensure they are only shown in the right moments. TipKit also lets you control who should see your tips and when with eligibility rules and display frequency. And TipKit provides different presentation styles to best fit your app's UI and it's available on every platform. And now you can do even more to customize feature discovery so your tips feel integrated and seamless. In this video, I'll cover how to group tips together so features are discovered in the ideal order, show how to make tips reusable with custom tip identifiers, match tips to your app's look and feel with TipViewStyle, and sync the TipKit datastore using CloudKit so tip display states are shared across devices.
But first, tip groups. Tip groups allow you to specify multiple tips and present them one at a time, either in a specific order or using the first tip eligible for display.
We're going to update our app that helps backcountry hikers discover and navigate new trails.
I recently added a compass control to our map with two features we're going to let people know about using tips. The first tip we want to create is about tapping the compass to show the current location on the map. So we'll make a new tip struct with a title, message, and image that describe the feature.
Our second tip is for a gesture that is a little more hidden; our compass control can also be long pressed to rotate the map back to 0 degrees North, so we're going to add a tip about that feature too.
And to display them, we're going to make two calls from the compass to TipKit's popoverTip view modifier. One for the showLocationTip, and another for the rotateMapTip.
These popovers work great, but there's just one problem. Right now, we don't have control over the order our tips are presented in and we really want someone to know about the tap compass feature before showing a tip about long pressing the compass. So let's update this code to use a TipGroup.
To control their display, we're going to add both of our compass tips to a TipGroup. We'll initialize it with an ordered priority. Using an ordered priority ensures that our RotateMapTip won't be displayed until the ShowLocationTip has been invalidated, either by the ShowLocationTip view being dismissed, or someone performing the show location tap gesture. And now we just need to update the compass's popoverTip view modifier to use the currentTip property to display our group's currently available tip.
TipGroup's currentTip property can also be cast as a specific type to customize where a tip is presented. If we had separate buttons for both of our compass features, we could use currentTip as? ShowLocationTip to ensure that that tip's popover will only be presented from the first control. And the RotateMapTip popover will only be presented from the second.
Now that we have both of our compass tips appearing in the correct order, there is only one thing left to add. We need to invalidate both of our tips when the features they describe are used. Invalidating a tip ensures that it will not be shown to someone that has already discovered its feature. Additionally, for TipGroups that use an ordered priority, a tip can only be displayed when all of the preceding tips have been invalidated. TipGroups can be configured with either an ordered or firstAvailable priority. An ordered priority, like the one used for our compassTips, is great for progressively teaching people about related features.
The firstAvailable priority displays the first tip that has its display rules satisfied. This is useful if you have a view with multiple unrelated tips, but only want one to appear at a time. And TipGroups also work great with displayFrequency. Our backcountry trails app configures tips with a weekly displayFrequency. This allows trail seekers time to discover our compass's features on their own before showing them tips about it. TipKit's displayFrequency is an excellent way to avoid overwhelming people with too many tips the first time they launch your app. Tip groups are the perfect way to show tips one at a time and in the exact order you want them to be displayed.
By using tip groups alongside display rules and display frequency you can progressively call out functionality without overburdening your app with too many tips.
Now let's talk about making tips reusable with custom identifiers. A tip's status and rules are unique based on its identifier. Overriding a tip's default identifier allows you to reuse the same tip struct based on its content. Our backcountry trails app was recently updated with support for a new trail that we'd like to let people know about using tips. We'll start by creating a tip for the newly added Butler Fork trailhead with a message about where it's located. We're also going to include an action button so people can easily navigate to the new trail on our map. And because we only want this tip to appear for hikers that will find it most useful, we're going to add an event rule so the tip is only displayed if someone has visited the trail's region 3 or more times.
And to show the tip, we just need to add an instance of it to our TrailList and display it using a TipView. We're also going to add an action handler, so the new trail can be highlighted when the Go there now button is tapped.
But how well will this work if we add support for more new trails in the future? Uh-oh! Our TrailList code is going to be mostly tips if we continue adding new trails to our app. Also multiple tip views could appear at the same time, which would make it difficult for people to access the actual trail list. This approach isn't very scalable so let's update our code to make a reusable tip with a custom identifier.
We'll start by defining a new tip that is created with a specific trail object and give it a message based on that trail's name and region. Next, let's add a custom ID for the tip based on the trail used to initialize it. By customizing that ID, each instance of our NewTrailTip will have a unique status and rules based on the trail they're describing. This will allow our tip to be re-displayed with a new trail even if it has been previously invalidated for a different trail. And to ensure these tips still only appear for hikers interested in the areas they describe, we're going to update our didVisit display rule so it's based on the region of the newly added trail.
Now we just need to change our TrailList code to create a new tip based on the latest trail. This allows us to automatically display a tip anytime we add a new trail to our app. And because we're only creating a single instance of our tip we don't have to worry about multiple NewTrailTips appearing at the same time. Every tip has a persistent record based on its identifier, even if its never displayed. This allows TipKit to make tips eligible based on events that happen across multiple launches of your app. That's why when you specify a custom identifier it's important to base it on something concrete like a user ID or a trail name.
By default, a tip's identifier will be the type name used to initialize it. Overriding that ID allows your tips to be reused based on their content.
Custom identifiers are a great way to have TipKit reuse the same tip model for different tips.
Next, let's talk about customizing the appearance of tip views.
Tips have a great default presentation, but in some cases you may need deeper customization to better match your app's UI. For these tips, you can customize their appearance and behavior using TipViewStyle. We have these really beautiful photos for every trail we add to our hiking app and I think we should use the NewTrailTip to showcase them. So let's make a custom TipViewStyle and use each trail's hero image as the background and have the tip's title and message displayed in an overlay.
For the title and message, we're going to use the properties from the makeBody function's configuration argument instead of the tip's instance values. This will allow any modifiers we apply to our TipView to work with the message and title in our custom style.
And to apply it, we just need to call the tipViewStyle modifier. Now, our tip displays with the trail's gorgeous photo as its background.
But our NewTrailTip also includes an action for quickly highlighting the trail on our map and I don't think we want to put a button on top of that photo.
Instead, lets update our custom style to make the entire tip view tappable.
We'll start by getting the NewTrailTip's action from the configuration argument. Now we just need to call the action handler when the tip view is tapped. Using the actions property from the configuration argument ensures the handler we created as part of our TipView will still be called when the action is performed.
When creating a custom TipViewStyle, it's important to favor the properties from its configuration argument over the tip's instance values whenever possible.
This allows the closures and modifiers applied to your TipViews to still be evaluated when using a custom style.
Custom TipViewStyles also work great alongside other tip view modifiers like tipCornerRadius and tipBackground. And for apps that use UIKit or AppKit, there is a viewStyle property that can be set to change the style of TipUI and TipNSViews. By creating a TipViewStyle you can easily show tips with custom appearances and behaviors while still allowing TipKit's rules engine to handle their display and dismissal.
And now, let's turn to CloudKit syncing.
CloudKit improves your app's user experience by syncing the display states for tips and ensuring someone doesn't need to dismiss the same tip on more than one of their devices. Now that we've added some great tips to our backcountry trails app we should setup CloudKit syncing so our tips's statuses and rules can be shared. We'll start by adding iCloud to the Signing and Capabilities of our Xcode project. From there we'll turn on CloudKit under iCloud Services and create a new container for syncing our tips. We also need to add Background Modes and enable its Remote Notifications capability. This will allow TipKit to process remote changes in the background to ensure our app's trail tips always have the correct status and display state.
For the last step, we just need to update our Tips configuration call to include the cloudKitContainer option and pass in our new container's ID.
And that's it. Now our trail tips will stay in sync across devices so the same tip won't need to be dismissed multiple times. And because TipKit also syncs event and parameter values, the donations that allow our NewTrailTip to appear on one device will be shared, allowing it to be displayed on other devices.
Tips in your app may only appear a few times before being invalidated.
By persisting your tips, TipKit avoids loading their models into memory until they are ready to be displayed. And with CloudKit syncing, a tip's status and rules are shared so tips are not re-displayed on one device after being dismissed from another.
TipKit also syncs events and parameters allowing you to create display rules based on event donations across multiple devices.
And display count and duration values are also synced so tips that specify a MaxDisplayCount can be invalidated based on their total number of shared appearances.
In some cases, you may want to use CloudKit syncing but still have certain tips that are unique on different platforms. For those, you can use UIDevice to create platform specific tip IDs that allow the same tip to be re-displayed on multiple devices.
And for testing, TipKit's resetDatastore function will clear the local datastore as well as the CloudKit records for all of your tips.
TipKit is built on top of the powerful persistence of SwiftData. This lets tip statuses, rules, parameters, and events retain their values across app launches. And with CloudKit syncing it's even easier to share these values between devices.
TipKit has powerful tools to ensure your app's tips are only shown at the perfect time and to the audience that will find them most useful. Use TipGroup to allow your features to be discovered one at a time and in the ideal order. Tip groups work great alongside display rules and display frequency for customizing feature discovery in your app. And to learn more about creating display rules check out last year's WWDC video "Make features discoverable with TipKit".
Custom identifiers provide an easy way to make reusable tip models so tips can be re-displayed based on their content. TipViewStyle can be used to create custom layouts and interactions for your tips so they always match your app's UI. And CloudKit can be used to sync TipKit's datastore across devices so your tips don't get re-displayed unnecessarily.
On behalf of the entire TipKit team, I'd like to say thank you so much for joining me today and we can't wait to see how TipKit can help people discover your apps' great new features!
-
-
1:43 - 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") } }
-
1:54 - 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") } }
-
2:09 - 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() } } }
-
2:41 - Create a TipGroup
// Create a TipGroup 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() } } }
-
3:15 - Show TipGroup tips on different views
// Show TipGroup tips on different views struct MapControlsStack: View { @State var compassTips: TipGroup(.ordered) { ShowLocationTip() RotateMapTip() } var body: some View { VStack { ShowLocationButton() .popoverTip(compassTips.currentTip as? ShowLocationTip) RotateMapButton() .popoverTip(compassTips.currentTip as? RotateMapTip) } } }
-
3:50 - Invalidate tips
// Invalidate tips struct MapCompassControl: View { @State 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() } } }
-
5:37 - 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 } } }
-
6:01 - 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) } } }
-
6:45 - 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 } } }
-
7:26 - 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) } } }
-
8:55 - 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) } } }
-
9:20 - 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) } } }
-
9:45 - 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) } } }
-
11:38 - 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)") } } }
-
-
Looking for something specific? Enter a topic above and jump straight to the good stuff.
An error occurred when submitting your query. Please check your Internet connection and try again.