In 2018, Apple announced Siri Shortcuts, which allow users to interact with apps through Siri and the Shortcuts app without opening the app directly.
The Intents framework also has support for adding and recording shortcuts inside apps and allows apps to get a list of shortcuts the user has added. However, by default, users can only add and manage their shortcuts in the Shortcuts app.
In this tutorial, we'll explore the best way to let users add and manage their shortcuts in your app, including making a custom screen listing the different shortcuts available.
If you'd prefer to jump straight to a demo app, feel free to click here to go to the Conclusion. There, you'll find an Xcode Project containing a sample app that uses this code to provide an example of the shortcuts view controller.
This blog post and code is based on this great example Github Gist by Simon Ljungberg, but introduces major changes such as the way shortcuts are loaded and the delegate system. Any code from the Gist is available under the MIT License from the Gist.
Getting started
Note: Some sections of this tutorial require Xcode 11 and iOS 13, which are currently in beta, as it relies on new features or frameworks that are not available on previous versions.
No installation or configuration is required to go through this tutorial, as SiriKit and the Intents framework is built into iOS. However, this blog post assumes you have already integrated and set up Intents in your app, as we'll be focusing on more advanced features.
If you haven't added Intents to your app yet, I strongly recommend you go read Apple's official documentation on it, then come back to this.
Creating a Shortcuts Manager
Before adding a view controller that lets users add, edit or delete your app's shortcuts, we'll create a manager object that abstracts some of the more specific APIs to allow the view controller to easily perform actions. I find that avoiding storing the added voice shortcuts to disk is best, as it avoids inconsistency when shortcuts are added or removed in the Shortcuts app.
First, create a class called ShortcutsManager, and use a singleton to avoid having multiple instances.
class ShortcutsManager {
private init() { }
static let shared = ShortcutsManager()
}
Then, create a nested enum that will hold all of the different intent types your app supports. For example, a soup ordering app might have an order soup intent. We'll also use computed variables to specify each intent type's intent class, which is the class automatically generated by the Intents framework, and a suggested invocation phrase, which will be shown to users when they are adding the shortcut.
enum Kind: String, Hashable, CaseIterable {
case orderSoup
var intentType: INIntent.Type {
switch self {
case .orderSoup: return OrderSoupIntent.self
}
}
var suggestedInvocationPhrase: String? {
switch self {
case .orderSoup: return "Order Soup"
}
}
var intent: INIntent {
let intent = intentType.init()
intent.suggestedInvocationPhrase = suggestedInvocationPhrase
return intent
}
}
Next, add a nested struct that we'll use to abstract the actual INVoiceShortcut
type, so our view controller can display shortcuts with or without them being added by the user.
struct Shortcut: Hashable {
var kind: Kind
var intent: INIntent
var voiceShortcut: INVoiceShortcut?
var invocationPhrase: String? {
voiceShortcut?.invocationPhrase
}
}
Thanks to Swift, the struct will automatically get a custom initializer with each of the properties, so we don't need to create it ourselves.
Loading shortcuts
Before adding a function to load shortcuts, we need to create a small private helper that we'll use to find intents of the right kind:
private func isVoiceShortcut<IntentType>(_ voiceShortcut: INVoiceShortcut, intentOfType type: IntentType.Type) -> Bool where IntentType: INIntent {
voiceShortcut.shortcut.intent?.isKind(of: type) ?? false
}
Now, we need to make a function that loads all of the available shortcuts and shortcuts the user has added. We'll create the function in stages, but I'll add the complete function at the bottom of this section so you can check your work.
First, create the function declaration. Our view controller will provide a list of intent kinds it wants shortcuts for, and we'll call the completion handler with the shortcuts.
func loadShortcuts(kinds: [Kind], completion: @escaping ([Shortcut]) -> Void) {
}
In the function, we'll call the Intents framework's getAllVoiceShortcuts
function, which will give us all of the shortcuts the user has added to Siri. If it fails or doesn't return any voice shortcuts, we'll just call the completion handler with Shortcut
objects without the voiceShortcut
variable. This way, the view controller can display a list of available shortcuts that haven't been added.
func loadShortcuts(kinds: [Kind], completion: @escaping ([Shortcut]) -> Void) {
INVoiceShortcutCenter.shared.getAllVoiceShortcuts { [weak self] voiceShortcuts, error in
guard let self = self, let voiceShortcuts = voiceShortcuts, error == nil else {
completion(kinds.map { Shortcut(kind: $0, intent: $0.intent) })
return
}
}
}
Then, we'll go through each of the intent kinds that were passed in and try to find a corresponding voice shortcut. If we can't find a corresponding voice shortcut, we'll just return a Shortcut
without the voiceShortcut
variable, which means the user hasn't added it to Siri.
func loadShortcuts(kinds: [Kind], completion: @escaping ([Shortcut]) -> Void) {
INVoiceShortcutCenter.shared.getAllVoiceShortcuts { [weak self] voiceShortcuts, error in
guard let self = self, let voiceShortcuts = voiceShortcuts, error == nil else {
completion(kinds.map { Shortcut(kind: $0, intent: $0.intent) })
return
}
var shortcuts = [Shortcut]()
for kind in kinds {
let filteredVoiceShortcuts = voiceShortcuts.filter({ self.isVoiceShortcut($0, intentOfType: kind.intentType) })
guard !filteredVoiceShortcuts.isEmpty else {
let shortcut = Shortcut(kind: kind, intent: kind.intent)
shortcuts.append(shortcut)
continue
}
for voiceShortcut in filteredVoiceShortcuts {
let shortcut = Shortcut(kind: kind, intent: kind.intent, voiceShortcut: voiceShortcut)
shortcuts.append(shortcut)
}
}
completion(shortcuts)
}
}
As you can see in the complete function, if a shortcut hasn't been added, we return a Shortcut
object without the voiceShortcut
variable, and if a shortcut was added, we'll set the voiceShortcut
to the object returned by the system. This way, our view controller can show both added and available shortcuts.
Creating shortcuts
The Intents framework has two built in view controllers that we can present to let the user manage a shortcut: INUIAddVoiceShortcutViewController
, to add a new shortcut, and INUIEditVoiceShortcutViewController
, to change the invocation phrase for an existing shortcut or delete it.
To abstract their delegates, we'll create a ShortcutsManagerDelegate
and then make a DelegateProxy
that calls our delegate when one of the framework's delegates are called.
First, we'll make a ShortcutsManagerDelegate
, which will have similar methods to the INUIAddVoiceShortcutViewControllerDelegate
and INUIEditVoiceShortcutViewControllerDelegate
.
protocol ShortcutsManagerDelegate: AnyObject {
func shortcutViewControllerDidCancel()
func shortcutViewControllerDidFinish(with shortcut: ShortcutsManager.Shortcut)
func shortcutViewControllerDidDeleteShortcut(_ shortcut: ShortcutsManager.Shortcut, identifier: UUID)
func shortcutViewControllerFailed(with error: Error?)
}
Next, we'll create a private DelegateProxy
class, which we'll use internally in the ShortcutsManager
. This class won't be exposed to the view controller, and is a little lengthy as it conforms to both INUIAddVoiceShortcutViewControllerDelegate
and INUIEditVoiceShortcutViewControllerDelegate
.
private class DelegateProxy: NSObject, INUIAddVoiceShortcutViewControllerDelegate, INUIEditVoiceShortcutViewControllerDelegate {
var shortcut: Shortcut
weak var delegate: ShortcutsManagerDelegate?
var completion: () -> Void
init(shortcut: Shortcut, delegate: ShortcutsManagerDelegate, completion: @escaping () -> Void) {
self.shortcut = shortcut
self.delegate = delegate
self.completion = completion
}
// MARK: - INUIAddVoiceShortcutViewControllerDelegate
func addVoiceShortcutViewControllerDidCancel(_ controller: INUIAddVoiceShortcutViewController) {
controller.dismiss(animated: true)
delegate?.shortcutViewControllerDidCancel()
completion()
}
func addVoiceShortcutViewController(_ controller: INUIAddVoiceShortcutViewController, didFinishWith voiceShortcut: INVoiceShortcut?, error: Error?) {
defer { completion() }
controller.dismiss(animated: true)
guard let voiceShortcut = voiceShortcut else {
delegate?.shortcutViewControllerFailed(with: error)
return
}
shortcut.voiceShortcut = voiceShortcut
delegate?.shortcutViewControllerDidFinish(with: shortcut)
}
// MARK: - INUIEditVoiceShortcutViewControllerDelegate
func editVoiceShortcutViewControllerDidCancel(_ controller: INUIEditVoiceShortcutViewController) {
controller.dismiss(animated: true)
delegate?.shortcutViewControllerDidCancel()
completion()
}
func editVoiceShortcutViewController(_ controller: INUIEditVoiceShortcutViewController, didUpdate voiceShortcut: INVoiceShortcut?, error: Error?) {
defer { completion() }
controller.dismiss(animated: true)
guard let voiceShortcut = voiceShortcut else {
delegate?.shortcutViewControllerFailed(with: error)
return
}
shortcut.voiceShortcut = voiceShortcut
delegate?.shortcutViewControllerDidFinish(with: shortcut)
}
func editVoiceShortcutViewController( _ controller: INUIEditVoiceShortcutViewController, didDeleteVoiceShortcutWithIdentifier deletedVoiceShortcutIdentifier: UUID) {
controller.dismiss(animated: true)
delegate?.shortcutViewControllerDidDeleteShortcut(shortcut, identifier: deletedVoiceShortcutIdentifier)
completion()
}
}
I won't go over the entire class, but as you can see, it just dismisses each controller and forwards delegate calls to our custom delegate.
Finally, we'll add an array of delegate proxies and create the function which will be called by our view controller to present the appropriate intents view controller.
private var delegates = [String: DelegateProxy]()
public func showShortcutsPhraseViewController(for shortcut: Shortcut, on viewController: UIViewController, delegate: ShortcutsManagerDelegate) {
let delegateProxy = DelegateProxy(shortcut: shortcut, delegate: delegate) { [weak self] in
self?.delegates[shortcut.kind.rawValue] = nil
}
delegates[shortcut.kind.rawValue] = delegateProxy
if let voiceShortcut = shortcut.voiceShortcut {
let editController = INUIEditVoiceShortcutViewController(voiceShortcut: voiceShortcut)
editController.delegate = delegateProxy
viewController.present(editController, animated: true)
} else {
guard let shortcut = INShortcut(intent: shortcut.kind.intent) else { return }
let addController = INUIAddVoiceShortcutViewController(shortcut: shortcut)
addController.delegate = delegateProxy
viewController.present(addController, animated: true)
}
}
In the function, we create a DelegateProxy
to abstract the delegates and present the add or edit view controller based on whether the Shortcut
has an existing INVoiceShortcut
.
Creating a shortcuts view controller
After creating our ShortcutsManager
with support for loading and editing shortcuts, we'll create a simple UITableViewController
to list both available and added shortcuts, and allow the user to edit them.
Note: In this section, I'm using
UITableViewDiffableDataSource
and related APIs, which require Xcode 11 and iOS 13 (currently in beta) to simplify it. If you need to support iOS 12, you can use the older data source APIs instead.
First, create a new view controller that conforms to UITableViewController
. We'll also create a delegate that the view controller conforms to, so the custom data source class we'll create later can access the shortcuts stored on the view controller.
protocol AllShortcutsViewControllerDataSourceDelegate: AnyObject {
var addedShortcuts: [ShortcutsManager.Shortcut] { get }
var allShortcuts: [ShortcutsManager.Shortcut] { get }
}
class AllShortcutsViewController: UITableViewController, AllShortcutsViewControllerDataSourceDelegate {
}
In the view controller, we'll add variables for the shortcuts and for our diffable data source:
class AllShortcutsViewController: UITableViewController, AllShortcutsViewControllerDataSourceDelegate {
var addedShortcuts = [ShortcutsManager.Shortcut]()
var allShortcuts = [ShortcutsManager.Shortcut]()
var dataSource: DataSource!
}
Then, we'll create a custom nested Section
enum, which will act as the custom type for our table view sections.
class AllShortcutsViewController: UITableViewController, AllShortcutsViewControllerDataSourceDelegate {
enum Section: Hashable {
case addedShortcuts
case allShortcuts
var title: String? {
switch self {
case .addedShortcuts: return "Your Shortcuts"
case .allShortcuts: return "All Shortcuts"
}
}
}
}
Next, we'll add a custom initializer to setup our data source and load our shortcuts. To satisfy the compiler, we have to add the required init(coder:)
initializer, but it won't be called.
class AllShortcutsViewController: UITableViewController, AllShortcutsViewControllerDataSourceDelegate {
init() {
super.init(style: .insetGrouped)
title = "Siri Shortcuts"
navigationItem.largeTitleDisplayMode = .never
tableView.register(UITableViewCell.self, forCellReuseIdentifier: DataSource.cellReuseIdentifier)
setupDataSource()
reloadShortcuts()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setupDataSource() {
dataSource = DataSource(delegate: self, tableView: tableView)
dataSource.reload()
}
func reloadShortcuts() {
ShortcutsManager.shared.loadShortcuts(kinds: ShortcutsManager.Kind.allCases) { [weak self] shortcuts in
self?.allShortcuts = shortcuts.filter { $0.voiceShortcut == nil }
self?.addedShortcuts = shortcuts.filter { $0.voiceShortcut != nil }
DispatchQueue.main.async {
self?.dataSource.reload()
}
}
}
}
When we're loading our shortcuts from the ShortcutsManager
, we'll separate them into all shortcuts and shortcuts the user has already added.
Now, we can create a nested custom DataSource
class, which will be a subclass of UITableViewDiffableDataSource
. We'll setup each cell with a check or plus SF Symbol, the shortcut's suggested invocation phrase, and if it's been added, the custom invocation phrase the user chose.
class AllShortcutsViewController: UITableViewController, AllShortcutsViewControllerDataSourceDelegate {
class DataSource: UITableViewDiffableDataSource<Section, ShortcutsManager.Shortcut> {
static let cellReuseIdentifier = "SettingsCell"
weak var delegate: AllShortcutsViewControllerDataSourceDelegate?
var snapshot: NSDiffableDataSourceSnapshot<Section, ShortcutsManager.Shortcut>!
func getSection(for section: Int) -> Section? {
snapshot?.sectionIdentifiers[section]
}
func getItem(at indexPath: IndexPath) -> ShortcutsManager.Shortcut? {
itemIdentifier(for: indexPath)
}
init(delegate: AllShortcutsViewControllerDataSourceDelegate, tableView: UITableView) {
self.delegate = delegate
super.init(tableView: tableView) { tableView, indexPath, shortcut -> UITableViewCell? in
guard let cell = tableView.dequeueReusableCell(withIdentifier: DataSource.cellReuseIdentifier, for: indexPath) else { return nil }
cell.textLabel?.text = shortcut.kind.suggestedInvocationPhrase
let phrase = shortcut.voiceShortcut?.invocationPhrase ?? ""
cell.detailTextLabel?.text = phrase.isEmpty ? nil : "Say \"" + phrase + "\""
if shortcut.voiceShortcut == nil {
cell.accessoryView = UIImageView(image: UIImage(systemName: "plus"))
} else {
cell.accessoryView = UIImageView(image: UIImage(systemName: "checkmark"))
}
cell.accessoryView?.tintColor = .systemOrange
return cell
}
}
func reload() {
guard let addedShortcuts = delegate?.addedShortcuts, let allShortcuts = delegate?.allShortcuts else { return }
snapshot = NSDiffableDataSourceSnapshot<Section, ShortcutsManager.Shortcut>()
if !addedShortcuts.isEmpty {
snapshot.appendSections([.addedShortcuts])
snapshot.appendItems(addedShortcuts, toSection: .addedShortcuts)
}
if !allShortcuts.isEmpty {
snapshot.appendSections([.allShortcuts])
snapshot.appendItems(allShortcuts, toSection: .allShortcuts)
}
apply(snapshot, animatingDifferences: true)
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
getSection(for: section)?.title
}
}
}
Finally, we'll show the add or edit view controller when a row is tapped and make the view controller conform to ShortcutsManagerDelegate
.
class AllShortcutsViewController: UITableViewController, ShortcutsManagerDelegate, AllShortcutsViewControllerDataSourceDelegate {
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let shortcut = dataSource.getItem(at: indexPath) else { return }
ShortcutsManager.shared.showShortcutsPhraseViewController(for: shortcut, on: self, delegate: self)
}
// MARK: - ShortcutsManagerDelegate
func shortcutViewControllerDidCancel() {
return
}
func shortcutViewControllerDidFinish(with shortcut: ShortcutsManager.Shortcut) {
reloadShortcuts()
}
func shortcutViewControllerDidDeleteShortcut(_ shortcut: ShortcutsManager.Shortcut, identifier: UUID) {
reloadShortcuts()
}
func shortcutViewControllerFailed(with error: Error?) {
reloadShortcuts()
}
}
When conforming to the ShortcutsManagerDelegate
, we're just reloading our shortcuts (which also reloads the data source), and thanks to diffable data sources, the table view will animate automatically.
Conclusion
In this tutorial, we went in depth into creating a custom ShortcutsManager
and AllShortcutsViewController
to allow users to manage their shortcuts without leaving your app.
For your reference, I've created a simple example Xcode Project with a demo app which contains the ShortcutsManager
and AllShortcutsViewController
from this tutorial.
I hope this tutorial was useful, whether you just wanted to learn more about Siri Shortcuts or are trying to implement your own shortcuts management screen. If you have any questions or feedback, feel free to reach out on Twitter or email me: [email protected]. Thanks for reading 🎤