[SC]()

iOS. Apple. Indies. Plus Things.

Create an Interactive Snippet Shortcut using App Intents

// Written by Jordan Morgan // Sep 20th, 2025 // Read it in about 2 minutes // RE: Snips

This post is brought to you by Sentry, mobile monitoring considered "not bad" by 4 million developers.

let concatenatedThoughts = """

Welcome to Snips! Here, you'll find a short, fully code complete sample with two parts. The first is the entire code sample which you can copy and paste right into Xcode. The second is a step by step explanation. Enjoy!

"""

The Scenario

Create an interactive snippet view with buttons, powered by App Intents.

import SwiftUI
import AppIntents

// 1

struct FirstIntent: AppIntent {
    static let title: LocalizedStringResource = "Initial Intent"
    static let description: IntentDescription = "Boots into the snippet"

    func perform() async throws -> some ShowsSnippetIntent {
        .result(snippetIntent: CounterSnippetIntent())
    }
}

struct CounterSnippetIntent: SnippetIntent {
    static let title: LocalizedStringResource = "Counter Snippet"
    static let description: IntentDescription = "Shows an interactive counter"

    @Dependency var model: SnippetModel

    @MainActor
    func perform() async throws -> some IntentResult & ShowsSnippetView {
        .result(view: CounterSnippetView(model: model))
    }
}

// 2

struct CounterSnippetView: View {
    let model: SnippetModel

    private var count: Int { model.count }

    var body: some View {
        VStack(spacing: 12) {
            Text("Count: \(count)")
                .font(.title2).bold()
                .contentTransition(.numericText())
            HStack(spacing: 24) {
                Button(intent: DecrementCountIntent(current: count)) {
                    Image(systemName: "minus.circle.fill").font(.largeTitle)
                }
                Button(intent: IncrementCountIntent(current: count)) {
                    Image(systemName: "plus.circle.fill").font(.largeTitle)
                }
            }
        }
        .padding()
    }
}

// 3

struct DecrementCountIntent: AppIntent {
    static let title: LocalizedStringResource = "Decrease"
    static let isDiscoverable = false

    @Dependency var model: SnippetModel
    @Parameter var current: Int

    @MainActor
    func perform() async throws -> some IntentResult {
        model.count = max(0, current - 1)
        return .result()
    }
}

extension DecrementCountIntent {
    init(current: Int) { self.current = current }
}

struct IncrementCountIntent: AppIntent {
    static let title: LocalizedStringResource = "Increase"
    static let isDiscoverable = false

    @Dependency var model: SnippetModel
    @Parameter var current: Int

    @MainActor
    func perform() async throws -> some IntentResult {
        model.count = current + 1
        return .result()
    }
}

extension IncrementCountIntent {
    init(current: Int) { self.current = current }
}

// 4

@MainActor
final class SnippetModel {
    static let shared = SnippetModel()

    private init() {}

    var count: Int = 0
}

// In your app..
import AppIntents

@main
struct MyApp: App {
    init() {
        let model = SnippetModel.shared
        AppDependencyManager.shared.add(dependency: model)
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

With that, the we get an interactive snippet whose view stay presented even when you interact with the buttons:

The Breakdown

Step 1
You start with an AppIntent which returns ShowsSnippetIntent. The SnippetIntent itself vends the interactive view, its return type is ShowsSnippetView.

Step 2
The CounterSnippetView is a view which takes in any dependencies it needs, here — that’s our model. It’ll have buttons which give it interactivity, but they must fire an AppIntent, and when it does — it’ll reload the interactive snippet.

Step 3
These two AppIntent structs mutate the data, and they are wired up to the button. They’ve both set isDiscoverable to false since they don’t make sense to use anywhere else.

Step 4
Finally, you must add the depedency to your intents. You do that by adding it AppDependencyManager. Then, it’s available to any intent reaching for it via @Dependency.

Until next time ✌️

···

Creating Light and Dark Mode Icons using Icon Composer

// Written by Jordan Morgan // Sep 7th, 2025 // Read it in about 3 minutes // RE: Icon Composer

This post is brought to you by Sentry, mobile monitoring considered "not bad" by 4 million developers.

Ah, new toys.

When Icon Composer was first announced, I thought, “Hey, here’s something that might help with my biggest blind spot in iOS development, making icons look somewhat okay.” Anything that helps the solo indie developer make an icon easier is fantastic news to me.

The biggest hang up? For the life of me, this thing is simplistic yet confusing at times. Making a unique icon for both light and dark mode was important for my upcoming app, Alyx. I finally figured out how, because if I can’t have Alyx with a sleep mask on when it’s dark mode — really, what do I even have anymore?

Alyx espresso shot glass in dark mode.

Here’s how it’s done:

1. Add all of the groups you want to use in totality. So, all of your layers (and their respective groups) for light and dark mode:

Adding groups in Icon Composer.

2. Now, select the individual layers that should be hidden in light mode — set their opacity to 0%:

Adding groups in Icon Composer.

3. Then, select the dark mode icon at the bottom of Icon Composer:

Selecting the dark mode icon variant.

4. Finally, choose the individual layers that should be hidden in dark mode — set their opacity to 0%:

Setting opacity to 0 percent in dark mode in Icon Composer.

And that should do it. What really tripped me up was that I started by choosing a dark mode icon, and I thought, “Okay, it clearly says "Dark" here, so I’ll hide the light layers now.” But, going the other way was confusing to me — because you don’t select a “Light” toggle at that point. Instead, it’ll say “Default” with no option to choose or add anything else. What this is trying to say is “The changes here apply to light mode.”

The more you know.

Until next time ✌️

···

Open Intent in iOS 26

// Written by Jordan Morgan // Aug 19th, 2025 // Read it in about 1 minutes // RE: App Intents

This post is brought to you by Sentry, mobile monitoring considered "not bad" by 4 million developers.

While working on Alyx and its (over 40!) Siri Shortcut actions, I came upon the need for an AppEntity to launch the app. In the past, I had similar intents for Elite Hoops:

struct OpenTeamIntent: AppIntent {
    static var isDiscoverable: Bool = false
    static var title: LocalizedStringResource = "Open Team"
    @Parameter(title: "Team")
    var team: TeamEntity
    
    @Dependency
    var deepLinker: DeepLinker
    
    init(team: TeamEntity) {
        self.team = team
    }
    
    init() {}
    
    @MainActor
    func perform() async throws -> some IntentResult {
        deepLinker.selectedCourtID = team.id
        return .result()
    }
}

Turns out, doing it this way is🥁….

Wrong!

There’s an intent type just for this type of thing, called OpenIntent. So, I adopted that correctly:

struct OpenTeamIntent: OpenIntent {
    static var isDiscoverable: Bool = false
    static var title: LocalizedStringResource = "Open Team"
    @Parameter(title: "Team")
    var target: TeamEntity
    
    @Dependency
    var deepLinker: DeepLinker
    
    init(target: TeamEntity) {
        self.target = target
    }
    
    init() {}
    
    @MainActor
    func perform() async throws -> some IntentResult {
        deepLinker.selectedCourtID = target.id
        return .result()
    }
}

But wait! It’s actually even easier than that! I don’t even need my own navigation class to do this anymore. Enter iOS 26 variant:

import AppIntents
import CaffeineKit

struct OpenDrinkIntent: OpenIntent {
    static let title: LocalizedStringResource = "Log Matched Drink"
    
    @Parameter(title: "Drink", requestValueDialog: "Which drink?")
    var target: CaffeineDrinkEntity
    
    func perform() async throws -> some IntentResult {
        return .result()
    }
}

#if os(iOS)
extension OpenDrinkIntent: TargetContentProvidingIntent {}
#endif

That’s it! Then, in your app in some view, there’s a modifier to handle it:

SomeView()
.onAppIntentExecution(OpenDrinkIntent.self) { intent in
    AlyxLogs.general.info("Opening drink intent: \(intent.target.name)")
    openTheThing()
}

To recap:

  1. Adopt OpenIntent
  2. Provide a target (yes, it MUST be named exactly that) of one of your AppEntity types.
  3. Add the TargetContentProvidingIntent conformance to it.
  4. And use the onAppIntentExecution modifier.

That’s an API at play I used with Visual Lookup support, by the way:

Until next time ✌️

···

The Business and the Boutique

// Written by Jordan Morgan // Jul 27th, 2025 // Read it in about 2 minutes // RE: The Indie Dev Diaries

This post is brought to you by Sentry, mobile monitoring considered "not bad" by 4 million developers.

Some apps are made to be big boy business ventures. Some are for the love of the game, man. I’ve been building a business, but yet…the itch remains.

If you’ve been around here a while, you’ll remember Spend Stack, a (if I may) delightful little personal finance app. Or, was it a budget tracker? Or a way to track a running total of stuff? I dunno, it was sort of all those things (which became an issue).

But, for all of its faults, there was one thing it absolutely was: An iOS-first playground for me. Drag and drop, multiple windows — all things that were new and novel at the time — were implemented over any actual product feature. I absolutely loved working on it. I’ll never forget its App Store features, being on demo units, the press coverage, App of the Day!

While Elite Hoops is doing great and it remains my primary focus, it’ll never really be that fun little indie darling. I want one of those, too. I want my cake. And I want to eat all of it.

But over the years, I’ve learned a bit more. If I ever made another “love of the game” app, two things would have to be true:

  • I would need a stable, business-could-grow project. I have that now with Elite Hoops.
  • And, if I made another love of the game app, it would need a clear audience and “thing it does” defined first.

Introducing Alyx

And so that brings me to Alyx!

Alyx espresso shot glass.

It has a clear mission — to track caffeine and see how it affects your body and sleep.

And, it's kinda fun!

So, what does that mean?

  • I’ll try to over-polish freaking everything.
  • I’ll go 200% in on Apple APIs like Shortcuts, widgets, etc.
  • Will I spend each summer integrating Apple’s new toys over anything else? You bet!
  • Dare I include an App Shortcut as part of onboarding, and jerryrig a way to detect when it was ran, then update the onboarding UI using TextRenderer APIs to make a video game-ish text bubble congratulating them? No-brainer!

It’s funny, I’ve actually used Alyx for two years now. I figured now was a good time to ship it out, and have it be my muse. Another “Spend Stack” if you will, but refined a bit from all rough edges I hit releasing, managing and creating it.

I’m trying my hardest to hit iOS 26 with this one, that’s always been a huge goal of mine. Ship a new app with a new version of iOS! I’m looking good so far, and because it’s my playground, the entire app is built around App Intents. Literally anything you can do in-app, you can do via an intent (even small stuff, like setting the app’s theme).

Elite Hoops is here to grow and make money. Alyx hopefully will do the same, but it’ll be my pretty little playground. Now I have a business, and my boutique shop.

Until next time ✌️

···

Confirmation and Result Interactive Snippets

// Written by Jordan Morgan // Jul 10th, 2025 // Read it in about 4 minutes // RE: App Intents

This post is brought to you by Sentry, mobile monitoring considered "not bad" by 4 million developers.

Recently, on the Superwall blog, I wrote up a guide over interactive snippets. If you haven’t checked that out, I’d go there first. Continuing on that same beat, I wanted to also highlight an important difference in interactive snippets.

There are two types you can provide:

  1. Result snippets: These show data that you don’t have to confirm (i.e. here’s how much caffeine you’ve had today).
  2. Confirmation snippets: These show data, but also require you to make some sort of decision before continuing (i.e here’s some coffee I want to order).

Both of them are interactive, and can have buttons or toggles that fire intents. At the API level, they both have the same signatures. However, confirmation snippets request said confirmation inside of the perform function. From Apple’s sample code:

struct FindTicketsIntent: AppIntent {

    func perform() async throws -> some IntentResult & ShowsSnippetIntent {
        let searchRequest = await searchEngine.createRequest(landmarkEntity: landmark)

        // Kicks off a confirmation snippet here
        try await requestConfirmation(
            actionName: .search,
            snippetIntent: TicketRequestSnippetIntent(searchRequest: searchRequest)
        )

        // Resume searching...
    }
}

And that is where things diverge. To understand that divergence better, consider that interactive snippets are typically dismissed by the user tapping the system provided “Done” button found below them:

Result type of an interactive snippet.

All “result” interactive snippets work this way. Alternatively, they can also just swipe it away to yeet it out of there.

But when you need confirmation, the “Done” button isn’t displayed, and instead the system will show contextualized buttons based on the actionName parameter. In the code above, that was set to “.search”, and so it is — you get a “Cancel” and “Search” button:

Confirmation type of an interactive snippet.

And then the intent carries on, here’s the full sample, I’ve edited the comment to demonstrate the flow better:

struct FindTicketsIntent: AppIntent {

    static let title: LocalizedStringResource = "Find Tickets"

    static var parameterSummary: some ParameterSummary {
        Summary("Find best ticket prices for \(\.$landmark)")
    }
    
    @Dependency var searchEngine: SearchEngine

    @Parameter var landmark: LandmarkEntity

    func perform() async throws -> some IntentResult & ShowsSnippetIntent {
        let searchRequest = await searchEngine.createRequest(landmarkEntity: landmark)

        // The new UI shows here, so you can think of `TicketRequestSnippetIntent`
        // As its own confirmation snippet. Once the user is done and they tap search,
        // We'll come back right here, and the search engine code will fire
        try await requestConfirmation(
            actionName: .search,
            snippetIntent: TicketRequestSnippetIntent(searchRequest: searchRequest)
        )

        // This happens after the confirmation of the number of tickets is done
        try await searchEngine.performRequest(request: searchRequest)

        // And finally, a new snippet is shown
        return .result(
            snippetIntent: TicketResultSnippetIntent(
                searchRequest: searchRequest
            )
        )
    }
}

What can be tricky here is that this flow shown today technically uses three different interactive snippets:

1. First, there’s the ClosestLandmarkIntent, which returns that landmark view:

Result type of an interactive snippet.

2. Then, tapping “Find Best Ticket Prices” takes us to the above code, where TicketRequestSnippetIntent asks for confirmation on the ticket count:

Result type of an interactive snippet.

3. And, it circles back to TicketResultSnippetIntent to show the results. If you tap “Book Now”, we go back to first step:

Result type of an interactive snippet.

I hope that helps clear up when you’d use a “result” interactive snippet versus a confirmation one. Just because Apple refers to result interactive snippets as a result of something, they are very much well-suited to powerful tasks complete with interactivity.

Now go forth, and make all quick, impactful interactions a snippet!

Until next time ✌️

···