[SC]()

iOS. Apple. Indies. Plus Things.

Learnable, Memorable, Accessible

// Written by Jordan Morgan // Oct 3rd, 2025 // Read it in about 4 minutes // RE: SwiftUI

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

Maybe it’s just me, but it seems like we’re seeing quite a bit more custom controls in iOS apps lately. My theory? It’s a direct result of SwiftUI, wherein its composable nature makes them easier to architect when contrasted to UIKit. Override init(frame:)? Nah, just toss a VStack out there and add what you need.

The barrier to entry is lower, but the risk for tomfoolery has scaled linearly.

Thankfully, Apple has done a phenomenal job of keeping these controls accessible. Most of it, true to form, “just works” — but some of it doesn’t. So here’s a quick guide to follow, should you consider a custom control. Our subject matter for this post? This little toggle-segment guy found in Alyx:

A segment control customized in Alyx.

For custom controls, there are three rules I try to follow. Each custom control should be:

  1. Learnable: If something is not obvious to use, people will not use it at all.

  2. Memorable: If something has no obvious reason to be used in lieu of the system control, reconsider making it.

  3. Accessible: If something can’t be used by everyone, then it probably shouldn’t be shipped to anyone.

So, back to my toggle guy.

Learnable

Is this learnable? I think so, because it passes the visual inspection test. If someone looks at it, they are likely to understand why it’s there and what it might do (regardless as to whether or not they are privy to the tech scene). Here, they are likely to think, “This seems like a toggle.” That’s not by accident, as a Picker with the segmented style is a prevalent SwiftUI control, whose roots go back to UISegmentedControl (a control that’s been around so long iOS wasn’t even called iOS when it first shipped, it was iPhoneOS).

Like other controls, if it works, then it inherently becomes more flexible, too. I have another similar variant of the same control I use to toggle dates, it’s mostly the same but just a smidge tinier:

The same control used in another context.

There is a tolerance scale you have to weigh here, and finding the balance on it doesn’t come naturally to a lot of us. It’s easy to make a custom control because you can, it’s not exactly hard anymore. Always pump the brakes first, and ask yourself if the control will be understood at first glance. If the cognitive load to understand it is high, then the reason to ship it should be low.

Memorable

But (and there’s always a "but", isn’t there?)!

There is, of course, a spectrum here — because part of the joy of custom controls can be discovery. If the intent is to drive home some selective emphasis and joy, I tend to think that’s a completely legitimate reason to make a custom control. We can wax poetic about how boring software is now, but…actually — yeah, let’s keep doing that! Adding a little splash of creativity to your app can be endearing, and it can also make it memorable.

There are different ways to be memorable, though, and many of them have nothing (at least, directly) to do with jolts of pure creativity. For example, when Loren Brichter created the pull to refresh UX, I assume that it wasn’t exactly created to be splashy, nor was it the product of a need to express a creative outlet, it just made more sense than we had been doing. The rest, is of course, cemented in history on your phone right now. We all pull to refresh.

As such, my decision to make the custom toggle in Alyx was a creative one. I wanted to reinforce its branding, the roundy-ness, bouncy and playful tone of the app, that nothing is really that serious here. And, it was just a gut call to assume that this one was better than the stock one for my use case:


Image 1
Image 2
My version
Stock version


Accessible

Of course, if it’s not accessible then you’ll run into a whole host of issues. Empathy is the best teacher, and it wasn’t until I personally met someone who relied on accessibility features on their phone that I truly grasped how critical it is to consider. While you can easily say it’s the right thing to do, I think that’s an obvious argument to make. Of course it is!

Beyond that, custom controls that fully support all accessibility contexts also have an air of craftsmanship to them that not everyone is willing to achieve. And, it’s so easy to do that now! Apple has a killer API for custom controls and accessibility, .accessibilityRepresentation. This lets you vend an entirely different control to the accessibility engine in place of the one that you’ve made.

Why is that critical? Because you can pass off Apple’s controls! And guess what? They’ve thought about more accessibility edge cases than you or I have. So, here, that could look something like this:

HStack {
    theControl
        .accessibilityRepresentation {
            Picker("", selection: $inputMode) {
                ForEach(PresentationInput.allCases) { mode in
                    Button {
                        update(to: mode)
                    } label: {
                        Image(systemName: mode.glyph)
                    }
                }
            }
            .pickerStyle(.segmented)
    }
}

Now, the accessibility engine will see Apple’s far superior accessibility implementation. When Apple shipped this change, it became immediately obvious that this was the best way to handle similar situations — I couldn’t believe A) I had never thought of it, and B) it didn’t ship with SwiftUI 1.0. It’s a literal cheat code for a good accessibility outcome.

Even though I just mentioned there is a little hint of craftsmenship to fantastic accessibility support in custom controls, on second thought — the APIs, SwiftUI and UIKit have become so accessible by default that it’s almost harder to make something not accessible. That’s a good place to be.

So that’s my thought process. Learnable, memorable and accessible. If your custom control passes that smell test, then you’re probably heading down a good path.

Until next time ✌️

···

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 ✌️

···