Imagine: you’re sitting at your desk and suddenly have an idea for a new color theme. Instead of opening your app, tapping through menus, and adjusting settings, you just say:
“Hey Siri, generate a 4 by 6 mesh with sunset colors.”
And there it is – your app launches and presents the gradient exactly as you asked for. That’s the power of App Intents.
App Intents are Apple’s way of making your app’s features available anywhere in the system – not just inside your app’s UI. With App Intents you can:
- Ask Siri to trigger your app’s actions by voice.
- Create Shortcuts that combine your app with other apps for automation.
- Expose your app’s content to Spotlight, so users can search for it.
- Provide system intelligence with structured actions and data.
Apple calls App Intents “self-contained types that act as a bridge between your code and system experiences and services.” Each intent represents one action your app can do — like showing a hiking trail, converting units, or, in our case, generating a colorful mesh gradient.
Why This Matters for Us
In this code-along, we’ll take our existing MeshArt project we created in a previous code-along and extend it with an App Intent. By the end, you’ll be able to generate a gradient not only from inside the app, but also by asking Siri or running a Shortcut.
We will extend our MeshArt app we created in our mesh art code-along https://swiftandcurious.com/2025/04/16/mesh-art/
Our extended app will look the following:

Let’s get started!
Step 1: Open your existing MeshArt Project or download from GitHub
To follow along, you have two options:
- Continue with your own project
If you already completed the earlier MeshArt code-along, simply open your existing project in Xcode — we’ll build directly on top of it.
Start fresh with the MeshArt project from GitHub
If you’re joining now, no worries! You can clone the starter project from GitHub:- Open Xcode and select Clone Git Repository from the welcome window (or from the File menu).
- In the search bar, paste:
https://github.com/swiftandcurious/MeshArt.git
- Click Clone.
Once you have the project open, build and run it once on the simulator or your device.
You should see the MeshArt app with:
- A static mesh gradient
- The Pride stripes view
- The draggable mesh gradient
This confirms you’re starting from the same baseline as the earlier tutorial.
Requirements: You’ll need Xcode 15 or newer and a device/simulator running iOS 17+.
From here, we’ll extend MeshArt with App Intents, so you can generate gradients directly from Siri, Shortcuts, or Spotlight.
Step 2: Create Your First App Intent
With the project running, let’s give MeshArt its first App Intent. An App Intent is a small Swift type that tells the system: “Here’s an action my app can perform, and here are the parameters it needs.”
We’ll start by creating an intent called GenerateMeshIntent. This intent will:
- Have parameters for rows, columns, and palette
- Be discoverable in Siri and Shortcuts
- Open our app and immediately show the generated gradient
Let’s get started:
First, we need to define our color palette:
- In Xcode, create a new Swift file called
Palette.swift
. - Add the following
import AppIntents
enum Palette: String, AppEnum, Sendable {
case sunset, ocean, forest, desert, aurora, lava, galaxy, pastel, neon
nonisolated static var typeDisplayRepresentation: TypeDisplayRepresentation {
TypeDisplayRepresentation(name: "Palette")
}
nonisolated static var caseDisplayRepresentations: [Palette: DisplayRepresentation] {
[
.sunset: .init(title: "Sunset"),
.ocean: .init(title: "Ocean"),
.forest: .init(title: "Forest"),
.desert: .init(title: "Desert"),
.aurora: .init(title: "Aurora"),
.lava: .init(title: "Lava"),
.galaxy: .init(title: "Galaxy"),
.pastel: .init(title: "Pastel"),
.neon: .init(title: "Neon"),
]
}
}
The typeDisplayRepresentation
defines how iOS should refer to this type in system UIs like Shortcuts or Siri. In our case, setting the name to “Palette” tells iOS to show and say “Palette” whenever this parameter type appears.
caseDisplayRepresentations
tells iOS how to present each case of your enum in system UIs like Shortcuts pickers or Siri dialogs.
- The key (.sunset, .ocean, …) is your enum case.
- The value (
DisplayRepresentation
) is the user-facing label.
For example:
.sunset: .init(title: "Sunset")
means: “When the user chooses the .sunset case in Shortcuts or hears it from Siri, show the word Sunset.”
So together with typeDisplayRepresentation (the overall type name), this gives the system everything it needs to present your enum nicely:
Palette
→ the parameter type- Sunset, Ocean, Forest… → the selectable values
We keep the UI related to the Palette in a separate file. Please create a new Swift file called “Palette+UI.swift”:
import SwiftUI
@MainActor
extension Palette {
var bandColors: (top: Color, mid: Color, bottom: Color) {
switch self {
case .sunset:
return (.orange, .pink, .purple)
case .ocean:
return (.cyan, .blue, .indigo)
case .forest:
return (.green, .mint, .teal)
case .desert:
return (.yellow, .orange, .brown)
case .aurora:
return (.purple, .mint, .blue)
case .lava:
return (.red, .orange, .black)
case .galaxy:
return (.indigo, .purple, .black)
case .pastel:
return (.pink.opacity(0.7), .mint.opacity(0.7), .yellow.opacity(0.7))
case .neon:
return (.green, .pink, .cyan)
}
}
}
We keep the AppEnum part of Palette
(the system-facing code) in a file without SwiftUI, and the color helpers in a separate SwiftUI file. Why? Because App Intents need to treat Palette as a lightweight, system-safe type that can run outside the main UI thread. If we mix in SwiftUI code, Xcode thinks Palette is tied to the UI (the “main actor”), which causes warnings.
By splitting them:
Palette.swift
= the system-facing definition (safe for Siri/Shortcuts).Palette+UI.swift
= the SwiftUI helper that knows about colors.
This way, both sides stay clean: the system can use Palette safely, and our app can still render it with SwiftUI.
Now, let’s create our first AppIntent:
- In Xcode, create a new Swift file called
GenerateMeshIntent.swift
. - Add the following code:
First, we need to import the App Intents framework, which provides all the building blocks we need (as we will see in a moment: AppIntent
, @Parameter
, ParameterSummary
, IntentResult
, etc.):
import AppIntents
We define this new struct that conforms to the AppIntent protocol. Every intent is just one action your app can perform:
struct GenerateMeshIntent: AppIntent {
}
We can define a name of the action that will appear in Shortcuts and Siri suggestions:
static var title: LocalizedStringResource = "Generate Mesh"
When you search for actions, you’ll see “Generate Mesh”.
To ensure to launch the app when the intent is run (so the user actually sees the result), we add the following:
static var openAppWhenRun: Bool = true
If we left this as false
, Siri could just reply with text and the app wouldn’t open.
To open our MeshArt app, we need 3 inputs to our action:
rows
: how many rows the mesh should havecols
: how many columns the mesh should havepalette
: which color set to use (our Palette enum)
To define those, please add:
@Parameter(title: "Rows")
var rows: Int
@Parameter(title: "Columns")
var cols: Int
@Parameter(title: "Palette")
var palette: Palette
The ‘title’ you set in each parameter is what the Shortcuts app shows in its parameter fields.
To define how iOS should describe the action in plain language when you add it to a Shortcut, we add:
static var parameterSummary: some ParameterSummary {
Summary("Generate a \(\.$rows) by \(\.$cols) mesh with \(\.$palette) colors")
}
So instead of showing a bunch of fields, Shortcuts will display a regular sentence like:
“Generate a 4 by 6 mesh with sunset colors.”
We also need to define a function that actually runs when the intent is triggered. In our case:
func perform() async throws -> some IntentResult & ProvidesDialog {
return .result(
dialog: "Generating a \(rows) by \(cols) mesh with \(palette.rawValue.capitalized) colors."
)
}
For now, we’re just returning a dialog string. Siri (or the Shortcuts app) will speak or show:
“Generate a 4 by 6 mesh with sunset colors.”
Later, we’ll expand this to open the app and show the gradient.
- Build & run your project once.
- Open the Shortcuts app, tap +, and add the action Generate Mesh.
- You’ll see fields for Rows, Columns, and Palette.
- Enter some values and tap Run.
For now, the Shortcut will run and Siri will reply with a confirmation message like:
“Generate a 4 by 6 mesh with sunset colors.”
Congratulations — you just created your first App Intent!
At this point, the Shortcut only shows a dialog; it doesn’t navigate yet.
In the next step, we’ll make the intent actually open the app into a generated gradient view, using the parameters you provide.
Step 3: Connect the App Intent to Your UI
We’ll pass the parameters (rows, columns, palette) from the intent to SwiftUI using a tiny in-memory controller.
What you’ll add:
- A shared MeshController that the intent writes to.
- A one-line registration so
@Dependency
can inject that controller into the intent. - A small update to
ContentView
to navigate intoGeneratedMeshView
whenever a request arrives.
3.1 Add the shared controller
Please create a new Swift file MeshController.swift
.
import Foundation
import Combine
final class MeshController: ObservableObject {
@Published var pending: (rows: Int, cols: Int, palette: Palette)?
@MainActor
func generate(rows: Int, cols: Int, palette: Palette) {
pending = (rows, cols, palette)
}
}
Here, the variable pending
holds the next thing the UI should show and generate(...)
sets it on the main actor so SwiftUI can react safely.
3.2 Register the controller in your App
We’ll create one instance, register it for @Dependency
, and inject it into the SwiftUI environment.
In the existing MeshArtApp.swift
, please modify the existing code to:
import SwiftUI
import AppIntents
@main
struct MeshArtApp: App {
@StateObject private var meshController: MeshController
init() {
// Create the controller first…
let controller = MeshController()
// …store it in StateObject…
_meshController = StateObject(wrappedValue: controller)
// …and register THIS exact instance for @Dependency lookups.
AppDependencyManager.shared.add(dependency: controller)
}
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(meshController)
}
}
}
This ensures the same instance is used by both the intent and your SwiftUI views.
3.3 Have the intent write into the controller
Add a dependency to your existing intent and set the request when it runs. Therefore, in GenerateMeshIntent.swift
, add the following variable (e.g. just after the parameter definition of palette
):
@Dependency var meshController: MeshController
Modify the perform
function to:
func perform() async throws -> some IntentResult & ProvidesDialog {
await MainActor.run {
meshController.generate(rows: rows, cols: cols, palette: palette)
}
return .result(dialog: "Opening MeshArt…")
}
3.4 GeneratedMeshView (the screen your intent opens)
This view renders a mesh gradient for any rows × columns with a chosen palette. We’ll start with a clean, non-draggable version (so it’s easy to verify the intent wiring).
In a later step you can upgrade it to show the draggable control points.
Create a new SwiftUI file called GeneratedMeshView
:
import SwiftUI
struct GeneratedMeshView: View {
let rows: Int
let cols: Int
let palette: Palette
private var points: [SIMD2<Float>] {
let r = max(2, rows), c = max(2, cols)
return (0..<r).flatMap { row in
(0..<c).map { col in
let x = Float(col) / Float(max(1, c - 1))
let y = Float(row) / Float(max(1, r - 1))
return SIMD2<Float>(x, y)
}
}
}
private var colors: [Color] {
let r = max(2, rows), c = max(2, cols)
let (top, mid, bottom) = palette.bandColors
let band1 = r / 3
let band2 = 2 * r / 3
return (0..<r).flatMap { row in
let rowColor: Color = (row < band1) ? top : (row < band2) ? mid : bottom
return Array(repeating: rowColor, count: c)
}
}
var body: some View {
MeshGradient(
width: max(2, cols),
height: max(2, rows),
points: points,
colors: colors
)
.ignoresSafeArea()
.overlay(alignment: .topLeading) {
// Small header so users can confirm the parameters came through
HStack(spacing: 8) {
let (t, m, b) = palette.bandColors
RoundedRectangle(cornerRadius: 2).fill(t).frame(width: 14, height: 8)
RoundedRectangle(cornerRadius: 2).fill(m).frame(width: 14, height: 8)
RoundedRectangle(cornerRadius: 2).fill(b).frame(width: 14, height: 8)
Text("\(rows)×\(cols) • \(palette.rawValue.capitalized)")
.font(.headline)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.ultraThinMaterial, in: Capsule())
.padding()
}
}
}
#Preview {
GeneratedMeshView(rows: 4, cols: 6, palette: .sunset)
}
This view is doing the following:
points
builds a normalized grid(0…1)
in[SIMD2]
as required byMeshGradient
.colors
duplicates the palette’s 3 band colors across each row; this keeps the output predictable for any grid size.- The
overlay
shows a tiny swatch of the three colors plus a label—handy to verify that Siri/Shortcuts parameters were applied.
3.5 Navigate into the generated view when a request arrives
We’ll use item-based navigation so it’s reliable even if the user requests the same parameters repeatedly.
Create a new Swift file GeneratedRoute.swift
:
import SwiftUI
struct GeneratedRoute: Identifiable, Hashable {
let id = UUID()
let rows: Int
let cols: Int
let palette: Palette
var title: String {
"GeneratedMeshView (\(rows)×\(cols) \(palette.rawValue.capitalized))"
}
}
In ContentView
please add the following properties:
@EnvironmentObject private var meshController: MeshController
// Label state for the menu row (what the user sees)
@State private var generatedLabel = GeneratedRoute(rows: 4, cols: 6, palette: .sunset)
// Route used to navigate (nil means not pushed)
@State private var navRoute: GeneratedRoute?
And below the NavigationLink()
for the DraggableMeshView
we add a new entry to the list
:
// Use a Button so we can push programmatically every time.
Button {
// Always push: assign a fresh route (new UUID) from the current label.
navRoute = GeneratedRoute(
rows: generatedLabel.rows,
cols: generatedLabel.cols,
palette: generatedLabel.palette
)
} label: {
HStack {
Text(generatedLabel.title)
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.tertiary)
}
}
.buttonStyle(.plain)
and below the .navigationTitle
please add:
.navigationDestination(item: $navRoute) { route in
GeneratedMeshView(rows: route.rows, cols: route.cols, palette: route.palette)
}
After the closing curly bracket of the NavigationStack
please add the following .onReceive
modifier:
.onReceive(
Publishers.Merge(
Just(meshController.pending).compactMap { $0 },
meshController.$pending.dropFirst().compactMap { $0 }
)
) { req in
generatedLabel = GeneratedRoute(
rows: max(2, req.rows),
cols: max(2, req.cols),
palette: req.palette
)
navRoute = GeneratedRoute(
rows: generatedLabel.rows,
cols: generatedLabel.cols,
palette: generatedLabel.palette
)
meshController.pending = nil
}
Test it quickly: Run the app. In your app’s menu (from earlier steps), tap the “GeneratedMeshView (… )” row—you should see a gradient. Close the app and run the “Generate Mesh” shortcut (e.g., 3×5, Forest). The app should open directly into “GeneratedMeshView (3×5 Forest)”.
The next step is not necessary for the AppIntent purpose but if you want to add the same draggable control points to the GeneratedMeshView
we had in our original app, then please follow along the next step.
3.6 Optional: Add Draggable Control Points
If you want to add the draggable control points, let’s update the GeneratedMeshView
to the following – similar to the DraggableMeshView
in our previous code-along:
import SwiftUI
struct GeneratedMeshView: View {
let rows: Int
let cols: Int
let palette: Palette
// Tiny inset to avoid sampling artifacts on the border
private let margin: CGFloat = 0.01
// MARK: - Grids
/// Fixed grid at exact edge-aligned positions (0...1)
private var framePointsCG: [CGPoint] {
let r = max(2, rows), c = max(2, cols)
return (0..<r).flatMap { row in
(0..<c).map { col in
CGPoint(
x: CGFloat(col) / CGFloat(max(1, c - 1)),
y: CGFloat(row) / CGFloat(max(1, r - 1))
)
}
}
}
/// Initial draggable grid, slightly inset from the edges to avoid harsh pinning
private var initialDragPoints: [CGPoint] {
framePointsCG.map { p in
CGPoint(
x: p.x * (1 - 2 * margin) + margin,
y: p.y * (1 - 2 * margin) + margin
)
}
}
// Live state for dragging (normalized 0...1). Starts empty; we lazily set it on first drag/reset.
@State private var dragPoints: [CGPoint] = []
// MARK: - Colors
/// 3-band color fill (top / middle / bottom) expanded across columns.
private var colors: [Color] {
let r = max(2, rows), c = max(2, cols)
let (top, mid, bottom) = palette.bandColors
let band1 = r / 3
let band2 = 2 * r / 3
return (0..<r).flatMap { row in
let rowColor: Color = (row < band1) ? top : (row < band2) ? mid : bottom
return Array(repeating: rowColor, count: c)
}
}
// Helpers
private func simd(_ points: [CGPoint]) -> [SIMD2<Float>] {
points.map { SIMD2(Float($0.x), Float($0.y)) }
}
private var activeDrag: [CGPoint] {
dragPoints.isEmpty ? initialDragPoints : dragPoints
}
// MARK: - Body
var body: some View {
GeometryReader { geo in
ZStack {
// 1) Background mesh using the fixed frame grid (adds pleasant structure)
MeshGradient(
width: max(2, cols),
height: max(2, rows),
points: simd(framePointsCG),
colors: colors
)
.ignoresSafeArea()
// 2) Foreground mesh driven by draggable points
MeshGradient(
width: max(2, cols),
height: max(2, rows),
points: simd(activeDrag),
colors: colors
)
.ignoresSafeArea()
// 3) Draggable handles
ForEach(activeDrag.indices, id: \.self) { i in
Circle()
.fill(.white)
.frame(width: 10, height: 10)
.padding(5)
.shadow(color: .black.opacity(0.6), radius: 0.5, x: 0.5, y: 0.5)
.position(
x: activeDrag[i].x * geo.size.width,
y: activeDrag[i].y * geo.size.height
)
.gesture(
DragGesture()
.onChanged { value in
// Initialize live grid on first drag
if dragPoints.isEmpty {
dragPoints = initialDragPoints
}
let nx = value.location.x / geo.size.width
let ny = value.location.y / geo.size.height
dragPoints[i] = CGPoint(
x: min(max(nx, margin), 1 - margin),
y: min(max(ny, margin), 1 - margin)
)
}
)
}
}
}
.overlay(alignment: .topLeading) {
// Small header: palette swatches + current parameters
HStack(spacing: 8) {
let (t, m, b) = palette.bandColors
RoundedRectangle(cornerRadius: 2).fill(t).frame(width: 14, height: 8)
RoundedRectangle(cornerRadius: 2).fill(m).frame(width: 14, height: 8)
RoundedRectangle(cornerRadius: 2).fill(b).frame(width: 14, height: 8)
Text("\(rows)×\(cols) • \(palette.rawValue.capitalized)")
.font(.headline)
}
.padding(.horizontal, 12).padding(.vertical, 8)
.background(.ultraThinMaterial, in: Capsule())
.padding()
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
// Reset: snap back to the neat inset grid
dragPoints = initialDragPoints
} label: {
Image(systemName: "arrow.counterclockwise.circle")
.resizable()
.frame(width: 24, height: 24)
.padding(6)
.foregroundStyle(.white)
.shadow(color: .black.opacity(0.7), radius: 1, x: 1, y: 3)
.accessibilityLabel("Reset control points")
}
}
}
}
}
Now your generated view is interactive, and it still honors all parameters from your App Intent.
Run your app, go to Shortcuts and run the Shortcut with parameters of your choice – and you will get a draggable mesh gradient of your choice. Congratulations!
Of course, the app could (and should) be refactored since the previously created DraggableMeshView
and the now created GeneratedMeshView
are using pretty similar code. However, this code-along was dedicated to AppIntents and therefore I leave it up to you to refactor the code. 😀
What you have learned
In this code-along, you’ve learned how to
- Define a custom AppEnum (Palette) and make it usable in Siri and Shortcuts
- Create your very first App Intent with parameters (rows, columns, and palette)
- Use AppDependencyManager and a shared controller to pass data from an intent into SwiftUI
- Automatically open your app and navigate to the right screen when the intent runs
- Build a GeneratedMeshView that renders a gradient from the intent parameters
That’s a lot to cover in just one code-along! You made it this far, very well done! 🎉
That’s a wrap!
Keep learning, keep building, and let your curiosity guide you. Happy coding! ✨
A person who never made a mistake never tried anything new. — Albert Einstein
Download the full project on GitHub https://github.com/swiftandcurious/MeshArtAppIntent