TipKit, the Apple developer framework for guided discovery, was introduced in iOS 17. It provides a declarative system for defining context-aware guidance for the user of your app.
One of the features in Discretion is to automatically hide the faces in a photo. This feature is referred to as "face masking". However, users may not be aware that these automatically placed face masks can be modified.
In the example shown here, TipKit is used to automatically display an "Edit Masks" tip when a set of specific conditions are met.
Here is the tip in code:
// Tips.swift
import Foundation
import TipKit
// MARK: - Edit Masks Tip
@available(iOS 17, *)
struct EditMasksTip: Tip {
@Parameter static var automaticFacemaskingIsOn: Bool = false;
@Parameter static var hasCompletedOnboarding: Bool = false;
@Parameter static var photoReviewHasJustLoaded: Bool = false;
@Parameter static var activityHUDIsActive: Bool = false;
@Parameter static var maskCount: Int = 0;
static let madeMaskEdit = Tip.Event(id: "madeMaskEdit");
var title: Text {
Text("Edit Masks")
}
var message: Text? {
Text("To change style, move, add or delete masks, tap Edit to open the Mask Editor.")
}
var image: Image? {
Image(systemName: "theatermasks")
}
var options: [TipOption] {
[Tip.MaxDisplayCount(1)]
}
// Note: madeMaskEdit has to be triggered less than 3 times or less to pass!
// (The count starts at event 0!)
var rules: [Rule] {
[
#Rule(Self.$hasCompletedOnboarding) { $0 == true },
#Rule(Self.madeMaskEdit) { $0.donations.count < 2 },
#Rule(Self.$automaticFacemaskingIsOn) { $0 == true },
#Rule(Self.$photoReviewHasJustLoaded) { $0 == true },
#Rule(Self.$maskCount) { $0 > 1 },
#Rule(Self.$activityHUDIsActive) { $0 == false }
]
}
}
// other tips...
TipKit requires iOS 17 or greater, while Discretion targets iOS 15 or greater. To prevent TipKit types from being used on older operating systems, they should be contained within conditional compilation blocks. Normally, a tip is stored in a view controller with a typed reference for access and rule triggering. However, properties can’t be added to classes or structs in conditional compilation blocks based on OS version.
One solution is storing TipKit instances to properties typed with ”Any”. But this requires casting to the correct Tip subtype within a conditional compilation block, making the code more verbose and difficult to maintain.
The solution, shown here, is to create a TipManager object that provides proxy access to TipKit.
import Foundation
import TipKit
// MARK: - Tip Manager
@available(iOS 17, *)
class TipManager {
// MARK: - Singleton Implementation
static let shared = TipManager()
private init() { }
// MARK: - Storage
private var tipContainers: [TipContainer] = []
func add(tip: any Tip, viewController: UIViewController, sourceItem: UIPopoverPresentationControllerSourceItem) {
let tipContainer = TipContainer(tip: tip, viewController: viewController, sourceItem: sourceItem)
tipContainers.append(tipContainer)
}
// MARK: - Public Methods
func activateTips() async {
for tipContainer in tipContainers {
tipContainer.observationTask = await activateObservationTask(tipContainer: tipContainer)
}
}
func deactivateTips() async {
for tipContainer in tipContainers {
await tipContainer.deactivate()
}
}
// MARK: - Private Methods
private func activateObservationTask(tipContainer: TipContainer) async -> Task<Void, Never> {
let tip = tipContainer.tip
return Task { @MainActor in
for await shouldDisplay in tip.shouldDisplayUpdates {
if shouldDisplay {
let popoverController = TipUIPopoverViewController(tip, sourceItem: tipContainer.sourceItem)
guard let viewController = tipContainer.viewController else { return }
viewController.present(popoverController, animated: true)
tipContainer.controller = popoverController
} else if tipContainer.controller is TipUIPopoverViewController {
guard let viewController = tipContainer.viewController else { return }
viewController.dismiss(animated: true)
tipContainer.viewController = nil
}
}
}
}
fileprivate func invalidateTip(id: String) async {
if let tipContainer = tipContainers.first(where: { $0.id == id }) {
tipContainer.tip.invalidate(reason: .actionPerformed)
}
}
}
TipManager uses TipContainer to reference each tip together with all the components that are required to work with them.
// MARK: - Tip Container
@available(iOS 17, *)
class TipContainer {
var tip: any Tip
var id: String
var sourceItem: UIPopoverPresentationControllerSourceItem
weak var viewController: UIViewController!
var controller: Any?
var observationTask: Task<Void, Never>?
init(tip: any Tip, viewController: UIViewController, sourceItem: UIPopoverPresentationControllerSourceItem) {
self.tip = tip
self.id = tip.id
self.sourceItem = sourceItem
self.viewController = viewController
}
func deactivate() async {
if let observationTask {
observationTask.cancel()
self.observationTask = nil
}
}
}
Static convenience functions for mapping triggers and events to the relevant tips...
import Foundation
import TipKit
// MARK: - Tip Triggers and Events
@available(iOS 17, *)
extension TipManager {
// MARK: - Triggers
/// Triggered each time automatic face masking is turned on or off (via main screen or settings).
static func triggerParameter(automaticFacemaskingIsOn: Bool) {
EditMasksTip.automaticFacemaskingIsOn = automaticFacemaskingIsOn
}
/// Triggered when onboarding has completed.
static func triggerParameterHasCompletedOnboarding() {
let hasCompletedOnboarding = true
LoadPhotoTip.hasCompletedOnboarding = hasCompletedOnboarding
DefaultFaceMaskTip.hasCompletedOnboarding = hasCompletedOnboarding
EditMasksTip.hasCompletedOnboarding = hasCompletedOnboarding
BatchLoadTip.hasCompletedOnboarding = hasCompletedOnboarding
}
/// Triggered with true when an image or imeges have been loaded.
/// Triggered false if any action is taken after the image has been loaded.
static func triggerParameter(photoReviewHasJustLoaded: Bool) {
EditMasksTip.photoReviewHasJustLoaded = photoReviewHasJustLoaded
Task { await shared.invalidateTip(id: "LoadPhotoTip") }
}
/// Triggered with true when the activity HUD is active, false when not.
static func triggerParameter(activityHUDIsActive: Bool) {
EditMasksTip.activityHUDIsActive = activityHUDIsActive
}
/// Triggered with the number of masks in the current image (0 when there are no masks, or no image).
static func triggerParameter(maskCount: Int) {
EditMasksTip.maskCount = maskCount
}
// other triggers...
// MARK: - Events
/// Event donated each time a mask is changed in the Mask Editor. (A rough estimate, as not all types
/// of change make a donation).
static func sendEventDonationForMadeMaskEdit() {
EditMasksTip.madeMaskEdit.sendDonation()
}
// other events...
}
Here we store the shared TipManager as an apparent read only TipManager
type on iOS 17 and above (backed by an Any
private property)...
// MainViewController.swift
import UIKit
class MainViewController: UIViewController, UINavigationControllerDelegate {
// MARK: - Outlets
@IBOutlet weak var settingsButtonItem: UIBarButtonItem!
@IBOutlet weak var editImageButtonItem: UIBarButtonItem!
@IBOutlet weak var importButtonItem: UIBarButtonItem!
// MARK: TipKit
private var _tipManager: Any!
@available(iOS 17, *)
func setTipManager(_ tipManager: TipManager) {
_tipManager = tipManager
}
@available(iOS 17, *)
var tipManager: TipManager! {
if let tipManager = _tipManager as? TipManager { return tipManager }
return nil
}
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
if #available(iOS 17, *) {
let tipManager = TipManager.shared
tipManager.add(tip: LoadPhotoTip(), viewController: self, sourceItem: importButtonItem)
tipManager.add(tip: DefaultFaceMaskTip(), viewController: self, sourceItem: settingsButtonItem)
tipManager.add(tip: EditMasksTip(), viewController: self, sourceItem: editImageButtonItem)
tipManager.add(tip: BatchLoadTip(), viewController: self, sourceItem: importButtonItem)
setTipManager(tipManager)
}
}
// more code…
}
The tip kit system is activated and deactivated in viewDidAppear and viewDidDisappear like this...
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if #available(iOS 17, *) {
Task {
await tipManager.activateTips()
}
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if #available(iOS 17, *) {
Task {
await tipManager.deactivateTips()
}
}
}