[SC]()

iOS. Apple. Indies. Plus Things.

The Big Feature

// Written by Jordan Morgan // Dec 15th, 2024 // 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.

In June of 2020, I wrote about my first major update to Spend Stack. It’s a fond indie memory! After meticulously preparing the update, press kit and everything else we all do, I woke up and…

  • Saw it shoot to #1 in the Finance category
  • It had press coverage
  • Multiple App Store features
  • And, App of the Day

the works, man. I remember thinking, “It doesn’t get any better than this!” Though, reflecting on it now, my moonshot goal has always been to grow an app which could be a single source of income for my family. With a stay at home wife, three kids, health insurance…it’s a tall order.

Spend Stack made a few thousand bucks over that month, I think, the most for me at the time. While I look back at it fondly, it never came close to being anything other than some nice side income.

Fast forward to now, and things look different. Elite Hoops has had zero press coverage and no App Store features and yet…it’s made that same amount over the last three or four days.

I don’t mean to puff out my chest (I know several devs making much, much more — I know several devs making much, much less). What I do mean to say is, I think I’ve arrived at a place where my experience is outpacing my previous “indie” desires. Instead of chasing a new API, I spend a lot of time these days pouring through feedback, wondering about my app’s ability to grow, and doubling down on the things that are working.

Which is where the aforementioned “big feature” launch came from. After a bunch of research, talking to coaches, and looking at my own data — I realized I was in a wonderful position to deliver a compelling practice planner experience. I wrote about it recently. I don’t want to rehash too much other than to say, it’s gone great! It’s only been over a week, but all metrics are up. Conversions. Trial starts. All of it.

Usually, I preach that growth is found outside of Xcode. But, maybe, just maybe, there’s that one killer feature that could drive up all metrics for you. The trick is to know what that feature is, because historically — we tend to think all of them are.

Postscript

Originally I wanted to do an in-depth, shareable, knowledgeable-laden postmortem that anyone could dissect at their leisure to learn from. Apply it to their own work. Share all I could. Though, the year is coming to a close, and, well — I’m pretty tired. I’m ready to sit on a couch and play Call of Duty (Nuketown 24/7 playlist is live!) once my Christmas break kicks in.

What I do hope you get from this, though, is that if you are in a spot where things are stale, you’re in a rut, not really making any money, downloads are down — you name it, I want you to know that I’ve been there for several years. And while I’m not at my goal of Elite Hoops being a sole source of income, I’m encouraged that it’s growing. It took so many years, but for the first time it feels possible, like I could actually do this.

And sometimes, all it takes is about ten years of it not working. Then one day, it just kinda does.

With that, I also want to say I’m truly thankful for all of you who have stuck around and read this silly website. I used to think of myself as the new kid on the block, writing for whoever would care to read. Now, I suppose I’ve been around a bit. I still enjoy all of this just as much as I did when I picked up this lil guy.

Have a wonderful rest of 2024, and I’ll see you all next year.

Until next time ✌️

···

Eight Fun SwiftUI Details in my Practice Planner

// Written by Jordan Morgan // Nov 22nd, 2024 // Read it in about 7 minutes // RE: The Indie Dev Diaries

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

For the last few months, I’ve been cracking away at a massive feature for Elite Hoops, a practice planner. At midnight a few days ago, I stealth shipped it to the App Store. While I have a mountain of marketing to go through still (social media assets, blog posts, email, you know the dance) I figured if it’s already approved, might as well ship it.

The feature turned out great, and while I try to pour love into everything I make, sometimes there’s just that thing where you step back and say, “Huh. I really kinda love this.” And that’s how this went for me.

From all of these scattered thoughts…

Notes in Free Form for my practice planner.

…I arrived here:

Elite Hoops practice planner running on iOS.

Here are eight little SwiftUI nuggets from the feature that I think turned out to be something worth sharing.

Photo slider
Directly inspired by Photos’ implementation. The clipping of the text was tricky to figure out at first, but animating the pill using .matchedGeometryEffect made the rest trivial:

Elite Hoops practice planner running on iOS.

Gradient header
Initially, the practice details screen was a little stale. Since each drill category has a color associated to it, I decided to incorporate a subtle MeshGradient at the top. The colors being derived from each category added a little pop (the extended font width also works great here, emphasizing the competitive nature of the feature):

Elite Hoops practice planner running on iOS.

Story pills
When running a practice from your phone, you cycle through drills which each have a time associated to them. I ended up using an interface inspired by workout apps paired with Instagram’s now famous “story pills” at the top, which fill up as the drill progresses. Here, I manually skipped the drill to demonstrate how it works:

Elite Hoops practice planner running on iOS.

Shortcuts sheet
Creating a practice has a near identical structure as Shortcuts, where actions are drills and the practice itself is an aggregation of them. As such, the retractable drawer made sense to replicate, and save for a few differences (primarily, the sheet doesn’t dismiss when you add a drill) it works much the same way:

Elite Hoops practice planner running on iOS.

Printing
From a technical aspect, printing was a difficult problem to solve. It involved a mix of ImageRenderer, .pdf generation and measuring the height of each element to support pagination. It was time well spent, as I quickly discovered a vocal minority of coaches who had no interest in running a practice from their phone1 due to a variety of reasons - they want it printed out:

Elite Hoops practice planner running on iOS.

Interpolation countdown
When starting a practice, I quickly noticed it was too jarring to have it begin immediately after the view was presented. To ease coaches in, or let them cancel it, I added a quick countdown experience. An increasingly firm haptic matches each digit to convey a sense of urgency:

Elite Hoops practice planner running on iOS.

Blur out for finished practice
Finishing a practice is an accomplishment, akin to finishing a workout or a run. As such, I wanted a quick interaction that created that sort of “atta boy” vibe. I ended up reusing much of the countdown view’s code above to make a little wrap up view:

Elite Hoops practice planner running on iOS.

Symbol animation for buttons
Finally, incorporating symbol animations feels so incredibly right to me. There are a few of them which occur in the buttons near the bottom of a practice:

Elite Hoops practice planner running on iOS.

Wrapping Up

The practice planner is something I’m proud to put my name on. It’s one of the first truly innovative things I’ve made — as planning basketball practices has traditionally been somewhat of a dross affair. It hasn’t really changed much or rethought in terms of, “With all we have available to us today, how could we make all of this easier, better and help coaches more?”

Perhaps more importantly, we live in an age where inspiration strikes all the time across social media, with talented trainers constantly posting incredibly useful drills. There hasn’t been an ideal place to save them all, and information becomes scattered. The drill library aims to solve that, too. Like lego building blocks, over time — coaches will build up a drill library, and use those pieces to form their ideal practices.

Feel free to download Elite Hoops today, even if you aren’t intent on using it, if even to simply play around with the feature. Most of it isn’t paywalled, so it’s no issue to poke around. Finally, huge thanks to the developers and designers at Apple who created first-class user experiences in Photos (an app I’ve long championed) and Shortcuts. Rebuilding similar flows from both of them not only increased my appreciation for their solutions (as I hit similar user experience dilemmas, and realized how their approaches made so much sense), but they also helped me chip away at the whole feature quite quickly.

Until next time ✌️

  1. In fact, they downright despised it. They felt as though it conveyed a sense of distraction to their players, as if they weren’t focused on them or the practice. 

···

Elite Hoops Year One: 12 Bite-Sized Lessons

// Written by Jordan Morgan // Oct 8th, 2024 // Read it in about 6 minutes // RE: The Indie Dev Diaries

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

Elite Hoops has hit the one year mark! I had a few buddies ask me some questions around getting those first paying customers, the kinds of marketing I’ve tried and other similar things.

In short, indies talkin’ shop. So, here is a point-by-point brain dump of what I think the twelve most helpful things I’ve learned or tried are. I’ll try to keep each one around a few sentences to keep it to the point. But, you know, IChat(.aLot).

In you just want the TL;DR on the #numbies, here’s that:

  • 860 paying customers
  • $3,000 MRR

Cool, on to the good stuff.

3 Tips on Getting the First 100 Paying Users

On getting to that 100 paying user mark, which was my first goal.

1: Ask for Emails in Exchange for ✨Value✨
Setup an email capture component. A form, use an API, whatever. This is a fantastic way to let people know of large updates, push sales and reactivate folks. I use Mailerlite, and it’s okay, but adding an option to join the mailing list during onboarding to learn about plays, free stuff and more means that a little over 30% of people join it.

let concatenatedThoughts = """

Also, use the right text content types on your sign up fields so iOS will offer to autofill their email.

"""

2: Scaling with Paid Ads
I spend $30 in Meta ads every single day1. It took about three months, but I found a winning creative and audience after experimenting. I think a lot of indies either say paid acquisition is “lighting money on fire” or they take too much pride in touting “organic growth” at the expense of, well — more growth. You don’t have to spend a ton of money, you can get meaningful results with a little money.

Here is an example of the ad I run most. I make all of the creatives in Sketch, too:

Sketch for paid ad creatives

3: Side-Side Project Marketing
I love what I call “side-side project” marketing. Give a useful tool away for free. Get backlinks to it. Spread awareness. That’s exactly what I did with my Youth Basketball Practice Planner, a little web app that creates practice plans that you can customize and share.

This means that:

  1. People use a good tool, who are my target customers, for free.
  2. They like it, and they share it because they have a reason too (i.e. “Check out this practice I came up with for this week”)
  3. Now, they download Elite Hoops to get more.
Image 1
Image 2
Image 3

3 Thoughts on Being a Big Boi App

Nuggets on how to treat an app like the business you may want to grow it to eventually.

4: Use Mixpanel
Analytics and tracking can be done “the righteous indie way” easily. You don’t need personal information - a random identifier that shows you how people use your app is critical, and you can do it all for free with Mixpanel.

This is how I know that half of my users are youth basketball coaches, which is why I built the tool above:

Mixpanel Dashboard

And the code for it is trivial:

struct Telemetry {
    static func initializeClient() {
        Mixpanel.initialize(token: "YOUR_TOKEN", trackAutomaticEvents: true)
    }
    
    static func createdDemoCourt() {
        guard !Device.current.isSimulator else { return }
        Mixpanel.mainInstance().track(event: "Demo Court Created")
    }

    // etc etc....
}

5: Use Plausible
Along the same lines, Plausible is wonderful for privacy-based web analytics. Once I double-down on some SEO plays, this is how I will know if what I’m trying is working.

6: Use Supabase for In-App Feedback
Slot in Supabase for easy feature requests. Again, they have a very nice Swift package (an indie started it, and they hired him on full time) and it takes minutes to get going. I wrote a post on how to get this up and running here.

I can’t tell you how valuable the stuff in here is:

Supabase dashboard with feedback

3 Indie Mindset Vitamin Shots

On ways to motivate your mindset in the indie world.

7: Think Small, Achievable Chunks
If I thought to myself, “I want to get to over 800 paying customers in a year” I probably would’ve stressed out. But, by breaking down that goal to mini ones, it was much more doable, “Get 100”, “Okay, maybe we can crack 200 in a few months”, etc.

8: Bake In Marketing
Indies don’t market enough. And, we can’t really learn much from the “winners” who had something go viral for whatever reason because that’s unlikely to happen to you or me. So, spend a whole day of your indie work on marketing.

I have this reminder weekly, #ItAintMuchButItsSomething:

Marketing tasks.

The above is the bare minimum, but while I’m in build mode (more on that below) it’s still going to help spread the word.

9: …And Figure Out If You’re in Build Mode or Marketing Mode
Are you building core functionality that’s going to get you to the next step? If you’re not, then you should really spend a lot more effort on marketing.

All of these stories going around about the Gen Z folks making millions on the App Store? They market so well. You can build an amazing thing that nobody knows about, and at the end of the day it will still be an amazing thing that nobody knows about.

3 Overall Lessons

Stuff I’ve picked up along the way.

10: Wow, Copywriting Matters
This is an area I’ve tried to grow and learn more about over the last year. And yeah, chatGPT really shines here for me. Consider selling the original iPod:

“100 GB of storage.” or “1,000s of songs in your pocket.”

One is a technical fact, the other tells a story. I’m prone to spitting out technical facts and not telling a basketball coach why something is great. So, often I’ll do a prompt this like: “I’m working on X feature, it does Y. My attempt at marketing copy was Z. Assume you are an expert in marketing in the basketball and tech space, how would you write about this feature?”

11: Staying Focused is the Hardest Part for Me
You’ll just see story after story after story about how some app, tool or gold rush is happening — and how much money is in it. And, while there is definitely value in jumping on the moment, I find that for me it’s super difficult to stay focused on Elite Hoops.

My MRR is peanuts to a lot of people, and a quite a bit to others. There’s always a spectrum, so I’ve opted to not really chase other opportunities and stay focused on growing Elite Hoops.

12: Have a Roadmap!
Seriously, it sounds obvious but I used to be bad at this! Now, I have a clear picture of where I want to go, and how I will get there. I know I’m in the aforementioned “build” mode because of the feedback I’ve received from paying users. Once I have that in, I’ll shift more towards marketing mode (but, again, don’t stop doing marketing ever!).

Wrapping Up

And that’s it! I hope this time next year, Elite Hoops has continued to grow and I’ll have more (hopefully helpful) things to share. Let’s build, baby!

Until next time ✌️

  1. And, I don’t even use their SDK. You can run install ads without it, you just won’t get accurate results in terms of being able to say ‘This install was definitely from Facebook or Instagram’, but I have enough data to have a good idea of what I get from it. 

···

Using PreviewModifier for Quick Xcode Previews

// Written by Jordan Morgan // Sep 30th, 2024 // Read it in about 2 minutes // RE: SwiftUI

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

Ah, Xcode Previews. The absolute best, worst thing Apple has ever shipped for developers. When it works, which for me is around 90% of the time, it’s absolutely essential to my workflow.

Other times? It crashes, spins, indexes and loads for an eternity — and I end up sitting there and waiting for it to work again out of nothing else but pure, unadulterated spite. And, as I launch my fusillade of negative thoughts and words towards Xcode, I couldn’t help but wonder if the problem was me.

Foolishly, I was passing in an expensive object around my previews, a holdover of a //TODO: Don't do this I had while prototyping stuff. In the process, though, I came to love having actual data from the server that wasn’t mocked. This was the good stuff, along with the real-world connectivity loading times.

In short, I wanted my cake. And I wanted to eat it, too.

Recently, I found a tip that’s helped out with the long loads like this, PreviewModifier (docs). It helps make complex, heavy-load type of objects more performant to use across previews by basically doing all the expensive work once.

Sharing is Caring

Consider the purpose of Observable - it’s literally to reuse and share the same chunk of data, managers, data access objects, networking layers — you name it — across your codebase:

@Observable
class DAO {
    var accountData: AccountData = .empty 
    init() {
        veryExpensiveSetup()
    }

    func veryExpensiveSetup() { 
        // Hit API, or DB, or both...
        accountData = fetchedData
    }
}


@main
struct TheBestApp: App {
    // Expensive work happens here
    @State private var dataAccess = DAO()


    var body: some Scene {
        WindowGroup {
            MyViewHierarchy()
                // But at least the hit happens only once
                .environment(dataAccess) 
        }
    }
}

And so it goes, dataAccess can do the time-consuming stuff with an upfront cost, and let the rest of the app use it without incurring the same hit:

struct MyViewHierarchy: View {
    @Environment(DAO.self) private var dataAccess


    var body: some View {
        Form {
            // Everything has access to dataAccess
            Text(dataAccess.accountData.name)
            UserAccountView()
            NotificationsView()
            LegalView()
        }
    }
}

This is SwiftUI 101 stuff. So, given the wins we have here — why don’t we extend the same logic to Xcode Previews? Instead of doing something like this:

#Preview {
    MyViewHierarchy()
        // Snapple! We're running `init()` every
        // time this thing reloads!
        .environment(DAO())
}

…or this1:

extension DAO {
    var mock: DAO { // Setup mocked DAO... }
}

extension AccountData {
    var mock: AcccountData { // Setup mocked account data... }
}

…maybe you want the real, bonafide data that comes with actually hitting your API, or setting up your data layer. But, without doing that over and over.

Caching Preview Objects

It turns out, you can. And I had no idea this was possible until a few days ago. It’s eerily similar to how ViewModifier is handled. Here’s what it looks like:

// Adopt `PreviewModifier`
struct CachedPreviewData: PreviewModifier {

    static func makeSharedContext() async throws -> DAO {
        let dao = DAO()
        await dao.makeSomeOtherNetworkCalls()
        await dao.noSeriouslyGoCrazyWithIt()

        // Because we're only doing this once...
        return dao
    }


    // And now it's reused
    func body(content: Content, context: DAO) -> some View {
        content
            .environment(context)
    }
}


// Add the modifier to the preview.
#Preview(traits: .modifier(CachedPreviewData())) {
    // Now this, or any other preview using the same setup
    // Has access to `DAO`, and it only ran `init()` once
    MyViewHierarchy()
}

When you do this, Xcode Previews will cache the expensive object, and then refer to it over and over. Now, you’re free to have real data, in real previews, without it being a real %!@*&%$ pain in the @$!.

And that’s it! Print out some logs, and you’ll see that crazy work only happens once. Plus, you can get as creative as you want here - again, it works just like ViewModifier does. So, pass in some flags or data in the initializer, make a few of them, house core logic in only one — it all works:

struct CachedPreviewData: PreviewModifier {
    // Add in a flag to access different stuff
    enum UserRole: Int { case user, admin, temp }
    var role: UserRole 

    // Same code as we had before that uses that enum
}

#Preview(traits: .modifier(CachedPreviewData(role: .admin))) {
    // Admin role will be used
    MyViewHierarchy()
}

Until next time ✌️

  1. Which, by the way, is not a bad approach at all. I use this method all the time. But there are situations where you need to hit the actual server for a variety of testing reasons. 

···

Recreating Readable Content Guide Sizing in SwiftUI

// Written by Jordan Morgan // Sep 15th, 2024 // Read it in about 4 minutes // RE: SwiftUI

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

The readableContentGuide(docs) API was (and still is) such a quintessential quality of life fix for UIKit developers. With the rise of #huge iPad devices emerging with iOS 9, the need to reasonably size views primarily meant for reading became important. As such, Cupertino & Friends© gifted us a straightforward way to do just that:

private func setupTableView() {
    tableView = UITableView(frame: .zero, style: .grouped)
    tableView.dataSource = self
    tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
    tableView.translatesAutoresizingMaskIntoConstraints = false
    
    view.addSubview(tableView)
    
    // The magic...
    NSLayoutConstraint.activate([
        tableView.topAnchor.constraint(equalTo: view.readableContentGuide.topAnchor),
        // Readable content guide leading anchor...
        tableView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor),
        // Readable content guide trailing anchor...
        tableView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor),
        tableView.bottomAnchor.constraint(equalTo: view.readableContentGuide.bottomAnchor)
    ])
}

The results were markedly better. Here, the first image applies the readable content guide’s width, and the latter does not:

Image 1
Image 2

True to the docs, the resulting view is objectively easier to read:

This layout guide defines an area that can easily be read without forcing users to move their head to track the lines.

But, because life isn’t fair, we don’t have this in SwiftUI. It’s trivial to recreate, though (or at least get close) in landscape orientations. Here’s how I do it, driven by using .containerRelativeFrame:

struct ReadableContentBridge: View {
    @Environment(\.horizontalSizeClass) private var hClass
    @Environment(\.verticalSizeClass) private var vClass
    
    var body: some View {
        ZStack {
            Color(uiColor: .systemGroupedBackground)
                .edgesIgnoringSafeArea(.all)
            Form {
                ForEach(0...10, id: \.self) { _ in
                    Text("Stuff and things")
                }
            }
            // Right here
            .containerRelativeFrame([.horizontal]) { length, axis in
                guard axis == .horizontal else { return length }
                if vClass == .regular && hClass == .regular {
                    return length * 0.52
                } else {
                    return length
                }
            }
        }
    }
}

The idea is simple: If we’re in a horizontally and vertically regular size class, then set the width to about 52% of the container’s width. And, that gets us strikingly close to the actual readableContentGuide output (ignore the vertical padding, the width is what to key in on here):


Image 1
Image 2
UIKit version
SwiftUI version


By the book, readableContentGuide follows a few rules to calculate its layout:

  1. The readable content guide never extends beyond the view’s layout margin guide.
  2. The readable content guide is vertically centered inside the layout margin guide.
  3. The readable content guide’s width is equal to or less than the readable width defined for the current dynamic text size.

let concatenatedThoughts = """

Though I should say, this doesn't quite match up for portrait orientations. I would need a bit more logic to make it work there. For example, in the code I have, I would need to check if we're in portrait or landscape orientation and then adjust accordingly. For my use case, though, I just didn't need it.

"""

By my count, I think my approach should satisfy all but rule #3 accurately, but for most cases — I imagine it would still be close. For your convenience, here’s a custom modifier to do it all for you:

struct ReadableContentModifier: ViewModifier {
    @Environment(\.horizontalSizeClass) private var hClass
    @Environment(\.verticalSizeClass) private var vClass
    
    func body(content: Content) -> some View {
        content
            .containerRelativeFrame([.horizontal]) { length, axis in
                guard axis == .horizontal else { return length }
                if vClass == .regular && hClass == .regular {
                    return length * 0.52
                } else {
                    return length
                }
            }
    }
}

extension View {
    func readableContentGuide() -> some View {
        modifier(ReadableContentModifier())
    }
}

// Then, in your view
struct Example: View {
    var body: some View {
        Form {
            // Content
        }
        .readableContentGuide()
    }
}

Until next time ✌️

···