[SC]()

iOS. Apple. Indies. Plus Things.

Asking AI to Refactor Swift Code

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

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

Recently I’ve been adding quality-of-life platform fixes to Elite Hoops. Among those? Home Screen Quick Actions.

It’s been a minute since I’ve created dynamic ones, so I forgot how much bookkeeping can be involved. I wanted a dynamic quick action for each recently viewed team in Elite Hoops. But, that also means that…

  • I need to add it to UIApplication.shared.shortcutItems when one is opened.
  • And update it when the name of a team could be changed.
  • And remove it when it’s been deleted.
  • And write it in such a way to where when I add cloud syncing, it’ll stay in lockstep with any changes from the server.

So, one of those hard easy things.

let concatenatedThoughts = """

In fact, the last time I wrote Home Scren Quick Actions was for the chapter in my book series. Which, as it were, I leaned upon heavily to implement these. I'm in the middle of releaseing an update for it now, do check it out if you haven't yet. Five books, one price forever.

"""

To that end, here is the function I initially wrote:

// This function is kind of hard to read - can you make it simpler?  

func updateQuickActionsForCourts() {
    if appSettings.quickActionCourtsToAdd.isEmpty &&
       appSettings.quickActionCourtsToRemove.isEmpty {
        return
    }
    
    var quickActions: [UIApplicationShortcutItem] = UIApplication.shared.shortcutItems ?? []
    
    // Any removals?
    var removalIndices: [Int] = []
    appSettings.quickActionCourtsToRemove.forEach { courtIDToRemove in
        for (index, item) in quickActions.enumerated() {
            let courtID = item.userInfo?["courtID"] as? Int64 ?? 0
            if courtIDToRemove == courtID {
                removalIndices.append(index)
            }
        }
    }
    
    removalIndices.forEach { removalIndex in
        quickActions.remove(at: removalIndex)
    }
    
    // Any additions?
    appSettings.quickActionCourtsToAdd.forEach { court in
        if quickActions.filter({ item in
            let courtID = item.userInfo?["courtID"] as? Int64 ?? 0
            return courtID == court.id
        }).isEmpty {
            quickActions.append(court.quickAction())
        }
    }
    
    UIApplication.shared.shortcutItems = quickActions.reversed()
    
    appSettings.quickActionCourtsToAdd.removeAll()
    appSettings.quickActionCourtsToRemove.removeAll()
}

It’s ugly, but it works. So, I began to wonder — how would AI tools suggest I change it. Below are the results, all given the same prompt.

The Prompt

// This function is kind of hard to read - can you make it simpler?  

/* Then I included the function above right here */

chatGPT 4

First off, the institutional choice. OpenAI’s chatGPT using chatGPT 4:

/*
Certainly! Here's a simplified and refactored version of your Swift function. I've added comments for clarity and improved the readability of the code by separating the removal and addition processes into separate functions:
*/

func updateQuickActionsForCourts() {
    guard !appSettings.quickActionCourtsToAdd.isEmpty || !appSettings.quickActionCourtsToRemove.isEmpty else {
        return
    }
    
    var quickActions = UIApplication.shared.shortcutItems ?? []
    
    // Process removals
    quickActions = removeCourts(from: quickActions)
    
    // Process additions
    quickActions = addCourts(to: quickActions)
    
    // Update quick actions in reverse order and clear settings
    UIApplication.shared.shortcutItems = quickActions.reversed()
    clearCourtSettings()
}

/// Removes courts from the quick actions list based on app settings.
private func removeCourts(from quickActions: [UIApplicationShortcutItem]) -> [UIApplicationShortcutItem] {
    let courtIDsToRemove = Set(appSettings.quickActionCourtsToRemove)
    return quickActions.filter { item in
        guard let courtID = item.userInfo?["courtID"] as? Int64 else { return true }
        return !courtIDsToRemove.contains(courtID)
    }
}

/// Adds courts to the quick actions list based on app settings.
private func addCourts(to quickActions: [UIApplicationShortcutItem]) -> [UIApplicationShortcutItem] {
    var updatedQuickActions = quickActions
    appSettings.quickActionCourtsToAdd.forEach { court in
        let exists = updatedQuickActions.contains { item in
            let courtID = item.userInfo?["courtID"] as? Int64 ?? 0
            return courtID == court.id
        }
        if !exists {
            updatedQuickActions.append(court.quickAction())
        }
    }
    return updatedQuickActions
}

/// Clears the lists of courts to add or remove from app settings.
private func clearCourtSettings() {
    appSettings.quickActionCourtsToAdd.removeAll()
    appSettings.quickActionCourtsToRemove.removeAll()
}

/*
Explanation of Changes:
Guard Statement: A guard statement at the start of the function checks whether any updates are needed, improving readability by reducing nesting.

Separation of Concerns: I extracted the logic for removing and adding courts into separate functions, removeCourts and addCourts. This makes the main function more concise and each piece of functionality easier to maintain.

Filter and Contains: The removal function uses filter and contains to efficiently remove items. This eliminates the need for finding indices and then removing by index, which can introduce errors or require additional adjustments in indexing after each removal.
Function Encapsulation: Moving specific functionality into private helper functions clarifies the main function's flow and isolates each specific task.

Reverse Logic Consolidation: The reversal of the quick actions list is kept at the point where the list is updated in the UIApplication, making the order manipulation more visible.

This refactor should make your function easier to read, maintain, and modify in the future.
*/

I’ve found that chatGPT is more verbose in its response. Not only does it write code, but it tries to explain it to you. This code is nice if you’re the type of who believes each function should be a single thing, and not much more. Of all the choices, when I go back and pretty up my code (because we always go back, right?!) — this is probably the closet to how I tend to author it.

meta.ai

The new kid on the block, personifying the “if it’s free you are the product” approach is Meta’s newly minted meta.ai. Here’s how it refactored my code:

// Here's a simplified version of the function:

func updateQuickActionsForCourts() {
    guard !(appSettings.quickActionCourtsToAdd.isEmpty && appSettings.quickActionCourtsToRemove.isEmpty) else {
        return
    }

    var quickActions = UIApplication.shared.shortcutItems ?? []

    // Remove quick actions
    quickActions.removeAll { item in
        appSettings.quickActionCourtsToRemove.contains(item.userInfo?["courtID"] as? Int64 ?? 0)
    }

    // Add quick actions
    appSettings.quickActionCourtsToAdd.forEach { court in
        if !quickActions.contains(where: { item in
            item.userInfo?["courtID"] as? Int64 == court.id
        }) {
            quickActions.append(court.quickAction())
        }
    }

    UIApplication.shared.shortcutItems = quickActions.reversed()

    appSettings.quickActionCourtsToAdd.removeAll()
    appSettings.quickActionCourtsToRemove.removeAll()
}

/*
This version uses more concise and expressive syntax, such as guard statements, removeAll and contains methods, and more. It should be easier to read and understand. Let me know if you have any questions!
*/

What I like about this is how much simpler it is to read. Even though I most write code like chatGPT 4, I think I can read Meta’s take on it easier.

Claude by Anthropic

Finally, there’s Claude. I consider it the last of the “big three”, at least in my sphere of influence. I should note, this is not Anthropic’s best model — that’s Claude 3 Opus, but anyways here is what I got:

// Here's a simplified version of the updateQuickActionsForCourts function:

func updateQuickActionsForCourts() {
    guard !appSettings.quickActionCourtsToAdd.isEmpty || !appSettings.quickActionCourtsToRemove.isEmpty else {
        return
    }

    var quickActions = UIApplication.shared.shortcutItems ?? []

    // Remove unwanted quick actions
    quickActions = quickActions.filter { action in
        guard let courtID = action.userInfo?["courtID"] as? Int64 else {
            return true
        }
        return !appSettings.quickActionCourtsToRemove.contains(courtID)
    }

    // Add new quick actions
    let existingCourtIDs = quickActions.compactMap { $0.userInfo?["courtID"] as? Int64 }
    let newQuickActions = appSettings.quickActionCourtsToAdd
        .filter { !existingCourtIDs.contains($0.id) }
        .map { $0.quickAction() }
    quickActions.append(contentsOf: newQuickActions)

    UIApplication.shared.shortcutItems = quickActions
    appSettings.quickActionCourtsToAdd.removeAll()
    appSettings.quickActionCourtsToRemove.removeAll()
}

/*
Here are the main changes:

- The early return is moved to the beginning of the function to avoid unnecessary work if there are no actions to add or remove.

- The removal of unwanted quick actions is done using the filter method, which creates a new array containing only the elements that satisfy the condition.

- The addition of new quick actions is done by first creating an array of existing court IDs, then filtering the appSettings.quickActionCourtsToAdd array to remove any courts that already have a quick action, and finally mapping the remaining courts to their corresponding quick actions.

- The append(contentsOf:) method is used to add the new quick actions to the existing array in a single operation.

- The reversed() call has been removed since it is unnecessary.

These changes make the code more concise and easier to read, while preserving the original functionality.
*/

Not bad, like chatGPT 4, it tends to explain in detail what its changed are, and why it made them. And, like Meta, it’s solution is still within one function, but greatly simplified.

Final Thoughts

I offer no verdict in this post other than…it’s interesting to see how large language models reason about code. All three models quickly pointed out an early guard statement, for example. And, each one tends to lean into language features, such as Swift’s functional aspects. As it should, I presume.

So where did I land?

I just left my original, ugly function in and wrote this post 😇.

Until next time ✌️.

···

Introducing Elite Soccer Club

// Written by Jordan Morgan // Apr 10th, 2024 // Read it in about 5 minutes // RE: The Indie Dev Diaries

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

Well, here goes another one — Elite Soccer Club is officially live in the App Store. Please do check it out here!

A screenshot of Elite Soccer Club's running on several devices

The elevator pitch? Elite Soccer (…or, football 😉) Club is an easy way to…

  1. Share lineups with a visually rich graphic [free].
  2. Explain formations or concepts using a traditional whiteboard [free].
  3. And finally, you can record videos with audio commentary to share with your players, staff, parents - whoever [requires pro].

There are some nice little features in there too, like team size, whiteboard customization and more.

Instead of going into the story behind things, I thought it would be interesting to touch on the indie dev aspects of this one. In a recent post, I talked about basing app releases on a particular theme. This one is “Test a hypothesis”. And, while that may be Elite Soccer Club’s raison d’être, I still have other motivations — namely, shipping stuff is just incredibly fun.

But anyways, my theory? Soccer is a global sport, the most popular one on the planet. If I can ship an MVP to help soccer coaches, and later internationalize it — maybe there’s something there?

Luckily, I had a massive head start - Elite Hoop’s codebase. You know the jokes in game development where, when a game is similar to a previous entry, ignorant observers simply say it was “copy and pasted”?

Yeah, well this time, I literally did that…

The Great Refactor

…and it ended up being a lot of work. Luckily, this post was a phenomenal read for the niche crowd of iOS developers who need to wholesale rename a project. By copying and renaming Elite Hoop’s project, I had a really good foundation. Even so, I had hoped this project would take weeks, but it took months.

So much of what Elite Hoops has either isn’t relevant to soccer (i.e. zone overlays, SLOB/BLOB set mode, etc), didn’t make sense to keep or simply was focused on an entirely different sport — basketball.

Keeping all of this in the same app was something I considered, but after going through this, I am so glad I didn’t go that route. There’s too much that’s different between them, and so much room for each to grow - an app for each of them makes perfect sense from a business and maintainability standpoint. There are some things that make sense for both apps, there are many things that are unique to each.

No matter, the result of starting with an existing project to retool? A metric ton of refactoring classes, structs and data access models to reflect soccer. For example, there’s code like this in Elite Hoops:

func addPlayer() {
    if team.roster.count >= Avatar.MaxPlayerCount {
        // Add to bench
    } else {
        // Add to court
    }
}

But in Elite Soccer Club, team size is variable. A few friends told me separately that the ability to use different sized teams was important, especially in the lens of youth soccer.

A screenshot of Elite Soccer Club's team editor showing different team size options

So, a hard coded team size value no longer made sense. Instead, it’s closer to something like this:

func addPlayer() {
    if team.roster.count >= team.activePlayerChoice.rawValue {
        // Add to bench
    } else {
        // Add to pitch
    }
}

There were many, small little situations like this — resulting in the development cycle going on longer than I had originally thought. What else is new? It’s like getting a house, but completely remodeling it.

Another tricky one? Positions. In basketball, you generally have a few guards, a forward or two and maybe a big. Reading that sentence back makes sense to those who have played ball their whole life. But, I have a newfound sense of humility to those who may be new to the game.

Why? Because if you read point guard, shooting guard, small forward, power forward and center — you may not know the relation to those things and someone saying “Yeah, he’s a guard”, or “She plays the 4 or 5” but coaches talk like this all the time.

That made little ol’ me trying to figure out soccer positions quite a task. As far as I could make sense of it, soccer positions could have…sub positions?…for lack of a better term. So if you picked defender…you could just be a defender. Or, maybe you wanted to be a centerback. And so on.

One U.X. implication of that meant tapping on one position could reveal several others: A screenshot of Elite Soccer Club's position picker

As such, I had to change the whole data model around players and positions to support that:

static func subPositionsFor(primaryPosition: Avatar.Position) -> [SubPosition] {
    switch primaryPosition {
    case .unset:
        return []
    case .goalKeeper:
        return []
    case .defender:
        return [.centerBack, .fullBack, .wingBack]
    case .midFielder:
        return [.defensiveMidfielder, .centralMidfielder, .wideMidfielder, .attackingMidfielder]
    case .forward:
        return [.wing, .striker]
    }
}

There are one million examples of this kind of thing that I ran into, but you get the idea. Copy and pasting things means a long road of tiny to medium sized headaches, whereas creating a #BrandNewThing is a massive adventure all its own. I’ve done both, but I’ll always reuse my work where I can and where it makes sense. And this? This was the perfect time to do just that.

However, there are positives to this too. I’ve come across some novel quality of life tweaks I’d love to take back to Elite Hoops. Of those, editing player appearance labels (i.e. name, position, shortened position, number) is nice — and I came up with a grid layout system to “pretty place” players initially. Instead of a scrambled mess of players placed randomly all over the pitch when you first open a team, they are uniformly placed in spots that make sense.

My Soccer/Football/Futbol Diliemma

Originally, this app was called Elite Football Club to cater towards the world at large. Plus, I did enjoy the E.F.C. acronym. In fact, the URL reflects as much: https://www.elitefootballclub.com - but I ended up discovering how non-trivial it would be to get the name of the sport correct.

To wit: An illustration of what the world calls soccer across different locales.

Long story short? It’s on the list. I’d love to call the sport by the correct name. Plus, other things like does this locale call the coach a coach, or is manager more common? That sort of stuff is over my head right now. But, if I was going to launch quickly — I simply had to stick within my comfort zone.

But that’s the thing, right? This app is also pretty far outside of it. I played soccer when I was a little dude for like one season when I was seven. I was the kid who sat on the grass when the soccer ball was not immediately within my vicinity. I mean, I probably stayed there even when it was.

Lipso facto, there is a lot I don’t know about this sport. Thankfully, so many friends gave me a hand. But, I know there’s still quite a bit I will get wrong. So this release is all about adapting quickly, and politely responding to the emails from more experienced coaches who will tell me X or Y makes no sense as implemented.

Website and Quick Wins

Regardless, speed was my goal here. As I’ve established, I already had a battle tested, sturdy foundation to build off of. So, in the spirit of shipping — here are three other ways I achieved a somewhat quick launch.

The Logo
I needed a logo fairly quick, so I simply worked with friend of mine who does graphic design work for me here and there. He was able to turn around a soccer-ized version of what I had for Elite Hoops within the day. Thanks, Kenny! A screenshot of Elite Soccer Club's logo versus Elite Hoop's logo.

The Website
I swear unto thee, I could stand up a website with my eyes closed, seared shut now until forever more and upside down and backwards. I’ve simply found my tech stack:

  1. Jekyll for static site generation.
  2. Github for source control.
  3. Netlify for deploys, hosting and domain registration.
  4. Tailwind CSS for everything else.

I simply push to main, and my website is updated. That’s how this blog is made, my book series website is done and everything else I’ve made on the interwebs.

As such, putting this puppy up took about two and a half hours from conception to production. It embodies the “it ain’t much, but it’s honest work” ethos: A screenshot of Elite Soccer Club's landing page.

The App Icon
Here’s another one I was worried about, but was able to push through. Essentially, I looked at the crest of several soccer club emblems for inspiration, added the laurel symbols converted into shapes, took a shield-like shape from Elite Hoop’s icon, tossed in a soccer vector that Kenny had made and….dashed in some gradients.

And, to me at least, that was good enough: A screenshot of Elite Soccer Club's logo in Sketch.

Superwall Dogfooding

Finally, a new app was a great time to dogfood Superwall. Not much to say here, other than I’m going to test paywalls so hard: A screenshot of Elite Soccer Club's Superwall dashboard.

Final Thoughts

Launch day.

Nothing beats it, I’m thankful to have another one today. I look forward to serving soccer/football/futbol/futebol/sokker/etc coaches of all levels.

Until next time ✌️.

···

Quick and Painless Persistency on iOS

// Written by Jordan Morgan // Mar 29th, 2024 // Read it in about 4 minutes // RE: Swift

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

When you need to save stuff quick and dirty, the vast world of iOS affords us several attractive options. Here’s a few that I’ve used over the years. None are novel, but every once in a blue moon I find that I have to copy and paste this kind of thing into a project — so here it is on Swiftjective-C for all to benefit from.

let concatenatedThoughts = """

Omitted from this list are saving sensitive data with Keychain Services and interacting with SQLite directly. Both are other options, but a bit more involved. This post evangelizes the super easy, "I just need to save something real quick!" type of situations.

"""

Codable

Use the magic and convenience of Codable to write .json data blobs to disk, and later deserialize them:

struct Person: Codable {
	let name: String
}

let people: [Person] = [.init(name: "Jordan") , 
						.init(name: "Jansyn")]

let saveURLCodable: URL = .documentsDirectory
    					  .appending(component: "people")
    					  .appendingPathExtension("json")

func saveWithCodable() {
    do {
        let peopleData = try JSONEncoder().encode(people)
        try peopleData.write(to: saveURLCodable)
        print("Saved.")
    } catch {
        print(error.localizedDescription)
    }
}
    
func loadWithCodable() {
    do {
        let peopleData = try Data(contentsOf: saveURLCodable)
        let people = try JSONDecoder().decode([Person].self, from: peopleData)
        print("Retrieved: \(people)")
    } catch {
        print(error.localizedDescription)
    }
}

Codable with @Observable

Of course, many of us are using SwiftUI. And, if we’re using SwiftUI — you’re likely also taking advantage of @Observable. If won’t work with Codable out of the box, but it’s trivial to fix.

@Observable
class Food: Codable, CustomStringConvertible {
    enum CodingKeys: String, CodingKey {
        case genre = "_genre"
    }
    
    let genre: String
    
    var description: String {
        genre
    }
    
    init(genre: String) {
        self.genre = genre
    }
}

let foods: [Food] = [.init(genre: "American"), 
                     .init(genre: "Italian")]

let saveURLCodableObservable: URL = .documentsDirectory
        						    .appending(component: "foods")
                                    .appendingPathExtension("json")

func saveWithCodableObservable() {
    do {
        let foodData = try JSONEncoder().encode(foods)
        try foodData.write(to: saveURLCodableObservable)
        print("Saved.")
    } catch {
        print(error.localizedDescription)
    }
}
    
func loadWithCodableObservable() {
    do {
        let foodData = try Data(contentsOf: saveURLCodableObservable)
        let foods: [Food] = try JSONDecoder().decode([Food].self, from: foodData)
        print("Retrieved: \(foods)")
    } catch {
        print(error.localizedDescription)
    }
}

If you’re curious about why we need to do this, check out Paul Hudson’s quick video explainer.

NSKeyedArchiver

If you’re dealing with objects, any holdover Objective-C code or simply are dipping around in your #NSWays, NSKeyedArchiver is a good choice.

class Job: NSObject, NSSecureCoding {
    static var supportsSecureCoding: Bool = true
    
    let name: String
    
    override var description: String {
        name
    }
    
    init(name: String) {
        self.name = name
    }
    
    func encode(with coder: NSCoder) {
        coder.encode(name, forKey: "name")
    }
    
    required init?(coder: NSCoder) {
        self.name = coder.decodeObject(of: NSString.self, forKey: "name") as? String ?? ""
    }
}

let jobs: [Job] = [.init(name: "Developer"), 
                   .init(name: "Designer")]

let saveURLKeyedrchiver: URL = .documentsDirectory
        					   .appending(component: "jobs")

func saveWithKeyedArchiver() {
    do {
        let jobsData: Data = try NSKeyedArchiver.archivedData(withRootObject: jobs,
                                                              requiringSecureCoding: true)
        try jobsData.write(to: saveURLKeyedrchiver)
        print("Saved.")
    } catch {
        print(error.localizedDescription)
    }
}

func loadWithKeyedArchiver() {
    do {
        let jobsData = try Data(contentsOf: saveURLKeyedrchiver)
        let decodedJobs: [Job] = try NSKeyedUnarchiver.unarchivedArrayOfObjects(ofClass: Job.self, 
        	                                                                    from: jobsData) ?? []
                                                                               
        print("Retrieved: \(decodedJobs)")
    } catch {
        print(error.localizedDescription)
    }
}

If you’re curious what the NSSecureCoding dance is all about, check this out.

UserDefaults

The easiest of them all. It can save off primitive types in a hurry, or even custom models using the NSKeyedArchiver route above (though that is not advised).

let names: [String] = ["Steve", "Woz"]

func saveWithUserDefaults() {
    let defaults = UserDefaults.standard
    defaults.set(names, forKey: "names")
    print("Saved.")
}
    
func loadWithUserDefaults() {
    let defaults = UserDefaults.standard
    if let names = defaults.object(forKey: "names") as? [String] {
        print("Retrieved: \(names)")
    } else {
        print("Unable to retrieve names.")
    }
}

Local .plist, .json or other file types

If you’ve got a local .plist, .json or other file type hanging around — you can simply decode those the same way you would any other data blob. Consider this cars.json file:

[
    {
        "make": "Toyota"
    },
    {
        "make": "Ford"
    },
    {
        "make": "Chevrolet"
    },
    {
        "make": "Honda"
    },
    {
        "make": "BMW"
    },
    {
        "make": "Mercedes-Benz"
    },
    {
        "make": "Volkswagen"
    },
    {
        "make": "Audi"
    },
    {
        "make": "Hyundai"
    },
    {
        "make": "Mazda"
    }
]
struct Car: Codable {
	let make: String 
}

func loadWithLocalJSON() {
    guard let carsFile = Bundle.main.url(forResource: "cars.json", withExtension: nil)
        else {
            fatalError("Unable to find local car file.")
    }

    do {
        let carsData = try Data(contentsOf: carsFile)
        let cars = try JSONDecoder().decode([Car].self, from: carsData)
        print("Retrieved: \(cars)")
    } catch {
        print(error.localizedDescription)
    }
}

AppStorage

Of course, true to SwiftUI’s “ahhh that’s so easy” mantra, try using its app storage attribute. Simply create a variable and mutate it like anything else, and it’ll be automatically persisted using UserDefaults under the hood:

struct AppSettings {
    @AppStorage("hasOnboarded") var hasOnboarded: Bool = false
}

Final Thoughts

Content warning: these techniques shouldn’t be used for your entire data graph. There are other, more sturdy ways, to amalgamate your data on disk which are more suited to the task. Though, in my first app, I saved everything in user defaults. In reality, only about a half megabyte is supposed to hang out in there. The more you know, right? For the most part, these APIs embody brevity being the soul of wit.

Until next time ✌️.

···

Basing Indie Releases in Term of Goals

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

As per usual, I set out for 2024 with goals aplenty. This should be not be surprising, coming from someone who wrote their own internal goal tracking app over Christmas break in 2022. Going indie is something I’d love to try at least once, so that’s what I base a lot of my work on. But as I began to think about what it would actually take to go indie, I realized I have quite the climb in front of me.

My wife stays at home, I have three kids and all of them are in sports (which, well, means 💸). As my good friend Mr.Selig put it, I am doing indie on “hard mode”. Oh, and let’s not forget how aFfOrDaBle American healthcare is (hint - it costs more than my mortgage). All of that to say, I’d need to be at around $20,000 MRR1 to even begin to start sniffin’ indie life.

let concatenatedThoughts = """

Is it possible? Absolutely. Is it easy to achieve? Obviously, it's not. My good friends at Revenue Cat even hinted as much in their annual report, which states that a large number of subscription apps don't even break $2,000 MRR.

"""

Everything I do has to be with a pointed focus, which is also one of several reasons behind me joining the team at Superwall — getting to see how people have done it before is very rewarding and fun. I learn a lot and enjoy the job. With all that said, I realized that even though I’m trying to build to $20,000 MRR — I do need to do it in a way I actually enjoy.

Choosing Themes

To that end, I’ve decided to build things that I love to work on, that could monetize, and each for their own specific reason. Everything has to help me in some way financially, but if my theme is to inch towards that $20k MRR mark, I decided that I should assign everything I work on to a specific goal supporting it.

Here are the four main themes I came up with:

  1. Build a business: My primary source of income at the beginning.
  2. Test a hypothesis: Testing things is important, so here I’d pick something I think could make money and see what happens.
  3. For the love of the game: Indie life means freedom in many ways, and I love expressing that through software. This project would make money, but likely not as much as the above projects. It will be where new iOS APIs go in, be the one “I love” — basically, it’ll be my Spend Stack all over again, but with more earning potential.
  4. Learn to embrace constraints: I’m not great at saying no (though I’ve gotten better), but I want to learn how to ship a bit faster, and refine things as I go. This project should embody that.

And so, I’ve got projects picked out for each of those themes:

Four app icons, and each has a corresponding goal next to it.

The first goal is being accomplished through Elite Hoops, and the second will be my upcoming soccer spin on it. The other two? Too early to share, but especially that third one - I’m excited about. I’ve tweeted once or twice about it. Aside from apps, I do have two other source of income:

  1. My book series: It still sells a few copies each week, and I’ll update it each W.W.D.C. - it’s nice side income, but I’m not really counting it much for my indie aspirations.
  2. Sponsorships: I sell sponsorships for this very site, and those have gone over and above my expectations. This year, I even did an annual deal with the folks at Emerge — and I’ve had three inquiries from companies looking for a similar deal in 2025.

So, I’ve got a bit of a head start. And hey - Elite Hoops is just about at $2,500 MRR. So, there’s my grand plan. At least four apps I’ll really love to work on, hopefully making money, doing it in different ways, all slowly (or quickly, that would be nice too) pushing me to $20,000 MRR.

Until next time ✌️.

  1. MRR means monthly recurring revenue, or the amount of money your subscription business makes when you take into account your subscriptions divided by 12 (roughly speaking - since there can be different subscription terms aside from annual ones). 

···

Fun Alignment Guide Tricks

// Written by Jordan Morgan // Feb 9th, 2024 // Read it in about 3 minutes // RE: SwiftUI

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

Setting your own vertical or horizontal alignment guide isn’t something I’ve thought about much when writing my own SwiftUI code. When they were announced, and later demo’d during a dub dub session in SwiftUI’s early days, I remember thinking, “Yeah, I don’t get that. Will check out later.”

Lately, though, I’ve seen two novel use cases where using one is exactly what was required. Or, at the very least, it solved a problem in a manageable way.

Rather than write out a bunch of code myself, I’ll get straight to it and show you the examples from other talented developers.

Creating the “Bottom Sheet”

Life, death and creating our own bottom sheet implementation, right? Sheet presentations have become more doable with the nascent iOS 16.4 APIs, allowing developers to set backgrounds and a corner radius on them. But, every now and then, you just have some weirdo requirement that necessitates rolling up your sleeves and doing it yourself.

The first inclination I’ve often seen is to use some sort of ZStack, .padding or .offset incantation:

@State private var bottomSheetOffset: CGFloat = 0.0

GeometryReader { geometry in
    VStack {
        Text("Sheet Content")
    }
    .frame(width: geometry.size.width, 
          height: geometry.size.height, 
          alignment: .top)
    .clipShape(
        UnevenRoundedRectangle(topLeadingRadius: 32, 
                               topTrailingRadius: 32, 
                               style: .continuous)
    )
    .frame(height: geometry.size.height, alignment: .bottom)
    .offset(y: bottomSheetOffset)
}

Or, maybe some sort of .transition:

@State private var showBottomSheet: Bool = false 

NavigationStack {
    // Content
    if showBottomSheet {
        VStack {
            Spacer()
            VStack(spacing: 18) {
                // Sheet content                
            }
            .padding([.leading, .trailing, .bottom])
            .background(.thickMaterial)
            .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
            .padding()
        }
        .safeAreaPadding([.bottom], 40)
        .transition(.move(edge: .bottom).animation(.easeInOut(duration: 0.3)))
    }
}

Me? I’ve done the ZStack route before:

VStack {
    // Content
}
.overlay {
    ZStack {
        // Background dim
        Color.black
            .opacity(0.15)
            .onTapGesture {
                dimissOnCurtainTap()
            }
        // Container to easily push the UI down
        VStack {
            Spacer()
            // The UI
            VStack {
                // Sheet content
            }
            .padding(8)
            .frame(minWidth: 0, maxWidth: .infinity)
            .frame(height: containerHeight)
            .background {
                RoundedRectangle(cornerRadius: theme.screenCornerRadius)
                    .fill(Color(uiColor: .systemBackground))
                    .padding(EdgeInsets(top: 0,
                                        leading: contentPadding,
                                        bottom: contentPadding,
                                        trailing: contentPadding))
            }
            .offset(y: containerYOffset)
        }
    }
    .ignoresSafeArea()
    .opacity(internalIsPresenting ? 1 : 0)
    .allowsHitTesting(internalIsPresenting)

They all mostly work (to varying degrees), but here’s an approach from Ian Keen I liked using an alignment guide:

VStack {
    // Content
}
.overlay(alignment: .bottom) {
   Color.white.frame(height: 50)
      .alignmentGuide(.top) { $0[.bottom] }
}

That’s an abridged version, you’d still need to hook in the offset to show it, but the idea is that to actually place the bottom sheet — you use .alignmentGuide(.top) { $0[.bottom] }. This basically says “align this content’s top origin to the parent’s bottom origin”, which puts the bottom sheet in the right spot to later present.

Smooth Animations

Ben Scheirman had a great example showing how alignment guides can give you the animation you could be after. I’d encourage you to read his post on the matter, though the gist is that by using alignment guides — he can get two rectangles in a ZStack to animate left and right smoothly from the center (they begin one on top of the other):

@State private var isLinked: Bool = false 

ZStack {
     BarView()
         .alignmentGuide(HorizontalAlignment.center) { d in
             d[isLinked ? .center : .trailing]
         }
     BarView()
         .alignmentGuide(HorizontalAlignment.center) { d in
             d[isLinked ? .center : .leading]
         }
 }

The result is that they “split” from the center, evenly. Without using alignment guides for this particular scenario, SwiftUI’s layout system can have some unintended effects on the resulting animation. His post shows this clearly with some pictures, go check it out.

If you want to dig in a bit deeper over how alignment guides work, I’d recommend reading these posts:

  • Paul Hudson has a great, overall explainer (as he always does).
  • SwiftUI Lab has an insane, in-depth post on the matter (and, as he always does).

When I don’t really “get” an API, I find the only way I learn it is by getting to the point where I can answer this question:

When would this API help me? Would I know when to reach for it?

After seeing these examples and then going back to the docs, I feel like I’m getting there with alignment guides.

Until next time ✌️.

···