Skip to content

Mastering App Intents: Querying Made Easy

🔍 Uncover the secrets of effective data retrieval in iOS development

by Ashli Rankin
The App Intents framework logo in front of stylized app interface wireframes.

App Intents are one of the most powerful features on iOS, but there is no clear pattern for sharing data and logic between your app and your App Intent. In this post, we will be building a simple to-do list to show how to share data between your app‘s existing data model and an App Intent. We will be focusing on a single item: marking a task in the list as complete. The GitHub repository of this project can be found here if you would like to code along!

Getting started

Let’s start by creating a new file in the Xcode project called Complete​Task​Intent. In this file, create a struct called Complete​Task​Intent, and conform it to the App​Intents protocol. For a more in-depth look into the components for setting up App Intents, you can visit my previous post.

Note: To conform to the App​Intents protocol, you need to add a title and the perform method.

Your file should look similar to this:

Swift
struct CompleteTaskIntent: AppIntent {
        
  static var title: LocalizedStringResource = "Complete Task"
        
  func perform() async throws -> some IntentResult {
      return .result()
  }
}

If you run the above logic in Xcode, open the Shortcuts app, and tap the + button to add a new shortcut, you should see this intent displayed under the “Apps” section of actions to add to your shortcut.

Adding parameters

Now, let’s add our first property: the date.

Swift
struct CompleteTaskIntent: AppIntent {
    
  @Parameter(title: "Date")
  private var date: Date
        
  static var title: LocalizedStringResource = "Complete Task"
        
  func perform() async throws -> some IntentResult {
      return .result()
  }
}

You may notice that date is a non-optional parameter. In this context, we are indicating to the system that this is a required parameter.

Now, lets add our second parameter: the task. Create a new parameter for your task like we did before — this time, the type of parameter is Todo​Task.

Swift
struct CompleteTaskIntent: AppIntent {
    
  @Parameter(title: "Date")
  private var date: Date
        
  @Parameter(title: "Task")
  private var task: TodoTask
    
  static var title: LocalizedStringResource = "Complete Task"
        
  func perform() async throws -> some IntentResult {
      return .result()
  }
}

This code doesn‘t compile!

The system is not aware of our app’s data model, so this code does not compile. To expose this “custom data type” to the system, we must put it in a form the system can recognize. In the App Intents framework, the way we do this is to create a new model conforming to the App​Entity.

An interface for exposing a custom type or app-specific concept to system services such as Siri and the Shortcuts app.

Apple

Once we’ve conformed our model to this protocol, the code will compile.

Apple recommends creating another structure representing the parts of your data model that your intent will supply the information to.

For this exercise, we will extend our data model to conform to the App​Entity protocol. You can add this code to a new file, or to the file containing the data model.

Swift
extension TodoTask: AppEntity {
        
  struct TaskQuery: EntityQuery {
      func entities(for: [Self.Entity.ID]) async throws -> [Self.Entity] {
          return []
      }
            
      func suggestedEntities() async throws -> Self.Result {
          return []
      }
  }
        
  var displayRepresentation: DisplayRepresentation {
      return .init(stringLiteral: "\(title)")
  }
        
  static var defaultQuery: TaskQuery = TaskQuery()
        
  static var typeDisplayRepresentation: TypeDisplayRepresentation = .init(name: "Task")
}

To conform to the App​Entity protocol, we need to implement three properties:

  1. display​Representation: Display​Representation: This property defines how the custom type will be displayed in the UI. You can specify the title, subtitle, synomyms and image.
  2. default​Query: Entity​Query: This provides the default query to be used when querying and retrieving entities with Shortcuts and Siri. When trying to resolve a parameter, the system will perform these queries to provide entities to be used by the UI.
  3. type​Display​Representation: Type​Display​Representation: A short, localized, human-readable name for the type.

Creating a query

Let’s take a closer look at query creation. According to Apple‘s documentation, when the system needs to retrieve one or more specific instances of an app entity, it asks you to provide a relevant query type — these queries are used at parameter resolution times. When we create our queries, they must conform to the Entity​Query protocol, which has two required methods illustrated below:

Swift
/// The required methods of EntityQuery
struct TaskQuery: EntityQuery {
    
  func entities(for identifiers: [Entity.ID]) async throws -> [TodoTask] {
        return []
  }
            
  func suggestedEntities() async throws -> [TodoTask] {
        return []
  }
}
  1. entities(for identifiers: [Entity.ID]) async throws -> [Todo​Task]: This method retrieves entities by their identifiers. Entities that do not match the identifiers will be skipped.
  2. func suggested​Entities() async throws -> [Todo​Task]: According to Apple, this method returns the initial results shown when a list of options backed by this query is presented.

Let’s continue — the aim of this intent is to provide a list of tasks for the user to choose from. Lets implement our query, starting with the suggested​Entities method.

Currently, our query object has no notion of the selected date. In order to bridge this gap, we have the handy property wrapper @Intent​Parameter​Dependency, which allows us to obtain the intent and its properties. It can be used on objects which conform to Dynamic​Options​Provider.

Add the code below to the TaskQuery section above, and build:

Swift
struct TaskQuery: EntityQuery {
        
  @IntentParameterDependency<CompleteTaskIntent>(
      \.$date
  )
  var completeTaskIntent
        
  private let taskManager: TaskManager = TaskManager.shared
  private let calendar = Calendar.autoupdatingCurrent
        
  func entities(for identifiers: [UUID]) async throws -> [TodoTask] {
      return try await suggestedEntities().filter { task in
          return identifiers.contains(task.id)
      }
    }
        
  func suggestedEntities() async throws -> [TodoTask] {
      guard let selectedDate = completeTaskIntent?.date else {
          return []
      }
            
      let startDate: Date = calendar.startOfDay(for: selectedDate)
      let endDate = calendar.date(byAdding: .day, value: 1, to: startDate)!
            
      let foundTasks: [TodoTask] = await taskManager.fetchTasks()
      let filteredTasks: [TodoTask] = foundTasks.filter { task in
          task.createDate >= startDate &&  task.createDate < endDate && !task.isComplete
      }
      return filteredTasks
  }
}

For our querying logic within the suggested​Entites() method above, we ensure the selected date is present. If it isn’t present, we return an empty array. We then get the start of the selected date, and create the end date by adding one day to that. With this logic, we are trying to set up the window that queried tasks with fall within. Then, we also make sure the tasks returned have not already been completed, ensuring we have the most accurate data presented to the user. We then update the entities(for identifiers: [UUID]) method. In this method, we use the suggested​Entites to return the tasks that match the identifiers provided by the system.

Adding a task

At this point in the process, we haven’t created any tasks, so when we run the app or try to execute the intent, we won’t see any options provided.

Let’s change that! Run the app and tap on the plus button on the main screen. A modal view will present itself — add a task there. You should see the task presented on the list once added.

Now that we’ve added a task, let’s execute our intent. Open the Shortcuts app and follow the video below to execute the intent:

You may have noticed that tapping the option in the presented menu does not mark the task as complete in the app. If you take a look in the Complete​Task​Intent.swift file within the perform method, you‘ll see we haven’t added any logic to be executed after the parameter resolution phase has taken place.

Swift
func perform() async throws -> some IntentResult {
    return .result()
}

In the Tasks folder, there is a file called Task​Manager that has the ability to add a task to the database. Add this as a property to this object. We will create a new instance of this object within our intent — in the context of Shortcuts and Siri, there is no way for us to inject it as a dependency. (In other contexts, such as within the widget or in the main application, we may be able to inject the Task​Manager as a dependency.) The intent file should now look like this.

Swift
struct CompleteTaskIntent: AppIntent {
    
  @Parameter(title: "Date")
  var date: Date
        
  @Parameter(title: "Task")
  private var task: TodoTask
    
  static var title: LocalizedStringResource = "Complete Task"
        
  private var taskManager = TaskManager.shared
        
  @MainActor
  func perform() async throws -> some IntentResult {
      taskManager.markTaskAsComplete(task: task)
      return .result()
  }
}

Now, when you execute the shortcut and select a task once you return to the app, that task will be completed. To make our user experience a little nicer, lets add a view to confirm that the task has been marked completed.

To achieve this, lets create our confirmation view by adding this code to the intent file:

Swift
private var successConfirmationView: some View {
  VStack {
      Image(systemName: "checkmark.circle")
          .foregroundStyle(.green)
          .imageScale(.large)
          .font(.largeTitle)
      Text("Task Completed")
  }
}

To ensure our intent doesn’t crash, we will update our perform method return type to include Shows​Snippet​View:

Swift
@MainActor
  func perform() async throws -> some ShowsSnippetView {
    taskManager.markTaskAsComplete(task: task)
    return .result(view: successConfirmationView)
}

Now we can call the result method and pass the success​Confirmation​View as a parameter.

The app can now open once the task is executed. By default, the value of open​App​When​Run is false — if you’d like to open the app after the task has been executed, update this value to true.

Wrapping up

In this blog post, we’ve explored the intricacies of creating an App Intent for our simple to-do list application. Starting with the basics, we delved into how to define and handle parameters such as the date and task selection, and then integrated them into our app‘s data model. Our journey included conforming to the App​Intents protocol, dealing with the nuances of custom data types, and understanding the importance of entity queries in the App​Intents framework.

As we have seen, integrating App Intents requires a thoughtful approach to app architecture and a deep understanding of both the app‘s data model and the App​Intents framework. However, the payoff is a more streamlined and efficient user experience.

For those who are looking to further explore this topic or apply these concepts in their own projects, I encourage you to experiment with different types of intents and explore how they can be tailored to fit the unique requirements of your app. The possibilities are endless!