[SC]()

iOS. Apple. Indies. Plus Things.

iOS 18: Notable UIKit Additions

// Written by Jordan Morgan // Jun 3rd, 2024 // Read it in about 4 minutes // RE: UIKit

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

A.I. - amirite?

While this year’s keynote was heavy on Apple Intelligence (that’s what you thought A.I. stood for, right?) — our timeless user interface framework cracked on.

So, as is tradition - here are some notable UIKit additions in iOS 18. If you want to catch up on this series first, you can view the iOS 11, iOS 12, iOS 13, iOS 14, iOS 15, iOS 16 and iOS 17 versions of this article.

Automatic Trait Tracking

Oh registerForTraitChanges(), we hardly knew ye. In iOS 18, we get automatic trait change tracking — in some cases (so registerForTraitChanges() isn’t entirely going away, but its primary use case is).

Consider this code:

class CustomBackgroundView: UIView {
    override init(frame: CGRect) {
        super.init(frame: frame)
        registerForTraitChanges([UITraitVerticalSizeClass.self], action: #selector(UIView.layoutSubviews))
    }
        
    override func layoutSubviews() {
        super.layoutSubviews()
        
        if traitCollection.verticalSizeClass == .regular {
            backgroundColor = .blue
        } else {
            backgroundColor = .orange
        }
    }
}

We register to hear about verticalSizeClass changes, and run layoutSubviews() when it does. In iOS 18, it looks like this:

class CustomBackgroundView: UIView {   
    override func layoutSubviews() {
        super.layoutSubviews()
        
        if traitCollection.verticalSizeClass == .regular {
            backgroundColor = .blue
        } else {
            backgroundColor = .orange
        }
    }
}

You can just…skip the register bit entirely! UIKit will note which traits we’re interested in within the function, and invoke layoutSubviews() again when it changes. This only works in a few scenarios though, namely — “update” or invalidation type of lifecycle functions:

  1. For views: layoutSubviews(), updateConstraints() and draw(CGRect).
  2. For view controllers: viewWillLayoutSubviews(), viewDidLayoutSubviews(), updateViewConstraints(), and updateContentUnavailableConfiguration(using:).
  3. In presentation controllers: containerViewWillLayoutSubviews() and containerViewDidLayoutSubviews().
  4. Inside buttons, table view headers and footers, and collection or table view cells (remember those!): updateConfiguration() and configurationUpdateHandler.
  5. Collection view compositional layouts: UICollectionViewCompositionalLayoutSectionProvider.

That’s a nice quality of life update — I’m sure Apple has optimized the living daylights out of this process, and it’s less code that you have to write.

If crazy, out-of-the-box animations are your thing, then UIUpdateLink may be what you’re looking for.

let concatenatedThoughts = """

But wait! How is this any different than `CADisplayLink`? I wondered the same. Basically, according to the docs, #Features - update link has more of them (like view tracking), better performance and battery efficiency and it also puts the system in low-latency mode for drawing applications.

"""

I imagine this is for applicable for things like custom drawing implementations, complex animations and more. I’m not going to pretend I have any novel examples here, but after a bit of tinkering — I was able to get the examples from the docs working:

class TestingViewController: UIViewController {
    let imgView = UIImageView(image: .init(systemName: "rays"))
    var updateLink: UIUpdateLink! = nil
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        imgView.contentMode = .scaleAspectFit
        imgView.frame.size = .init(width: 64, height: 64)
        imgView.frame.origin = .init(x: 100, y: 100)
        
        updateLink = UIUpdateLink(
            view: imgView,
            actionTarget: self,
            selector: #selector(update)
        )
        updateLink.requiresContinuousUpdates = true
        updateLink.isEnabled = true
        
        view.addSubview(imgView)
    }

    @objc func update(updateLink: UIUpdateLink,
                      updateInfo: UIUpdateInfo) {
        imgView.center.y = sin(updateInfo.modelTime)
            * 100 + view.bounds.midY
    }
}

Which yields:

UIUpdateLink in action.

More Symbol Animations

As always, Cupertino & Friends™️ are constantly breathing fresh life into SF Symbols. This year is no different:

  • Three new animation styles: Wiggle, breath and rotate. Here’s my favorite, .breathe:
    override func viewDidLoad() {
      super.viewDidLoad()
      let symbolView = UIImageView(image: .init(systemName: "arrowshape.up.circle.fill"))
      symbolView.frame = view.bounds.insetBy(dx: 2, dy: 2)
      symbolView.contentMode = .scaleAspectFit
      symbolView.addSymbolEffect(.breathe, options: .repeating, animated: true) { _ in }
      view.addSubview(symbolView)
    }
    

The result: The new breate symbol animation.

  • A new behavior, .periodic, which supports a timed delay or a number of times to repeat the animation. Or, you can use .repeat(.continuous) to keep the party going.
  • “Magic replace”, which looks so good, smoothly changes badges during replace animations. As far as I can tell, it only works with slashes and badges (going to and from, or vice-versa). But, you can provide a fallback replace behavior if it’s not supported. Here’s an example from Apple’s documentation:

Magic replace in a symbol.

Custom text formatting

Now, we all get to riff on Notes excellent implementation of text formatting:

Custom text formatting in iOS 18.

Even better, it just takes one line of code to opt-in:

override func viewDidLoad() {
    super.viewDidLoad()
    let tv = UITextView(frame: view.bounds)
    tv.text = "Format me!"
    tv.allowsEditingTextAttributes = true // This lil' guy is `false` by default
    view.addSubview(tv)
}

While that is great to offer to developers, what’s even better is that we can apparently customize the tools which show here, too. However, these symbols don’t appear to be present in the beta one, or they have since-been renamed and I can’t seem to track them down. Regardless, it looks like this:

tv.textFormattingConfiguration = .init(groups: [
    .group([
        .component(.textColor, .mini)
    ]),
    .group([
        .component(.fontPointSize, .mini)
    ])
])

Bonus Points

  • You can select dates week by week now using UICalendarSelectionWeekOfYear: Selecting entire weeks in iOS 18.

  • SwiftUI and UIKit have unified their gestures, and each one can know about and react to one another.
  • UICanvasFeedbackGenerator can match up haptic events to drawing events. The example Apple gave was a grid-like board, wherein a shape is “snapped” into place on the grid. You could marry haptics along with that experience.
  • Now UICommand, UIKeyCommand and UIAction can be invoked by the system on iPhone, and that’s due to the Mac Mirroring capabilities.
  • Lots of sidebar changes along with that spiffy new tab bar, where it floats and morphs into one or the other.

What more can you say? SwiftUI, like the last few years I’ve written this, is the future. But hey, UIKit, no doubt, is better than it ever has been.

Until next time ✌️

···

W.W.D.C. 2024: The Pregame Quiz

// Written by Jordan Morgan // May 27th, 2024 // Read it in about 5 minutes // RE: Trivia

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

Back to the mothership! Our favorite annual conference is near, which means that the TENTH(!!) annual edition of the Swiftjective-C W.W.D.C. Pregame Quiz is ready to go! If you want to test your skills first with the quiz backlog, here are the previous trivia posts from:

Ground Rules

There are three rounds, and the point break down is as follows:

  • Round 1 – 1 point each answer
  • Round 2 - 2 points each answer
  • Round 3 - 3 points each answer

The last question of each round is an optional wildcard question. Get it right, and your team gets 4 points, but miss it and the team will be deducted 2 points.

Round 1 - Siri Superfans

Let’s chat all about the voice assistant, rumored to be powered up this year.

Question 1:
Siri is rumored to get a much-needed AI boost this year. Though it seems like a lifetime ago, Siri has been around for 13 years in production. What was the first iPhone to house the virtual assistant?
A) iPhone 4
B) iPhone 3GS
C) iPhone 4S
D) iPhone 5

Question 2:
In a world where AI companies are definitely not ripping off others’ voices to sound like the movie Her, which person was the original voice for Siri?
A) Jennifer Hale
B) Tara Strong
C) Susan Bennett
D) Nancy Cartwright

Question 3:
Siri now enjoys fairly deep integration with apps thanks to the App Intents framework. Though SiriKit was the OG way to link up with Siri, which version of iOS did it make its debut?
A) iOS 8
B) iOS 9
C) iOS 10
D) iOS 11

Question 4:
Though we widely say Siri and think of it as nothing more than a name, it’s actually derived from an acronym. What is it?
A) Speech Interpretation and Recognition Interface
B) Speech Interpretation and Response Intelligence
C) Smart Interactive Response Interface
D) Systems Intelligent Recognition Interface

Wildcard:
Believe it or not, Siri has a few acting credits — and one of them is fairly prominent. Which movie did Siri’s voice make an appearance?
A) The Lego Batman Movie
B) Despicable Me 3
C) Ralph Breaks the Internet
D) The Boss Baby

Round 2 - W.W.D.C. 2023

How well do you remember last year’s conference?

Question 1:
Last year was one of those dub dubs where we got some hardware announced (aside from, you know, the whole Apple Vision Pro thingy). A new Macbook Air 15 inch and Mac Pro were unveiled — what version of Apple silicon were they using?
A) M3 Ultra
B) M3
C) M2 Ultra
D) M2

Question 2:
Let’s move to the Apple Design Award winners. Which app won the Apple Design Award for innovation?
A) Camo
B) stitch.
C) Rise
D) SwingVision

Question 3:
Let’s go to UIKit changes (yeah, you all are toast now). Which part of UIKit was given a fairly big revamp in terms of how the API is used? Yeah, it’s a bit subjective — but you should know it when you see it.
A) UIScrollView.
B) Table and collection view cell registration.
C) UITraitCollection.
D) View controller lifecycle functions.

Question 4:
As developers, sometimes we forget about the consumer side of W.W.D.C. announcements. To that end, which of these features were not a part of the announcements for the (then upcoming) version of iOS?
A) NameDrop
B) Offline Maps
C) Check In
D) Customizable Lock Screen

Wildcard:
The pandemic forced about every tech conference to rethink itself. Some, such as E3, didn’t survive while many (such as W.W.D.C.) adapted themselves into new formats. As such, this years marks the Xth year that Apple has had W.W.D.C. as a hybrid format:
A) The 2nd year.
B) The 3rd year.
C) The 4th year.
D) The 5th year.

Round 3 - Early iPad O.G. Trivia

How well do you remember the first iPads released?

Question 1:
A certain late-night TV host showcased the second iPad in a sketch where it was juxtaposed as good news against current events. Who was it?
A) Jimmy Kimmel
B) David Letterman
C) Stephen Colbert
D) Conan O’Brien

Question 2:
How well has inflation treated us? Well, let’s consider the very first iPad. What did the base model retail for?
A) $399
B) $499
C) $599
D) $699

Question 3:
We know Apple loves its codenames. What name was rumored to be the iPad’s codename?
A) Sonoma
B) Amber
C) K48
D) Kanga

Question 4:
Which technology was famously not supported on the iPad, which even resulted in a letter from Steve Jobs justifying the decision?
A) Java Applets
B) Macromedia Shockwave
C) Microsoft Silverlight
D) Adobe Flash

Wildcard:
We always want to wax poetic about what we think the iPad should do. Chief among them? Multitasking. Which versions of iOS first introduced multitasking to iPad, and then later Stage Manager?
A) iOS 9 and iOS 16
B) iOS 8 and iOS 16
C) iOS 10 and iOS 16
D) iOS 9 and iOS 15

👉

👈

Answer Key

Round 1:

  1. C. The iPhone 4S.
  2. C. The venerable Susan Bennett
  3. C. iOS 10 indeed.
  4. A. Coined by the original Siri creators.
  5. Wildcard: A, The Lego Batman Movie

Round 2:

  1. C. The M2 Ultra. Kinda hard to keep track of these things.
  2. D. SwingVision. Though you still gotta feel for Camo and the whole Continuity Camera situation.
  3. C. Table and collection view cell registration were a few years ago, trait collection switched all up.
  4. D. That came with iOS 16, the year prior.
  5. Wildcard: B, this is year three (starting in 2022).

Round 3:

  1. C. Stephen Colbert
  2. B. Depending on how you look at it, it’s mostly held - which makes me think it was fairly expensive at launch back then.
  3. C. K48. The iPhone 4’s was N90, so there seems to be a commonality between codename structure.
  4. D. Adobe Flash - which history seems to confirm was the right choice.
  5. Wildcard: A. That was iOS 9 and iOS 16, respectively.
···

Refactoring to TipKit from AppStorage and Custom Views in SwiftUI

// Written by Jordan Morgan // May 1st, 2024 // Read it in about 2 minutes // RE: TipKit

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

In my latest update to Elite Hoops, I replaced several homegrown solutions with Apple’s nascent iOS 17 equivalents. The most prominent example of this is TipKit.

To prompt coaches about a functional user experience feature in Elite Hoops, I had created my own view that worked like this:

Custom tip presenting a tip in Elite Hoops.

It works, but I was not particularly fond of how it looked, or the code I had to maintain to make it work. Any interface code, relying on user path logic combined with caching flags, has a unique propensity to naturally become voluminous — making them less than fun to maintain years later. Let alone the first time — but adding more and more logic to it? Nah, I’d rather not mess with it.

All that to say, when I have that feeling about some code — I always replace it with a baked-in Apple API if one is available.

Thus, TipKit. Here is what it looked like after replacing my own stuff with it:

TipKit presenting a tip in Elite Hoops.

let concatenatedThoughts = """

Plus, plugging in `.blurReplace()` makes it so fun.

"""

In addition to being a familiar user experience to most people (Apple has been using TipKit-esque tips for years), it also makes the code easier to reason about.

Skip the Extra Caching Flag

When using my old method, I had to manually cache the flag to hide or show it:

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

And then later, in the view:

CourtView()
.onFirstAppear {
    0.33.delayThen {
        if appSettings.hasSeenWelcomeToTeamsExplainer && !appSettings.hasSeenPassingTipsChanges {
            appSettings.hasSeenPassingTipsChanges = true
            showTipsPopover.toggle()
        }
    }
}

With TipKit, it all becomes a little more intuitive. I can skip the manual caching layer, because TipKit inherently uses its own. That also helps me worry about one less thing and it keeps the actual tip logic housed within the tip code itself:

import TipKit

struct PassingTip: Tip {
    @Parameter
    static var showPassingTip: Bool = false
    
    var rules: [Rule] {
        [
            #Rule(Self.$showPassingTip) {
                $0 == true
            }
        ]
    }
    
    var title: Text {
        Text("Tip: Easily pass to players")
    }
    
    var message: Text? {
        Text("· Tap twice on a player to pass.\n· Tap and hold on a player for more.")
            
    }
    
    var image: Image? {
        Image(systemName: "lightbulb.circle.fill")
    }
    
}

Which means the original calling site doesn’t even really have to change much:

CourtView()
.onFirstAppear {
    0.33.delayThen {
        if appSettings.hasSeenWelcomeToTeamsExplainer && !PassingTip.showPassingTip {
        	PassingTip.showPassingTip.toggle()
        }
    }
}

Further, they are easier to test without changing the call site (before, I needed to change the call site or nuke UserDefaults, which had a trickle down effect on a bunch of other things I wasn’t testing):

// In EliteHoopsApp

init() {
	try? Tips.resetDatastore()
	try? Tips.configure()
}

Aggregation

While moving over a few other tips, I was pleasantly suprised to see how trivial it was to aggregate tip logic. That is, TipA may have a flag that TipB needs to know about in its conditions to present.

In the same view, I show another tip about an easier way to undo things. But, it shouldn’t present if the previous tip hasn’t shown yet. Sticking all of this into the rules array of the other tip makes it all easy peezy:

struct UndoTip: Tip {
    @Parameter
    static var showUndoRedoTip: Bool = false
    
    var rules: [Rule] {
        [
            #Rule(Self.$showUndoRedoTip) {
                $0 == true
            },
            // References the previous tip above
            #Rule(PassingTip.$showPassingTip) {
                $0 == true
            }
        ]
    }
    
    var title: Text {
        Text("Tip: Easily undo")
    }
    
    var message: Text? {
        Text("· Tap twice with two fingers to undo.")
            
    }
    
    var image: Image? {
        Image(systemName: "arrow.uturn.backward.circle.fill")
    }
    
}

In the end, I made things look a little nicer, removed code I didn’t need to maintain and rested easier knowing I can let Apple handle Tip-related UI code going forward. Typically, I find frameworks from an API design standpoint to either be easy to use but rigid, or they are hard to get started with but extremely flexible.

TipKit is easy to use and it’s flexible. That…that’s the good stuff.

Until next time ✌️.

···

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

···