iPhone showing an example app with a list of Siri Shortcuts

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.

Download Materials

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 🎤