TipKit в UIKit и SwiftUI

Как добавить подсказки в интерфейс. Примеры кода на SwiftUI и UIKit.

Поможем в Telegram-чате для iOS разработчиков

С помощью TipKit разработчики показывают нативные подсказки. С помощью них можно сделать туториал или обратить внимание пользователя на новые фичи. Подсказки выглядят так:

 Подсказки TipKit
Подсказки 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 — хранилище данных подсказок. Это может быть:

  1. .applicationDefault — дефолтная локация, доступно только приложению
  2. .groupContainer — через appgroup, доступно между таргетами
  3. .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 направление стрелочки выбрать нельзя.

 Всплывающие Popever посказки со стрелками
Всплывающие Popever посказки со стрелками

Встраиваемые 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-подсказки. Они могут быть со стрелкой и без
Inline-подсказки. Они могут быть со стрелкой и без

У 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
}
 Inline-подсказки в коллекции. Можно добавить стрелку
Inline-подсказки в коллекции. Можно добавить стрелку

С помощью .shouldDisplay определяете показывать подсказку или нет:

NewFavoriteCollectionTip().shouldDisplay ? 1 : 0

Управление как для обычной ячейки — через методы делегата для коллекции.

Добавляем кнопку

В подсказку можно добавить кнопку, а по кнопке вызывать вашу логику. Кнопка нужна, чтобы открыть подробный туториал или направить на конкретный экран.

 Как выглядят кнопки в подсказках TipKit
Как выглядят кнопки в подсказках TipKit

Кнопки прописываются в протоколе в поле 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)

В методе укажите причину, почему закрыли подсказку:

  1. .actionPerformed - пользователь выполнил действие в подсказке
  2. .displayCountExceeded - подсказку показали максимальное количество раз
  3. .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())
}
 Зависмость подсказок друг от друга
Зависмость подсказок друг от друга

Одновременно несколько подсказок

Каждую подсказку нужно запускать в отдельном Task

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()
Не забудьте убрать .resetDatastore, иначе в релизе подсказки будут показываться постоянно