[SC]()

iOS. Apple. Indies. Plus Things.

Three Indie Marketing Tips from my Deep Dish 2025 Talk

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

This post is brought to you by Emerge Tools, the best way to build on mobile.

Deep Dish Swift 2025 has been a treat, as it always is. My talk over indie app marketing seemed to resonate, but if you missed it — here are the three main takeaways from it. Think of it as a cheat sheet for my talk:

Shift your mindset

The first thing we have to do as indies is to stop defaulting to only coding. We tend to use a lot of our rare free time on “the next feature” or something from WWDC. All well and good, but there is a time and a place for it — and it’s not all the time.

Try to split your indie-dev time around, ideally, 50/50. You market half the time. You do product work half the time. If that’s unreasonable for your current situation, then try something like 60/40.

The point is, marketing must become a critical and first-class component of how you do things.

Emails!

Email marketing is, I think, something I can promote in general terms - insofar as it’s applicable to any app, for any indie and at any size. The amount of purchasing intent is high on emailing lists or a newsletter. Folks are consciously choosing to be there. If you don’t have one, I’d encourage you to get started today.

I personally use Mailerlite, and it has worked pretty well. There are several options, though — and all of them have an API. I recommend popping in a sign up form tactfully in your app’s onboarding. In Elite Hoops, it’s about the third or fourth slide, depending on a few other factors:

The email sign up screen in Elite Hoops.

Yes, paid acquisition can work for indies. But, you need to come at it with the right expectations and game plan:

  1. Save up ahead of time to spend, at least, $30 a day — and for at least a month. In my experience, you need to be spending around that much to A) learn anything about if it’s working, and B) see any tangible results.
  2. Realize that setting up your first campaign sucks. It’s hard, the dashboard is confusing, there’s a bunch of esoteric errors you’ll encounter - the list can, and will, go on. It’s like submitting your first app to the App Store. A wee bit painful, but once you’re done - you’re good to go.
  3. Keep a strict spreadsheet of showing all meaningful results, how you much you’ve spent, the trial conversion rate and any other data you can think of to make it absolutely certain if things are working. The one I use is in the image below - and you can download the template here.
The Numbers spreadsheet used to track ad spend.

Be sure to change the top row to account for your trial length. The idea is that you go back and fill in the “Earned X Week/Day Later” and then the cell right after it will do a simple Income - Ad Cost = Takehome calculation.

Final Thoughts

What else can I say? Try marketing your app! If you want to check out the talk, it’s at the 5hr08min mark here.

Until next time ✌️

···

Showing What's New Screens using @AppStorage

// Written by Jordan Morgan // Apr 12th, 2025 // Read it in about 2 minutes // RE: SwiftUI

This post is brought to you by Emerge Tools, the best way to build on mobile.

The “What’s New” or “Release Notes” view are found in iOS apps the world over. Elite Hoops is no exception:

The Whats New view in Elite Hoops on iOS.

With SwiftUI and @AppStorage, this type of thing requires me to change, quite literally, one character in my codebase when I want to fire it:

private struct AppSettingsKey: EnvironmentKey {
    static var defaultValue: AppSettings { .init() }
}

extension EnvironmentValues {
    var appSettings: AppSettings {
        get { self[AppSettingsKey.self] }
        set { self[AppSettingsKey.self] = newValue }
    }
}

// MARK: App Settings

final class AppSettings: ObservableObject {
    @AppStorage("hasSeenWhatsNewV12") var hasSeenWhatsNew: Bool = false
}

I version my what’s new stuff, so when there is something to show, I simply increment the number:

@AppStorage("hasSeenWhatsNewV12") var hasSeenWhatsNew: Bool = false

// Becomes...

@AppStorage("hasSeenWhatsNewV13") var hasSeenWhatsNew: Bool = false

Since the app storage macro uses UserDefaults under the hood, it’ll recognize this as a new key — which defaults to false:

@main
struct EliteHoopsApp: App {
	private let deepLinker: DeepLinker = .init()

	var body: some Scene {
        WindowGroup {
        	TheViews()
	        	.sheet(item: $deepLinker.sheetLinkedView) { deepLink in
	                switch deepLink {
	                case .whatsNew:
	                    WhatsNewView()
	                default:
	                    EmptyView()
	                }
	            }
        		.environment(\.appSettings, appSettings)
        		.environment(deepLinker)
        		.task {
        			showPostLaunchAnnouncementsIfNeeded()
        		}
        }
    }

    private func showPostLaunchAnnouncementsIfNeeded() {
        guard deepLinker.linkedView == nil else { return }
        
        if !appSettings.needsViewOnboarding &&
            !appSettings.hasSeenWhatsNew &&
            appSettings.numberOfOpens >= 2 {
            appSettings.hasSeenWhatsNew = true
            deepLinker.sheetLinkedView = .whatsNew
        }
    }
}

And that’s it — once the user has:

  1. Gone through onboarding.
  2. Opened the app more than two times.
  3. And has not seen the latest what’s new view, it’ll show.

A lot of developers will point out that nobody really looks at these things. Maybe they’re right, but I love working on it. It’s a dopamine hit that tells me I’ve finished up a big feature — and I tell people about it succintly and interactively.

And there’s no fluff here. Elocution is for the stage. In apps? A well-timed “What’s New” screen does all the talking you need — and SwiftUI can do it one character.

Until next time ✌️

···

The Great App That Nobody Knows About

// Written by Jordan Morgan // Apr 6th, 2025 // Read it in about 1 minutes // RE: The Indie Dev Diaries

This post is brought to you by Emerge Tools, the best way to build on mobile.

You might have a fantastic app sitting at one or two downloads a day. You’re probably thinking that it’s deserving of a little bit more, yeah? Unfortunately, this is where I see the story end with far too many talented indies.

We love to be the starving artist, even if we don’t realize it. But if there’s one thing that’s changed things for me personally, at least from an earning standpoint, it’s somewhere down the line, I realized the following truth:

A great app that nobody knows about, is still a great app that nobody knows about.

Seriously! It’s so cheesy and hoo-rah, I know, but it’s absolutely true. You have to get the word out…

Van Gogh Releases

…or, stated differently — don’t do Van Gogh App Releases. Our boy Vincent made over 900 paintings. In his lifetime, he sold one. One!

We know how the story ends. His paintings hang in museums, sell for a lot of money, and have inspired generations since. But during his life? Crickets. However, his work was never the issue.

If the App Store had existed back then, he probably would’ve been sitting at two downloads a day, too. And, then, probably would’ve cut off his other ear dealing with app review (too much?).

Market your app

Elite Hoops has grown and grown over the past year, and it’s only because I’ve started taking marketing as seriously as I have traditionally taken development. And, to put my money where my mouth is — I’ve popped on Apple Search Ads for my good ol’ soccer app, too.

To date, it’s done like — $75 in MRR? In the past month(ish), it’s at $131. Not life changing money, but I’m giving it a chance.

When you’re in the pits, it’s easy to confuse activity with perceived achievement. But in the end, you can’t develop or feature flag your way out of nobody knowing that your app exists. You just have to tell them. Then, start shipping some of that good stuff, and it’ll most likely work out better for you.

So give something a try, here are some ideas:

  • Try paid acquisition from your expendable income: Even if it’s $10 a day for a few months. You’ll learn a lot.
  • Commit to marketing for a month instead of developing: Again, you’ll learn a lot. Put down the feature requests and hotfixes, and see if you can bring some people into your app.
  • Post and post and post: If you have a social angle, simple try posting some features sizzle reals. Not marketing, Apple-slickified, polished ones — just simply you using the app doing this one thing that it does well.
  • Think like a marketing person as much as a developer: Because when you are an indie, you are in marketing, too.

The list goes on. Try things, fail, and try again. You were probably awful at programming when you started (Me! My apps were too!). You might be terrible at marketing in the beginning, too! It’s all good, I promise you’ll get better. And, I hope you do. Frankly, I want to see more indie apps in the world.

Until next time ✌️

···

An Ode to Swift Enums: The View Models That Could

// Written by Jordan Morgan // Feb 24th, 2025 // Read it in about 6 minutes // RE: Swift

This post is brought to you by Emerge Tools, the best way to build on mobile.

Swift enums are the swiss army knife of the iOS developer’s tool belt. Adept at solving many problems in several novel ways, its utility extends far beyond what we might’ve come to expect from an enumeration (which rings louder for those of who started with Objective-C). As such, a lovely way to embrace your fellow Swift enum is a lightweight view model.

Consider:

  • A baskeball practice planner app (hey, I know of one!)
  • Which shows a list of practices
  • …and each with a practice type: team practice, workout or skills training.

That’s an ideal time to flex enum’s raw might:

enum PracticeType {
	case teamPractice, workout, skillsTraining
}

Text Me

A fine start, though you’ll likely need to show the actual type as a String in your interface. Being the epitome of flexibility, there are two obvious routes fit to the task when using an enum:

Option 1: Use a String as its rawValue:

enum PracticeType: String {
	case teamPractice, workout, skillsTraining
}

Here, PracticeType has a raw value type of String:

let team = PracticeType.teamPractice
// teamPractice
print(team)

Serviceable in many cases. We’ll come back to this. For now, there’s also…

Option 2: Adopt CustomStringConvertible:

enum PracticeType: CustomStringConvertible {
    case teamPractice, workout, skillsTraining
    
    var description: String {
        switch self {
        case .teamPractice:
            "Team Practice"
        case .workout:
            "Workout"
        case .skillsTraining:
            "Skill Training"
        }
    }
}

CustomStringConvertible is a lightweight protocol which allows you to describe the adopting type a String via its description property:

let team = PracticeType.teamPractice
// Team Practice
print(team.description)

let concatenatedThoughts = """

However, any Swift type can be represented as a `String`, it's a nicety built into the language. Using the `String(describing:)` initializer will still yield a `String` of the passed in type. In this case, if the type adopts this protocol, then Swift defers to the `description` implementation.

"""

If you want to be an academic, you could make the argument (perhaps, too easily) that this is a misuse of the protocol. Here’s Apple:

Accessing a type’s description property directly or using CustomStringConvertible as a generic constraint is discouraged.

To wit, in their example — they interpolate a String to represent a type described in a more holistic sense, rather than a single case of an enum:

struct Point {
    let x: Int, y: Int
}

extension Point: CustomStringConvertible {
    var description: String {
        return "(\(x), \(y))"
    }
}

Which is appropriate for you is a matter for you to decide, but what I can tell you is I’ve adorned enums the world over with CustomStringConvertible and I’ve been no worse off.

Identity Crisis

Moving along, now — what if we need to show such a type in a Picker or ForEach? In these situations, the concept of identity is crucial. How do we individualize each type — shouldn’t enums inherently be described in such a fashion by design? In SwiftUI, we solve this problem (in part) via Identifiable conformance. And in enums? It’s quite trivial:

enum PracticeType: CustomStringConvertible, Identifiable {
	case teamPractice, workout, skillsTraining
    
    var description: String {
	    switch self {
	    case .teamPractice:
	        "Team Practice"
	    case .workout:
	        "Workout"
	    case .skillsTraining:
	        "Skill Training"
	    }
    }

	var id: Self { self }
}

Now, you’re free to show it in several different SwiftUI views — thanks to each type representing its own unique value in a way SwiftUI understands:

@State private var practiceType: PracticeType? = nil

Picker("", selection: $practiceType) {
	// Your picker representation
}

This view, here — a Picker, naturally leads us to another issue — how do we show all of the cases? Certainly, something like this is tiresome…

Picker("", selection: $practiceType) {
	Text(PracticeType.teamPractice)
		.tag(PracticeType.teamPractice)
	Text(PracticeType.workout)
		.tag(PracticeType.workout)
	Text(PracticeType.skillsTraining)
		.tag(PracticeType.skillsTraining)
}

…and indeed, unnecessary thanks to more of what Swift enums offer us.

Iteration

Moving on, we now arrive at CaseIterable, yet another protocol the Swift compiler can handle for us. This protocol allows us to represent our type as a collection, accessed via its allCases property. Though common in enums without an associated types, it can be in enums with associated types all the same. In our scenario, though — all that’s required is to simply declare conformance:

enum PracticeType: CustomStringConvertible, Identifiable, CaseIterable {
	// No other changes required from us
}

Now, the ergonomics of utilizing a Picker with our enum becomes much more tolerable (and scalable):

Picker("", selection: $practiceType) {
	ForEach(PracticeType.allCases) { practice in 
		Text(practice.description)
			.tag(practice)
	}
}

For the Love of the Game

Enums are a quintessential fit in the SwiftUI ecosystem, lending itself well to many of its design choices. Beyond using them in View types, I find myself having maybe a little too much fun with them. I could write a book (technically, because I’m a glutton for punishment, I wrote five) over the flexibility of the Swift enum. I can think of no better way to wrap this post up other than showing them off like a show pony.

For example, could I interest you in a random practice type?

let randomPractice: PracticeType = PracticeType.allCases.randomElement() ?? .teamPractice

Create extensions for further use cases:
Or, as your app grows and feature emerge, I also find myself sticking several quality of life extensions on my enums:

extension PracticeType {
	var subTitle: String {
		switch self {
	    case .teamPractice:
	        "Practices suited towards a team environment with five or more players."
	    case .workout:
	        "An individual workout."
	    case .skillsTraining:
	        "Skills-based training sessions, where drills are the primary activity.""
	}
}

Don’t get me started on the flexibility of associated values:
Associated values make what was already a powerful little construct stray into OP territory. Consider loading states:

enum PracticeState {
    case notStarted
    case inProgress(TimeInterval) 
    case completed(totalDuration: Int)
}

struct PracticeView: View {
    @State private var state: PracticeState = .notStarted
    @State private var elapsedTime: TimeInterval = 0
    private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
    
    var body: some View {
        VStack(spacing: 20) {
            switch state {
            case .notStarted:
                Button("Start Practice") {
                    state = .inProgress(0)
                }
                
            case .inProgress(let timeElapsed):
                Text("Time Elapsed: \(Int(timeElapsed))s")
                
                Button("End Practice") {
                    state = .completed(totalDuration: Int(timeElapsed))
                }
                .padding(.top)
                
            case .completed(let totalDuration):
                Text("Practice Completed")
                Text("Total Duration: \(totalDuration) seconds")
                
                Button("Restart") {
                    state = .notStarted
                }
                .padding(.top)
            }
        }
        .onReceive(timer) { _ in
            if case .inProgress(let timeElapsed) = state {
                state = .inProgress(timeElapsed + 1)
            }
        }
        .padding()
    }
}

Mocks and Dependency Injection:
Continuing on with associated values, they naturally slot into SwiftUI previews and mock services:

enum Environment {
    case production(Service)
    case staging(Service)
    case mock

    var analyticsService: AnalyticsService {
        switch self {
        case .production(let service), .staging(let service):
            return service
        case .mock:
            return Service.mocked()
        }
    }
}

let env: Environment = .production(ProdAnalytics())
let analyticsSerivce = env.analyticsService

Stay vanilla:
All this talk of creative use cases — it’s possible I’ve overlooked using enums just as they were originally envisioned. Of course, they are practical for that, too. But because Swift treats them as more of first class citizen and not a singular value to switch on, they are suited to many different tasks:

enum FeatureFlags {
    case onboardingV2
    case usePracticePlanner
    case environmentValue(value: String)

    var isEnabled: Bool {
        switch self {
        case .onboardingV2:
            return true
        case .usePracticePlanner:
            return false
        case .environmentValue(let val):
            return ProcessInfo.processInfo.environment[val] == "true"
        }
    }
}

// Later on...

if FeatureFlags.onboardingV2.isEnabled {
    NewOnboardingView()
} else {
    OnboardingView()
}

Wrapping Up

In practice, many of my enums become tiny little view models. Just because they can do so much — should they? Personally, I have never been one to get lost in the weeds on questions like that in my career. I simply enjoy the flexibility that the Swift language provides, and I deploy these tiny little powerhouses to make life easier and my apps ship faster.

Enums represent the Ne plus ultra of Swift’s type system — what was historically a tiny little construct relegated to simple multiple case representations has instead evolved into a durable, flexible type capable of handling just about any situation that both Swift and SwiftUI could demand of it. As such, the evolution has been something along the lines of using a lightweight NSObject , which became a lightweight Struct, and then become a lightweight Enum. What a time to be alive 🍻.

Until next time ✌️

···

Introducing Scores for NCAA Sports

// Written by Jordan Morgan // Jan 28th, 2025 // Read it in about 4 minutes // RE: The Indie Dev Diaries

This post is brought to you by Emerge Tools, the best way to build on mobile.

All I wanted was Apple Sports for Division II and III sports.

It didn’t seem to exist, so I made it. Introducing Scores for NCAA, an extremely fast and easy way to view stats and scores for your favorite men’s and women’s NCAA teams — across Division I, II and III:

A screenshot of Scores for NCAA on iOS.

While it works great for Division I, where it really shines is for folks who follow Division II and III teams. Want to know how the Division 2 overtime thriller between the Huntsville Chargers and Valdosta St. Blazers went last night? Covered:

A screenshot of Scores for NCAA on iOS showing a D2 boxscore.

Or, how about your favorite D3 women’s hockey team? Who hit the game winner between the Trine Thunder against Lebanon Valley? It was Payton Hans (great shot, Payton!):

A screenshot of Scores for NCAA on iOS showing a D2 boxscore.

Personally, I live for an upset alert. I love when a D2, D3 or NAIA school beats a D1 team. Here, you can easily check when smaller schools are playing Division I universities (unfortunately, there was no trace of an upset on this one):

A screenshot of Scores for NCAA on iOS showing a D2 boxscore.

Finally, the app is very fast. Look at how quickly you can page between scores here:

A screenshot of Scores for NCAA on iOS showing a D2 boxscore.

So that’s Scores for NCAA. It has some other nifty features, but you can download it for free and check them out.

But wait Jordan, how does this compare to Apple Sports?!

Great question, and I’ll just copy and paste what I have in my press kit:

Apple Sports is wonderful and I use it daily, but it has a few things missing I really wanted:

  • The biggest one? It only covers Division 1 sports. It does not cover Division 2 or 3, which Scores for NCAA does. In fact, as far as I am aware, it is the only app on the App Store which does.
  • It also only shows scores/games for yesterday, today and “upcoming” — Scores for NCAA can you show games from any date. Three years ago. Today. Tomorrow. Whatever.
  • It doesn’t have light and dark mode, which is a small nitpick but still bothers me.
  • And it’s only on iPhone. Scores for NCAA will eventually be on iPad and Mac.

Go check it out

There is so much more to add. Obvious stuff, too. But I had to cut it here for version 1, otherwise I would’ve kept at it and the current winter sporting seasons would’ve come and gone. I think there’s enough value here today for college sports fans to enjoy it.

All of this is just twenty bucks a year, while viewing all of today’s action being free. Today, it shows the major winter NCAA sports (basketball and hockey), but I’ll certainly be adding other sports as they arrive in season. And yes, football being the primary one - I just couldn’t get it in for the initial release.

Until next time ✌️

···