С помощью TipKit разработчики показывают нативные подсказки. С помощью них можно сделать туториал или обратить внимание пользователя на новые фичи. Подсказки выглядят так:
Apple сделала и UI подсказок и управление когда их показывать. Фреймворк доступен с iOS 17 для всех платформ — iOS, iPadOS, macOS, watchOS и visionOS.
В каждом разделе нашего туториала будут примеры и на SwiftUI, и на UIKit.
Инициализация
Импортируем TipKit и в точке входа в приложение вызываем метод конфигурации:
Для SwiftUI
import SwiftUI
import TipKit
@main
struct TipKitExampleApp: App {
var body: some Scene {
WindowGroup {
TipKitDemo()
.task {
try? Tips.configure([
.displayFrequency(.immediate),
.datastoreLocation(.applicationDefault)
])
}
}
}
}
Для UIKit, в AppDelegate:
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
try? Tips.configure([
.displayFrequency(.immediate),
.datastoreLocation(.applicationDefault)])
return true
}
displayFrequency определяет как часто показывать подсказку. В примере стоит .immediate, подсказки будут показываться сразу. Можно поставить ежечасно, ежедневно, еженедельно и ежемесячно.
datastoreLocation — хранилище данных подсказок. Это может быть:
- .applicationDefault — дефолтная локация, доступно только приложению
- .groupContainer — через appgroup, доступно между таргетами
- .url — указываете свой путь
По умолчанию стоит .applicationDefault.
Создаем подсказку
Протокол Tip определяет контент и когда показывать подсказку. Картинка и подзаголовок опциональные:
struct FavoritesTip: Tip {
var title: Text {
Text("Add to Favorite")
}
var message: Text? {
Text("This user will be added to your favorites folder.")
}
var image: Image? {
Image(systemName: "heart")
}
}
Есть два вида подсказок — Popover показывается поверх интерфейса, а Inline встраивается как обычная вью.
Всплывающие Popover
Для SwiftUI
Вызываем модификатор popoverTip у вью, к которой добавить подсказку:
Image(systemName: "heart")
.popoverTip(FavoritesTip(), arrowEdge: .bottom)
Для UIKit
Слушаем подсказки через асинхронный метод. Когда shouldDisplay будет в тру, добавляем popover-контроллер. Передаем ему подсказку и вью, к которой привязать подсказку:
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
Task { @MainActor in
for await shouldDisplay in FavoritesTip().shouldDisplayUpdates {
if shouldDisplay {
let popoverController = TipUIPopoverViewController(FavoritesTip(), sourceItem: favoriteButton)
present(popoverController, animated: true)
}
// Сейчас крестик работать не будет, это нормально.
// Разберем дальше как это поправить
}
}
У Popever-подсказок стрелочка есть всегда, но направление стрелки может отличаться от того что укажите. В UIKit направление стрелочки выбрать нельзя.
Встраиваемые Inline
Inline-подсказки встраиваются между ваших вью и меняют лейаут. Они не перекрывают интерфейс приложения как Popever-подсказки. Добавлять их как обычные вью:
Для SwiftUI
VStack {
Image("pug")
.resizable()
.scaledToFit()
.clipShape(RoundedRectangle(cornerRadius: 12))
TipView(FavoritesTip())
}
Для UIKit
Добавляем так же через асинхронный метод, только когда shouldDisplay в тру:
Task { @MainActor in
for await shouldDisplay in FavoritesTip().shouldDisplayUpdates {
if shouldDisplay {
let tipView = TipUIView(FavoritesTip())
view.addSubview(tipView)
}
// Сейчас крестик работать не будет, это нормально.
// Разберем дальше как это поправить
}
}
У Inline-подсказок стрелочка опциональная. Направление стрелки будет именно такое, как вы укажите:
// SwiftUI
TipView(inlineTip, arrowEdge: .top)
TipView(inlineTip, arrowEdge: .leading)
TipView(inlineTip, arrowEdge: .trailing)
TipView(inlineTip, arrowEdge: .bottom)
// UIKit
TipUIView(FavoritesTip(), arrowEdge: .bottom)
Ячейка в UICollectionView
В UIKit есть специальный класс ячейки TipUICollectionViewCell для подсказок в коллекции. Работает как обычная ячейка, а для конфигурации нужно вызывать .configureTip:
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
TipUICollectionViewCell
cell.configureTip(NewFavoriteCollectionTip())
return cell
}
С помощью .shouldDisplay определяете показывать подсказку или нет:
NewFavoriteCollectionTip().shouldDisplay ? 1 : 0
Управление как для обычной ячейки — через методы делегата для коллекции.
Добавляем кнопку
В подсказку можно добавить кнопку, а по кнопке вызывать вашу логику. Кнопка нужна, чтобы открыть подробный туториал или направить на конкретный экран.
Кнопки прописываются в протоколе в поле actions:
struct ActionsTip: Tip {
var title: Text {...}
var message: Text? {...}
var image: Image? {...}
var actions: [Action] {
Action(id: "reset-password", title: "Reset Password")
Action(id: "not-reset-password", title: "Cancel reset")
}
}
id нужен чтобы определить какую кнопку нажали:
Для SwiftUI
TipView(tip) { action in
if action.id == "reset-password" {
// Делаем то что нужно по нажатию
}
}
Для UIKit
Task { @MainActor in
for await shouldDisplay in ActionsTip().shouldDisplayUpdates {
if shouldDisplay {
let tipView = TipUIView(ActionsTip()) { action in
if action.id == "reset-password" {
// Делаем то что нужно по нажатию
}
let controller = TipKitViewController()
self.present(controller, animated: true)
}
view.addSubview(tipView)
}
}
}
Закрываем подсказку
Подсказку может закрыть пользователь, когда нажмет на крестик. Но можно закрыть и кодом. Код одинаковый для SwiftUI и UIKit:
inlineTip.invalidate(reason: .actionPerformed)
В методе укажите причину, почему закрыли подсказку:
- .actionPerformed - пользователь выполнил действие в подсказке
- .displayCountExceeded - подсказку показали максимальное количество раз
- .tipClosed - пользователь явно закрыл подсказку
В UIKit для крестика нужно дописать код. Для popover-подсказки закрываем контроллер:
if presentedViewController is TipUIPopoverViewController {
dismiss(animated: true)
}
Для inline-подсказки удаляем вью:
if let tipView = view.subviews.first(where: { $0 is TipUIView }) {
tipView.removeFromSuperview()
}
Правила для подсказок: когда показывать
Когда показывать подсказку настраивается с помощью параметров:
struct FavoriteRuleTip: Tip {
var title: Text {...}
var message: Text? {...}
@Parameter
static var hasViewedTip: Bool = false
var rules: [Rule] {
#Rule(Self.$hasViewedTip) { $0 == true }
}
}
Rule проверяет значение переменной hasViewedTip, когда значение равно true, подсказка отобразится.
Для SwiftUI
struct ParameterRule: View {
var body: some View {
VStack {
Spacer()
Button("Rule") {
FavoriteRuleTip.hasViewedTip = true
}
.buttonStyle(.borderedProminent)
.popoverTip(FavoriteRuleTip(), arrowEdge: .top)
}
}
}
Для UIKit
Task { @MainActor in
for await shouldDisplay in FavoriteRuleTip().shouldDisplayUpdates {
if shouldDisplay {
let rulesController = TipUIPopoverViewController(FavoriteRuleTip(), sourceItem: favoriteButton)
present(rulesController , animated: true)
} else if presentedViewController is TipUIPopoverViewController {
dismiss(animated: true)
}
}
}
@objc func favoriteButtonPressed() {
FavoriteRuleTip.hasViewedTip = true
}
Когда подсказка зависит от другой подсказки
В этом примере сначала появится GettingStartedTip, а после FavoriteRuleTip:
struct GettingStartedTip: Tip {...}
struct FavoriteRuleTip: Tip {
var title: Text {
Text("Add to Favorite")
}
var message: Text? {
Text("This user will be added to your favorites folder.")
}
@Parameter
static var hasViewedGetStartedTip: Bool = false
var rules: [Rule] {
#Rule(Self.$hasViewedGetStartedTip) { $0 == true }
}
}
Теперь пример как менять флаги между подсказками:
VStack {
Rectangle()
.frame(height: 100)
.popoverTip(FavoriteRuleTip(), arrowEdge: .top)
.onTapGesture {
// Юзер выполнил действие, отключаем подсказку GettingStartedTip
GettingStartedTip().invalidate(reason: .actionPerformed)
// Значение hasViewedGetStartedTip true, значит показываем подсказку FavoriteRuleTip
FavoriteRuleTip.hasViewedGetStartedTip = true
}
// Подсказка видна сразу
TipView(GettingStartedTip())
}
Одновременно несколько подсказок
Inline-подсказок на экране может быть сколько угодно. Popover-подсказка может быть одна, но их можно показывать по очереди через флаги. Как это работает описал в предыдущем пункте.
Кастомизация подсказки
Протокол TipViewStyle определяет стиль подсказки. Стиль потом можно применять к любой подсказке.
Параметр configuration в методе makeBody это доступ к текстам, картинкам и кнопкам, которые можно кастомизировать:
struct MyTipViewStyle: TipViewStyle {
func makeBody(configuration: Configuration) -> some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
HStack {
configuration.image
configuration.title
}
.font(.title2)
.fontWeight(.bold)
Spacer()
Button(action: {
configuration.tip.invalidate(reason: .tipClosed)
}, label: {
Image(systemName: "xmark.octagon.fill")
})
}
configuration.message?
.font(.body)
.fontWeight(.regular)
.foregroundStyle(.secondary)
Button(action: configuration.actions.first!.handler, label: {
configuration.actions.first!.label()
})
.buttonStyle(.bordered)
.foregroundColor(.pink)
}
.padding()
}
}
Здесь можно создать кнопку, чтобы закрывать подсказку. .tipClosed — явное закрытие подсказки по крестику.
Button(action: {
configuration.tip.invalidate(reason: .tipClosed)
}, label: {
Image(systemName: "xmark.octagon.fill")
})
Добавляем в SwiftUI:
TipView(MyFavoriteTip())
.tipViewStyle(MyTipViewStyle())
Добавляем в UIKit:
let tipView = TipUIView(MyFavoriteTip())
tipView.viewStyle = MyTipViewStyle()
TipKit в Preview
Если закроете подсказку в Preview, она больше не покажется — это не удобно. Чтобы подсказки появлялись каждый раз, нужно сбросить хранилище данных:
SwiftUI
#Preview {
TipKitDemo()
.task {
// Cбрасываем хранилище
try? Tips.resetDatastore()
// Конфигурируем
try? Tips.configure([
.displayFrequency(.immediate),
.datastoreLocation(.applicationDefault)
])
}
}
UIKit
Добавить в AppDelegate:
try? Tips.resetDatastore()