[SC]()

iOS. Apple. Indies. Plus Things.

A Month of Marketing for Elite Hoops

// Written by Jordan Morgan // Nov 4th, 2025 // Updated Nov 17th, 2025 // Read it in about 3 minutes // RE: The Indie Dev Diaries

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

I’ll keep it short — I’m going to focus on doing a month straight of marketing for Elite Hoops. Xcode will only open if it helps me market something. I’ll try to update this post for the next 30 days on what I’ve tried. Check back each day to see what I’m up to. Think of this post as a live journal.

Wednesday, November 5th

  • Researched existing social formats to discover videos I could make for paid ads.
  • I found about four or five that I think I could turn around fairly quick.
  • One question I have on these - do I go with boosted posts, or actual paid ads?
  • I’m also looking at blog posts that are evergreen to help with SEO efforts, the Elite Hoops website will be critical moving forward.

Thursday, November 6th

  • One ephinany I had — my app is seasonal, and its season right now. So during my seasonal months, I need to triple down on marketing, and in the off-season I should triple down on feature development. I think not crossing those wires is smart.
  • I have a meeting with a marketing agency Friday, exicted to see what that brings.
  • One idea I have is to piggyback off of viral workouts that NBA stars used to do, maybe I could build those into Elite Hoops somehow.

Friday, November 7th

  • Hired a marketing agency, a 30 day agreement. I’m excited about this - it takes the load off of me for creating content. They’re going to do it.
  • That also means I’m a bit free to do other stuff, should it be content marketing? Blogs? Plays on YouTube? Where should I go?
  • I’m excited about that viral NBA workout idea I had, that’s pulling me in — so I’m going to explore that starting tomorrow.

Saturday, November 8th

  • Absolutely nothing, I was at kid’s sporting events all day.

Sunday, November 9th

  • And nothing again, I watched football all day and it was great.

Monday, November 10th

  • The marketing agency I’m working with is going to produce 20 videos for me, so content wise I’ll be posting those.
  • Since that frees me up, I’m going with this “Thompson Twins” workout idea.
  • That’ll include a blog post, another “free tool” I think on the Elite Hoops’ website, and I need to add the drills to the app itself.
  • Then, I’m thinking an email marketing campaign, along with reels I have around this to promote. This is lead-gen, since it’ll be free.

Also, here’s how I’m kind of thinking about these 30 days in terms of marketing:

Month of marketing gameplan.

Tuesday, November 11th

  • I’ve got the “Thompson Twins Workout” idea all fleshed out now. It’s a free tool on the website, and a template workout in the app.
  • The website version will actually be a bit more fleshed out, the workout is insane. So I have a 7 day plan .pdf to work up to it (email gated).
  • First up, I need to make the actual .pdf…and this is killing me. It’s soul crushing work making it myself, I would be faster doing it in SwiftUI.

Wednesday, November 12th

  • I added some more quotes to the basketball quotes post.
  • I’m slogging through this .pdf…I hate working on it and I have no idea why. I think because it’s hard for me to make it pretty, easily.
  • But, it has to be done because the rest of the project hinges on it.

Thursday, November 13th

  • If you were wondering, I still hate this .pdf.
  • BUT also, I nailed down the design, and I’m almost done with it! It’s seven pages, so even though I had the content, I needed the design to, well, perfect. It’s the centerpiece of the whole package.
  • The marketing agency has nailed down formats and creators, so that should start bearing fruit soon.

The .pdf final design for the Thompson Twins project.

Friday, November 14th

  • Game planned with my marketing agency, we should have videos next week.
  • I updated some blog posts which are starting to rank and drive good SEO results.
  • I had to rework some of the workouts for the Thompson Twins workout, but I’m happy with it now.

Saturday, November 15th

  • Did nothing!

Sunday, November 16th

  • And then I did nothing again!

Monday, November 17th

  • Everyone, an announcement: I HAVE FINISHED THE .pdf! What a battle, seven whole pages. But it turned out nice, and I had to nail it.
  • Met with the marketing agency, reviewed formats — we’re ready to record!
  • Started in on the web dev side of things, which shouldn’t take too long to finish. Famous last words, I guess.

Tuesday, November 18th

Coming soon, check back at this date to see what I marketed.

Wednesday, November 19th

Coming soon, check back at this date to see what I marketed.

Thursday, November 20th

Coming soon, check back at this date to see what I marketed.

Friday, November 21st

Coming soon, check back at this date to see what I marketed.

Saturday, November 22nd

Coming soon, check back at this date to see what I marketed.

Sunday, November 23rd

Coming soon, check back at this date to see what I marketed.

Monday, November 24th

Coming soon, check back at this date to see what I marketed.

Tuesday, November 25th

Coming soon, check back at this date to see what I marketed.

Wednesday, November 26th

Coming soon, check back at this date to see what I marketed.

Thursday, November 27th

Coming soon, check back at this date to see what I marketed.

Friday, November 28th

Coming soon, check back at this date to see what I marketed.

Saturday, November 29th

Coming soon, check back at this date to see what I marketed.

Sunday, November 30th

Coming soon, check back at this date to see what I marketed.

Monday, December 1st

Coming soon, check back at this date to see what I marketed.

Tuesday, December 2nd

Coming soon, check back at this date to see what I marketed.

Wednesday, December 3rd

Coming soon, check back at this date to see what I marketed.

Thursday, December 4th

Coming soon, check back at this date to see what I marketed.

Until next time ✌️

···

Opt for Localized Strings

// Written by Jordan Morgan // Oct 22nd, 2025 // Read it in about 2 minutes // RE: Foundation

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

One of my goals this year was to localize my soccer app into German, French and Spanish. With the nascent String Catalogs, and Xcode’s 26 on-device inference engine for creating comments about what each String represents, it felt like the time was right.

Ah, the time was right, but my code was not. I was using plain String types in a lot of places, and a String catalog won’t pick those up for translation:

enum AppTab: String, CaseIterable {
    case teams, drills, practices
}

// Later on, a simplified example...
ForEach(AppTab.allCases) { tab in 
    Text(tab.rawValue)
}

This is easy to miss, because SwiftUI does a fantastic job and opting you into using localizable String types, even if you don’t realize it:

Text("Make localizing your app easy!")

Under the hood, that string is a LocalizedStringKey, which means a String Catalog will pick it up for translation:

init(
    _ key: LocalizedStringKey,
    tableName: String? = nil,
    bundle: Bundle? = nil,
    comment: StaticString? = nil
)

Going forward, I’ve started writing String variables, parameters, and anything else that’ll show in a UI (which, well, Strings tend to do…all the time) using LocalizedStringKey — there’s no code changes you need to make when you swap this with a String type, plus you get the String Catalog support:

// From this
struct AnotherView: View {
    let headerText: String // <-- Won't show in String Catalog
    
    var body: some View {
        Text(headerText)
    }
}


// To this
struct AnotherView: View {
    let headerText: LocalizedStringKey // <-- Will show in String Catalog
    
    var body: some View {
        Text(headerText)
    }
}

To follow up on the first example:

enum AppTab: String, CaseIterable {
    case teams = String(localized: "Teams"), drills = String(localized: "Drills"), practices = String(localized: "Practices")
}

The same goes if you have interpolated Strings, using String(localized:comment:) does the trick:

// From this
let result = model.didGeneratePlan ? "Practice plan ready!" : "Failed to generate plan."

// To this
let result = model.didGeneratePlan ? String(localized: "Practice plan ready!") : String(localized: "Failed to generate plan.")

let concatenatedThoughts = """

Notice how I didn't use the `comment:` parameter in that last example? I found that Xcode's automatic generation was so good, it was making better comments that I did.

"""

My Localization “Stack”

It took the better part of my side project time last week, but I was able to complete a full translation to three new languages in Elite Soccer Club. It’s rolling out in v1.2.0 once App Review gives it its blessing, along with lineup sharing, Elite Hoops’ popular practice planner but retooled for soccer, and quite a bit more.

Here’s what I used to get my “v1” of localizations done:

  • I converted all String types to LocalizedStringKey, and any inline Strings to String(localized:).
  • I created String Catalogs, and downloaded the on-device model to create comments.
  • Following Daniel Saidi’s fantastic blog post, I used Cursor and Claude to translate over 3,000 items.
  • Then, using ButterKit, I paid the easiest $30 of my life, plugged in my OpenAI key, and had it translate all of the screenshot text.
  • Superwall automatically translated all of my paywalls to three languages in under 30 seconds. This feature is absolutely insane. Translating paywalls in Superwall.

  • And finally, I died inside while updating 6,000,000 things in App Store Connect — which would randomly lose images I uploaded constantly. This was not fun.

I feel like that gave me an incredible start, and a sign of the times that I could even do all of this within a week. While I am completely sure some of the translations won’t land, it’s better than not having anything. I plan on iterating when I get feedback to make things better, but also - Xcode’s comment generation surely helped AI translation since it had the extra context. I augmented the prompt in the blog linked above to make sure they were considered.

Until next time ✌️

···

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

···