Шорткаты в Сири

Это новая эппловская фича для iOS 12. Ее анонсировали во время приветственной речи на WWDC 2018, и вот уже этой осенью ею можно будет пользоваться… Знаете, стоит обновить свои приложения так, чтобы ваши будущие клиенты-пользователи могли работать с новой системной функцией. Чем быстрее, тем лучше.

Я уверен, что они оценят это, ведь управление голосом на самом деле штука удобная и делает жизнь еще проще, особенно если у вас есть шорткаты, которые будут на виду. Их можно будет вызвать прямо с экрана блокировки, через Поиск или через Siri Watch Face. 

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

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

Еще можно добавлять шорткаты из галереи шаблонов. Они будут доступны в новом приложении Shortcuts, когда пройдёт официальный релиз, или же разработчики сами смогут создавать кастомизированные шорткаты и добавлять их в свои приложения. 

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

Пример проекта

Я подготовил простой проект, его исходник можете взять с GitHub. Не стесняйтесь использовать его вместо того, чтобы писать код с нуля. Подходит для быстрого тестирования. 

Поехали

Первое, что нужно сделать (допустим, у вас уже есть пустой проект для начала), — добавить новый таргет и в качестве шаблона выбрать Cocoa Touch Framework.

Возьмём этот таргет как контейнер для фрагментов нашей информации API to fetch с GitHub, и фреймворк поможет нам распределить код между приложением и нашим целевым шорткатом. Начнём с создания нового класса. Он понадобится как модель для полученных данных (пользователя и его фолловеров).

public struct GitHubUser: Codable {
    public let name: String
    public let location: String
    public let repos: Int

    private enum CodingKeys: String, CodingKey {
        case name
        case location
        case repos = "public_repos"
    }
}

public struct GitHubFollower: Codable {
    public let login: String

    private enum CodingKeys: String, CodingKey {
        case login
    }
}

Я разделил его на две структуры, потому что нам нужно получить данные в двух разных конечных точках и такой подход явно ускорит путь к результату. Для эффективности кода нужна структура получше, а я фокусировался на создании шортката и написании чего-то “на скорую руку”. Просто помните об этом. 

А теперь давайте создадим API для получения всех данных, которые нам нужны:

public final class Fetcher: NSObject {
    public static func fetch(name: String, completion: @escaping ((user: GitHubUser?, followers: [GitHubFollower])) -> Void) {
        guard let url = URL(string: "https://api.github.com/users/\(name)") else {
            return
        }

        let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, timeoutInterval: 10.0)

        let task = URLSession.shared.dataTask(with: request) { data, response, error in
            guard
                let data = data,
                let user = try? JSONDecoder().decode(GitHubUser.self, from: data)
            else {
                completion((nil, []))
                return
            }

            self.fetchFollowers(for: name, completion: { followers in
                completion((user, followers))
            })
        }

        task.resume()
    }

    private static func fetchFollowers(for name: String, completion: @escaping (_ followers: [GitHubFollower]) -> Void) {
        guard let url = URL(string: "https://api.github.com/users/\(name)/followers") else {
            return
        }

        let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, timeoutInterval: 10.0)

        let task = URLSession.shared.dataTask(with: request) { data, response, error in
            guard
                let data = data,
                let followers = try? JSONDecoder().decode([GitHubFollower].self, from: data)
            else {
                completion([])
                return
            }

            completion(followers)
        }

        task.resume()
    }
}

Я не буду подробно объяснять код. Думаю, что вы все же знакомы с URLSession или можете найти себе данные где-то еще. Это может быть даже статический метод.

А теперь, создадим новый файл — SiriKit Intent Definition.

Открыв его, вы увидите простой редактор. В нём можно определить кастомные (или модифицировать системные) интенты и их ответы. Нажмите на значок ➕ , чтобы добавить новый интент. 

Что здесь происходит? Сейчас я всё объясню. В качестве категории выберите что угодно, что подходит под ваш интент больше всего. Я выбрал Do. Заголовок и описание говорят сами за себя. Также можете выбрать дефолтное изображение, если вам так хочется или нужно, чтобы ваш интент был подтвержден до выполнения. Проверьте отметку. 

В секции Parameters определите все параметры, которые могут передаваться в ваш интент. У меня он всего один — имя пользователя на GitHub. Если хотите, можете не передавать никакие параметры. 

А теперь давайте определим тип шортката (Shortcut Type). Снова нажмите значок ➕ под третьей секцией. Проверьте ваш параметр, если вы его создавали, и нажмите Add Shortcut Type.

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

В моём примере я также проверил фоновое исполнение, т.к. хотел, чтобы оно сработало без обращения к текущему приложению.

И вот пришло время определиться с ответом.

Достаточно просто. Список свойств, которые можно передавать в ответ и шаблоны ответов (response templates). Может выглядеть сложно на первый взгляд, но вы всё поймёте, когда начнёте писать код. Теперь вопрос: “Как же нам получить доступ к этим интентам?”

Не стоит беспокоиться, все необходимые классы, схожие с CoreData, будут автоматически сгенерированы для нас. Осталась одна вещь, которую нам нужно сделать на этом этапе, чтобы всё точно работало после создания фреймворка для распределения между таргетами. На картинке ниже видим target membership для файла intentdefinition, который мы только что создали. Нужно сгенерировать классы только для нашего фреймворка и ничего не генерировать для остальных таргетов.

Создаём интент

Итак, мы проделали такую большую работу к этому моменту и у нас всё еще нет результатов… Еще несколько шагов и мы закончим с базовым шорткатом. 

Добавляйте новый таргет — Intent Extension —вы можете так же включить UI Extension, если хотите. Это необязательно, хотя, возможно, пригодится нам попозже.

Помните, что надо слинковать созданный фреймворк с нашими новыми таргетами.

Как только мы создадим таргет интента, появится сгенерированный файл IntentHandler.swift. Давайте откроем его и заменим его реализацию, чтобы вернуть обработчик для кастомного интента. 

class IntentHandler: INExtension {
    override func handler(for intent: INIntent) -> Any {
        guard intent is CheckMyGitHubIntent else {
            fatalError("Unhandled intent type: \(intent)")
        }

        return CheckMyGitHubIntentHandler()
    }
}

Хм, естественно, он не скомпилируется, пока мы не создадим обработчик. Сделаем это:

final class CheckMyGitHubIntentHandler: NSObject, CheckMyGitHubIntentHandling {
    func handle(intent: CheckMyGitHubIntent, completion: @escaping (CheckMyGitHubIntentResponse) -> Void) {
        guard let name = intent.name else {
            completion(CheckMyGitHubIntentResponse(code: .failure, userActivity: nil))
            return
        }

        Fetcher.fetch(name: name) { (user, followers) in
            guard let user = user else {
                completion(CheckMyGitHubIntentResponse(code: .failure, userActivity: nil))
                return
            }

            completion(CheckMyGitHubIntentResponse.success(repos: user.repos as NSNumber, followers: followers.count as NSNumber))
        }
    }
    
}

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

Интент UI

Самый простой шаг. Создайте новый таргет для Intent UI , если вы не сделали этого раньше. Мы можем создать кастомный View для ответа нашему шорткату и сконфигурировать его здесь. Он будет отображаться на экране Siri.

class IntentViewController: UIViewController, INUIHostedViewControlling {
    
    @IBOutlet weak var reposLabel: UILabel!
    @IBOutlet weak var followersLabel: UILabel!
    @IBOutlet weak var activityIndicator: UIActivityIndicatorView!

    // ОТМЕТКА: - INUIHostedViewControlling
    
    // Чтобы всё сработало, подготовьте свой контроллер view.
    func configureView(for parameters: Set<INParameter>, of interaction: INInteraction, interactiveBehavior: INUIInteractiveBehavior, context: INUIHostedViewContext, completion: @escaping (Bool, Set<INParameter>, CGSize) -> Void) {

        guard
            let intent = interaction.intent as? CheckMyGitHubIntent,
            let name = intent.name
        else {
            return
        }

        activityIndicator.isHidden = false
        activityIndicator.startAnimating()

        Fetcher.fetch(name: name) { [weak self] user, followers in
            guard let user = user else {
                self?.hideActivityIndicator()
                return
            }

            DispatchQueue.main.async {
                self?.reposLabel.text = "Repos: \(user.repos)"
                self?.followersLabel.text = "Followers: \(followers.count)"

                self?.hideActivityIndicator()
            }
        }

        completion(true, parameters, self.desiredSize)
    }
    
    var desiredSize: CGSize {
        var size = self.extensionContext!.hostedViewMaximumAllowedSize
        size.height = UIFont.systemFont(ofSize: 15).lineHeight * 3

        return size
    }

    private func hideActivityIndicator() {
        DispatchQueue.main.async {
            self.activityIndicator.isHidden = true
            self.activityIndicator.stopAnimating()
        }
    }
    
}

Передача интента

Я сделал простой View с текстовым полем, где могу ввести имя пользователя и кнопку, которая будет передавать интент:

private func donate(name: String) {
        // 1
        let intent = CheckMyGitHubIntent()

        // 2
        intent.suggestedInvocationPhrase = "Check my GitHub"
        intent.name = name

        // 3
        let interaction = INInteraction(intent: intent, response: nil)

        // 4
        interaction.donate { (error) in
            if error != nil {
                if let error = error as NSError? {
                    print("Interaction donation failed: \(error.description)")
                } else {
                    print("Successfully donated interaction")
                }
            }
        }
    }
  1. Создаёт объект для нашего интента.
  2. Это необязательно, но мы можем установить фразу вызова для нашего интента и, если мы определили какие-либо параметры, то нужно обязательно их здесь заполнить.
  3. Начинает взаимодействие с нашим интентом.
  4. Передает интент. Первоочередный вызов INPreferences.requestSiriAuthorization для передачи может потребоваться при выполнении на текущем устройстве.

Это всё?

Да. Запустите приложение и поставьте триггер на передачу интента. А теперь откройте системные Настройки->Сири и добавьте шорткат к только что переданному интенту. Если всё прошло успешно, то он будет доступен здесь.

И вот мы готовы проверить всё, что мы сделали, в действии.

Заключение

Существует гораздо больше вещей, которые можно сделать с новой фичей. Например, собрать пользовательскую активность, что также может вполне стать шорткатом.

Спасибо за внимание.

Читайте также:


Перевод статьи Artur Rymarz: How to Create Custom Siri Shortcuts

Предыдущая статья10 Расширений VS Code Insider для веб разработки 2020
Следующая статьяПонятие о миграциях в TypeORM