Когда TipKit был впервые упомянут во время выступления на WWDC 2023, я поначалу предположил, что это какой-то новый способ отображения приложений в приложении Tips и, возможно, Spotlight. Вместо этого мы увидели встроенный компонент для добавления небольших обучающих представлений в наши собственные приложения на всех платформах с системой правил для отображения на основе условий и синхронизацией на нескольких устройствах через iCloud! Более того, Apple сама использует этот компонент в iOS 17, например, в приложениях Messages и Photos.

Разработав в прошлом несколько систем всплывающих сообщений с подсказками, я с нетерпением ждал анонса этого функционала на WWDC 2023. Я был несколько разочарован, когда бета-версия за бета-версией Xcode пропускала фреймворк TipKit, но, к счастью, в Xcode 15 beta 5 (вышедшей за день до публикации этой статьи) он наконец появился вместе с соответствующей документацией, позволяя нам интегрировать подсказки (tips) в свои собственные приложения.

Прежде чем я покажу, как работает TipKit и как его можно внедрить в наши приложения, вот очень важный совет, который дала Элли Гаттоцци (Ellie Gattozzi) в докладе "Делаем функции обнаруживаемыми с помощью TipKit" на WWDC 2023:

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

Итак, давайте создадим нашу первую подсказку!

Примечание: ниже я привел код для SwiftUI и UIKit, но Apple также предоставила способ отображения подсказок в AppKit. Следует отметить, что версии UIKit недоступны для watchOS и tvOS. Также стоит отметить, что во фреймворке TipKit бета-версии 5 есть несколько ошибок, в частности, связанных с действиями, которые я описал ниже.

1. Создание подсказки

Для начала нам необходимо инициировать работу системы Tips при запуске нашего приложения с помощью функции Tips.configure()1:

// SwiftUI
var body: some Scene {
    WindowGroup {
        ContentView()
        .task {
            try? await Tips.configure()
        }
    }
}

// UIKit
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    Task {
        try? await Tips.configure()
    }
    return true
}

Далее мы создаем структуру, определяющую нашу подсказку:

struct SearchTip: Tip {
    var title: Text {
        Text("Add a new game")
    }
    
    var message: Text? {
        Text("Search for new games to play via IGDB.")
    }
    
    var asset: Image? {
        Image(systemName: "magnifyingglass")
    }
}

И наконец, мы выводим нашу подсказку на экран:

// SwiftUI
ExampleView()
    .toolbar(content: {
        ToolbarItem(placement: .primaryAction) {
            Button {
                displayingSearch = true
            } label: {
                Image(systemName: "magnifyingglass")
            }
            .popoverTip(SearchTip())
        }
    })


// UIKit
class ExampleViewController: UIViewController {
    var searchButton: UIButton
    var searchTip = SearchTip()

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        Task { @MainActor in
            for await shouldDisplay in searchTip.shouldDisplayUpdates {
                if shouldDisplay {
                    let controller = TipUIPopoverViewController(searchTip, sourceItem: searchButton)
                    present(controller)
                } else if presentedViewController is TipUIPopoverViewController {
                    dismiss(animated: true)
                }
            }
        }
    }
}

Этот код — это все, что требуется для отображения нашей подсказки при первом появлении представления:

A popover tip using TipKit
Всплывающая подсказка, реализованная с помощью TipKit

Существует два вида представлений подсказок:

  • Всплывающее (popover): отображается в виде наложения на пользовательский интерфейс приложения, что позволяет ориентировать пользователей, не меняя представления.

  • Встраиваемое (in-line): временно перестраивает пользовательский интерфейс приложения вокруг себя, чтобы ничего не было перекрыто (недоступно в tvOS)

Если бы мы хотели вывести встраиваемую подсказку, то наш код выглядел бы следующим образом:

// SwiftUI
VStack {
    TipView(LongPressTip())
}

// UIKit
class ExampleViewController: UIViewController {
    var longPressGameTip = LongPressGameTip()

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        Task { @MainActor in
            for await shouldDisplay in longPressGameTip.shouldDisplayUpdates {
                if shouldDisplay {
                    let tipView = TipUIView(longPressGameTip)
                    view.addSubview(tipView)
                } else if let tipView = view.subviews.first(where: { $0 is TipUIView }) {
                    tipView.removeFromSuperview()
                }
            }
        }
    }
}
An in-line tip using TipKit
Встраиваемая подсказка, реализованная с помощью TipKit

В UIKit также имеется класс TipUICollectionViewCell для отображения подсказок в представлении коллекции, который можно использовать и в табличных интерфейсах. Код SwiftUI, безусловно, менее многословный.

2. Кастомизация подсказок

Вы можете кастомизировать подсказки, изменяя цвет текста и шрифтов, цвет фона, радиус скругления углов и иконки. Представления подсказок также полностью совместимы с темной темой.

Шрифты и цвет текста

Это настраивается в самих структурах Tip, поскольку вы возвращаете инстансы SwiftUI.Text, даже если в конечном итоге отображаете подсказку в UIKit или AppKit.

struct LongPressTip: Tip {
    var title: Text {
        Text("Add to list")
            .foregroundStyle(.white)
            .font(.title)
            .fontDesign(.serif)
            .bold()
    }
    
    var message: Text? {
        Text("Long press on a game to add it to a list.")
            .foregroundStyle(.white)
            .fontDesign(.monospaced)
    }
    
    var asset: Image? {
        Image(systemName: "hand.point.up.left")
    }
}

Поскольку и заголовок, и сообщение используют Text, можно использовать любые модификаторы, возвращающие экземпляр Text, такие как foregroundStyle, font, а также всякие удобные методы типа bold(). Иконка возвращается в виде Image, поэтому, если мы хотим изменить что-либо, например, цвет иконки, мы должны сделать это в самом представлении Tip:

Цвет иконки, цвет фона и цвет кнопки закрытия

// SwiftUI
TipView(LongPressGameTip())
    .tipBackground(.black)
    .tint(.yellow)
    .foregroundStyle(.white)

// UIKit
let tipView = TipUIView(LongPressGameTip())
tipView.backgroundColor = .black
tipView.tintColor = .yellow

Для изменения цвета фона подсказки предусмотрен специальный метод, для изменения цвета иконки необходимо использовать глобальный оттенок, а цвет кнопки закрытия зависит от foregroundStyle; обратите внимание, что эта кнопка на 50% непрозрачна, поэтому если вы используете темный фон, то вряд ли сможете рассмотреть что-нибудь, кроме белого. Похоже, что в UIKit не предусмотрен способ изменить цвет этой кнопки.

Несмотря на то, что пока нет Human Interface Guidelines для подсказок, просмотр бета-версии iOS 17 и выступления на WWDC 2023 показывает, что Apple использует незаполненные SF Symbols для всех своих подсказок. По этой причине я рекомендую вам поступать точно так же!

Радиус скругления угла

// SwiftUI
TipView(LongPressGameTip())
    .tipCornerRadius(8)

По умолчанию радиус скругления угла для подсказок в iOS равен 13. Если вы хотите изменить его, чтобы он сочетался с другими скругленными элементами в вашем приложении, вы можете сделать это с помощью функции tipCornerRadius() в SwiftUI. В UIKit нет возможности изменить радиус скругления угла для представлений подсказок.

A customised tip view with new colours and fonts
Кастомизированное представление подсказки с новыми цветами и шрифтами. Я знаю, насколько некрасиво это выглядит.

Я был приятно удивлен тем, насколько гибким оказался дизайн первой версии TipKit. Однако я бы поостерегся слишком сильно кастомизировать подсказки, поскольку их схожесть с дефолтными системным подсказкам, несомненно, положительно скажется на удобстве работы.

3. Прямо к действию!

Подсказки позволяют добавлять несколько кнопок, называемых действиями (actions), которые могут использоваться для перехода к соответствующей настройке или более подробному руководству. Эта функция недоступна в tvOS.

Чтобы добавить действие, сначала необходимо настроить структуру Tip, добавив в нее некоторые идентифицирующие данные:

// SwiftUI
struct LongPressGameTip: Tip {
    
    // [...] заголовок, сообщение, ассет
    
    var actions: [Action] {
        [Action(id: "learn-more", title: "Learn More")]
    }
}

Обратите внимание, что инициализатор Action также имеет позволяет использовать блок Text, а не String, что позволяет выполнять все те настройки цвета и шрифта, о которых говорилось ранее.

An action button within a Tip View
Кнопка действия в представлении подсказки

После этого мы можем изменить представление подсказки таким образом, чтобы она выполняла действие после нажатия кнопки:

// SwiftUI
Button {
    displayingSearch = true
} label: {
    Image(systemName: "magnifyingglass")
}
    .popoverTip(LongPressGameTip()) { action in
        guard action.id == "learn-more" else { return }
        displayingLearnMore = true
    }

// UIKit
let tipView = TipUIView(LongPressGameTip()) { action in
    guard action.id == "learn-more" else { return }
    let controller = TutorialViewController()
    self.present(controller, animated: true)
}

В качестве альтернативы мы можем добавить обработчики действий непосредственно в структуру Tip:

var actions: [Action] {
    [Action(id: "learn-more", title: "Learn More", perform: {
        print("'Learn More' pressed")
    })]
}

Важно: Несмотря на то, что в Xcode 15 beta 5 можно добавлять действия, обработчики не срабатывают при нажатии на кнопку независимо от того, какой метод используется для их подключения — из структуры или из представления.

И последнее, что следует отметить в отношении действий, — их можно отключить, если по каким-либо причинам вы не хотите их выполнять (например, если пользователь не залогинился в системе или не подписался на премиум-функции):

var actions: [Action] {
    [Action(id: "pro-feature", title: "Add a new list", disabled: true)]
}

4. Оглашаем правила

По умолчанию подсказки появляются сразу после того, как на экране появляется представление, к которому они привязаны. Однако вы можете не показывать подсказку в определенном представлении до тех пор, пока не будет выполнено какое-нибудь условие (например, пользователь должен залогиниться в систему), или вам нужно, чтобы пользователь повзаимодействовал с функцией определенное количество раз, прежде чем появится подсказка. К счастью, компания Apple подумала об этом и добавила концепцию, известную как "правила" (rules), которая позволяет ограничить появление подсказок.

Существует два типа правил:

  • Параметрические: Являются постоянными и в основном сопоставляются с логическими типами значений Swift

  • Событийные: Определяет действие, которое должно быть выполнено, прежде чем подсказка будет допущена к показу

Важно: В Xcode 15 beta 5 обнаружена ошибка, из-за которой макрос @Parameter не компилируется на симуляторах или в приложениях для macOS. В качестве временного решения можно добавить следующее значение в настройку сборки “Other Swift Flags”:

-external-plugin-path $(SYSTEM_DEVELOPER_DIR)/Platforms/iPhoneOS.platform/Developer/usr/lib/swift/host/plugins#$(SYSTEM_DEVELOPER_DIR)/Platforms/iPhoneOS.platform/Developer/usr/bin/swift-plugin-server

Параметрические правила

struct LongPressGameTip: Tip {
    
    @Parameter
    static var isLoggedIn: Bool = false
    
    var rules: [Rule] {
        #Rule(Self.$isLoggedIn) { $0 == true }
    }
    
    // [...] заголовок, сообщение, актив, действия и т.д.
    
}

Благодаря новой поддержке макросов в Xcode 15 их синтаксис относительно прост. Сначала мы определяем статическую переменную для условия, в данном случае булевую, которая определяет, залогинился ли пользователь в систему. Затем мы задаем правило, основанное на истинности этого условия.

Если мы запустим наше приложение сейчас, то подсказка больше не будет отображаться при запуске. Однако если мы отметим статическое свойство как true, то подсказка появится при следующем отображении соответствующего представления:

LongPressGameTip.isLoggedIn = true

Событийные правила

struct LongPressGameTip: Tip {
    
    static let appOpenedCount = Event(id: "appOpenedCount")
        
    var rules: [Rule] {
        #Rule(Self.appOpenedCount) { $0.donations.count >= 3 }
    }
    
    // [...] заголовок, сообщение, ассет, действия и т.д.
    
}

Правила, привязанные к событиям, несколько отличаются тем, что вместо параметра мы используем объект Event с выбранным нами идентификатором. Затем правило проверяет свойство donations этого события, чтобы определить (в нашем конкретном примере), было ли приложение открыто три или более раз. Для того чтобы это правило работало, нам необходимо иметь возможность сделать "donate" при наступлении этого события. Для этого мы используем метод donate в самом событии:

SomeView()
    .onAppear() {
        LongPressTip.appOpenedCount.donate()
    }

Свойство donation в событии содержит свойство date, которое устанавливается в то время, когда событие вызывало donate. А это значит, что мы можем добавить правило для проверки того, открывал ли пользователь приложение три или более раза:

struct LongPressGameTip: Tip {
    
    static let appOpenedCount: Event = Event(id: "appOpenedCount")
        
    var rules: [Rule] {
        #Rule(Self.appOpenedCount) {
            $0.donations.filter {
                Calendar.current.isDateInToday($0.date)
            }
            .count >= 3
        }
    }
    
    // [...] заголовок, сообщение, ассет, действия и т.д.
    
}

Важно: Несмотря на то, что в соответствии с докладом WWDC 2023 этот код должен работать, при запуске в Xcode 15 beta 5 он выдает ошибку “the filter function is not supported in this rule”.

5. Отображать или не отображать?

Хотя правила могут ограничивать отображение подсказок для показа в нужный нам момент, всегда существует вероятность того, что несколько подсказок могут попытаться отобразиться одновременно. Также может оказаться, что мы больше не хотим показывать подсказку, если пользователь начал взаимодействовать с нашей функцией до того, как была показана подсказка. Чтобы решить эту проблему, Apple предоставляет нам способы управления частотой, количеством отображений и аннулирования подсказок. Также предусмотрен механизм синхронизации состояния отображения подсказок на нескольких устройствах.

Частота

По умолчанию подсказки отображаются сразу же, как только им становится разрешено это сделать. Мы можем изменить это, задав параметр DisplayFrequency при инициализации Tips при запуске приложения:

try? await Tips.configure(options: {
    DisplayFrequency(.daily)
})

Этот код задает ограничение на показ одной подсказки в день.

Существует несколько предопределенных значений DisplayFrequency, таких как .daily и .hourly, но вы также можете указать TimeInterval, если вам нужно какое-то индивидуальное значение. В качестве альтернативы всегда можно восстановить поведение по умолчанию, используя значение .immediate.

Если вы установили не мгновенную частоту отображения, но у вас есть подсказка, которую вы хотите отобразить немедленно, вы можете сделать это с помощью опции IgnoresDisplayFrequency() в структуре Tip:

struct LongPressGameTip: Tip {
    
    var options: [TipOption] {
        [Tip.IgnoresDisplayFrequency(true)]
    }
    
    // [...] заголовок, сообщение, ассет, действия и т.д.
    
}

Счетчик числа отображений

Если подсказка не отменена пользователем вручную, то она будет показана вновь при следующем появлении соответствующего представления даже после запуска приложения. Чтобы избежать повторного показа подсказки пользователю, можно задать значение MaxDisplayCount, которое ограничит количество показов, по достижении которых подсказка перестанет отображаться:

struct LongPressGameTip: Tip {
    
    var options: [TipOption] {
        [Tip.MaxDisplayCount(3)]
    }
    
    // [...] заголовок, сообщение, ассет, действия и т.д.
    
}

Отмена подсказки

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

longPressGameTip.invalidate(reason: .userPerformedAction)

Существует три возможных причины признания подсказки аннулированной:

  • maxDisplayCountExceeded

  • userClosedTip

  • userPerformedAction

Первые два действия выполняются системой в зависимости от того, кто вызвал отмену подсказки — счетчик количества отображений или пользователь. Это означает, что при аннулировании подсказок всегда следует использовать .userPerformedAction.

Синхронизация с iCloud

В докладе "Делаем функции обнаруживаемыми с помощью TipKit" Чарли Паркс (Charlie Parks) упоминает:

TipKit также может синхронизировать статус подсказок через iCloud, чтобы гарантировать, что подсказки, отображенные на одном устройстве, не будут отображены на другом. Например, если приложение установлено на iPad и iPhone, а его функционал идентичен на обоих устройствах, то, вероятно, лучше не информировать пользователя об одной и той же функции на обоих устройствах.

Эта фича включена по умолчанию без возможности ее отключения, поэтому вам придется предоставлять пользовательские идентификаторы для каждой подсказки на поддерживаемых платформах, если вы хотите, чтобы подсказки по каким-либо причинам отображались повторно на каждом устройстве (например, если пользовательский интерфейс существенно отличается на разных устройствах).

6. Отладка

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

// Показать все определенные в приложении подсказки
Tips.showAllTips()

// Показать указанные подсказки
Tips.showTips([searchTip, longPressGameTip])

// Скрыть указанные подсказки
Tips.hideTips([searchTip, longPressGameTip])

// Скрыть все подсказки, определенные в приложении
Tips.hideAllTips()

Если мы хотим очистить все данные, связанные с TipKit, нам необходимо использовать модификатор DatastoreLocation при инициализации фреймворка Tips при запуске приложения:

try? await Tips.configure(options: {
    DatastoreLocation(.applicationDefault, shouldReset: true)
})

Заключение

A tip displayed on tvOS
Подсказка, отображаемая в моем приложении "Chaise Longue to 5K" для tvOS

Подсказки помогают пользователям открыть для себя функции вашего приложения на iOS, iPadOS, macOS, watchOS или tvOS. Не забывайте, что подсказки должны быть лаконичными, обучающими и действенными, а также используйте систему правил, частоту показа и отмену, чтобы подсказки показывались только тогда, когда это необходимо.


В заключение приглашаем всех желающих на двухдневный мастер-класс «Пишем iOS приложение на KMP + Compose», который пройдет 20 и 21 ноября в рамках онлайн-курса "iOS Developer. Professional". Регистрируйтесь: День 1, День 2.

Комментарии (0)