7 Aug 20189 min read

Creating custom Siri Shortcuts

It’s new Apple’s feature for iOS 12, announced during the WWDC 2018 keynote and it is coming this fall… so better get your applications ready so your users can take advantage of them as soon as possible. I am quite sure they will appreciate that since it is indeed really a handful and can make life easier — especially if you add shortcuts that will be used often and it all can be done directly from the lock screen, in Search, or from the Siri watch face.

Shortcuts can be suggested by Siri using a signal like location, time of day and type of motion (such as walking, running or driving) and appear on the lock screen as notifications which user can tap to run the task. Do not worry about your privacy — all collected data is stored on your device so nothing will be compromised.

It’ll be possible to add shortcuts from the gallery of samples which will be available in the new Shortcuts app once it’s officially released or Developers can create their custom shortcuts and provide them together with their applications. In this story I’ll show you how to create simple custom shortcut — just remember it’s still in beta so implementation might change a little bit once it’s officially released — if anything changes I’ll try to keep it up to date.

Sample project

I have prepared a simple project which you can find on GitHub. Feel free to give it a try if you want to skip all the coding and just try it out.

Final result

Shall we start?

The first thing you have to do (assuming you already have an [empty] project) — add a new target and as template choose Cocoa Touch Framework.

Cocoa Touch Framework

We will use this target as a container for our API to fetch pieces of information from GitHub and framework will help us to share the code between application and and our shortcut (intent).

Start with creating a new class which will be used as a model for fetched data (user and its followers)

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
    }
}

I have split it into two structs because we have to fetch them from two different endpoints and such an approach just makes it faster. Should use a better structure for a production code — I try to focus on creating a shortcut and writing just something “quick” for anything else — keep it in mind ☺️

Now let’s create an API to fetch all data we need

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()
    }
}

I am not explaining above code since I assume you are either familiar with URLSession or you will just get your data from somewhere else, it can even be a static method.

Now create a New File — SiriKit Intent Definition

SiriKit Intent Definition

Opening it will show a simple editor where we can define custom (or modify system’s) intents and it’s response. Press a ➕ symbol to add new intent.

Intent Setup

Let me explain what happened here 🔥

As a category choose whatever fits your intent the best — I have chosen Do. Title and Description are self-explanatory. You can also choose a default image if you feel like so and if you want your intent to be confirmed before executed you can check the mark.

In Parameters section define all parameters that can be passed to your intent — for me it’s just a GitHub user’s name. You can have no parameters if you want to.

Now let’s define a Shortcut Type. Again press a ➕ symbol under the 3rd section, check your parameter if you created one and click Add Shortcut Type — give it a title and here you can use your parameter which will be replaced with the given string while donating intent (will explain intent donation process later). In my example, I also checked background execution since I want it to be executed without having to run the actual application.

It’s time to define an answer

Intent properties

It is quite simple as well — list of properties that can be passed to a response and response templates. Might look complicated for now but once we start writing code it will be explained.

Now you might wonder — how can we access those intents? — similar to CoreData all needed classes will be automatically generated for us, so no worry about that. Just one thing we have to do here to make sure all works properly since we created a framework to share between targets — look at the image below — it’s Target Membership for a intentdefinition file we just created. We have to generate classes only for our framework and skip generating for any other target.

Target membership

Creating Intent

So… we have done so much work for now and there is still no results? Just a few more steps and we’ll be done with a basic shortcut.

Add new target — Intent Extension — you can include UI Extension as well if you want to, it’s optional but will optionally need it later on.

Intents Extension

Remember to link the framework we created earlier with our new target(s).

Linked Frameworks

As soon as we create intent target there will be a generated file IntentHandler.swift, let’s open it and replace its implementation to return handler for a custom intent we defined.

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

        return CheckMyGitHubIntentHandler()
    }
}

Uhm, of course, it won’t compile now since we didn’t create that Handler yet, let’s do it now then 😎

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))
        }
    }
}

What does it do? It takes a name from the intent and fetches data for that name and calls a completion block with the answer with defined earlier. If you do not want to fetch any data from GitHub you can just pass some static parameters here in the completion blocks.

Intent UI

This one is the easiest. Create a new target for Intent UI if you have not included it earlier. We can create a custom view for a response to our shortcut and configure it here. It will be shown on the Siri screen.

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

    // MARK: - INUIHostedViewControlling
    
    // Prepare your view controller for the interaction to handle.
    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()
        }
    }
    
}

Donating Intent

I have created a simple view with a text field where I can type in a GitHub user’s name and a button that will donate intent

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. Creates an object of our intent.
  2. It’s optional but can set a suggested invocation phrase for our intent and if we defined any parameters (still remember the first part?) we have to fill them here.
  3. Creates interaction with our intent
  4. Donates the intent. Calling INPreferences.requestSiriAuthorization prior to donating might be required when running on an actual device.

Are we done?

Pretty much… start our app and trigger the intent donation. Now go to system’s Settings -> Siri and add the shortcut for an intent we just donated (if it was donated successfully we should have it available here).

Phone shortcuts settings

And… we are ready to check everything we have done in action.

Full final result

Final words

There is way more stuff we can do with the new feature — like attaching a user activity that can be used as a shortcut as well. Might cover it in another story or update this one — depending on what readers would rather prefer to see.