[SC]()

iOS. Apple. Indies. Plus Things.

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

···

The AirDrop Conundrum: Passing Custom Models From and To Your App

// Written by Jordan Morgan // Sep 2nd, 2024 // Read it in about 8 minutes // RE: The Indie Dev Diaries

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

Did you know that as of iOS 17, you can’t AirDrop custom models from your app to another instance of your app anymore?

I do. And this post is about how I found out, how AirDrop even works because nobody seems to know that, and where Apple might go from here.

Discovering the Problem

I was chatting with my friend Mikaela about this topic around a year ago. She mentioned that the code sample wasn’t working as intended found in the chapter over AirDrop from my book series. I remember testing this quite a bit, and knew that (at the time) it was functioning.

And so, I told her I’d check into it. Then my kids started fighting, they also needed dinner, I finally cleaned up the kitchen afterwards and just like that — about a calendar year went by.

That brings me to last weekend. I noticed that in Elite Hoops, a team AirDrop transaction was opening Files.app instead of, well, Elite Hoops. You can try this yourself — AirDrop anything from an app not made by Apple and see where it ends up. If it’s not something defined in Apple’s public UTTypes, like a spreadsheet or word document, it’ll likely open Files.

In my case, this was (and isn’t) ideal because new iPhones are coming and coaches will want to move their data over. AirDrop is one way they could.

So I poured into the issue, determined to figure this out.

How AirDrop Works

Search for AirDrop solutions and you’ll find tumbleweeds, dead ends and years-old code samples. Apple’s own solution on the matter? It’s from iOS 7, and doesn’t build. No matter, because you’ll get the same result there — Files.app opening the drop.

To wit, there is no “Here is how AirDrop Works for 3rd Party Developers” page anywhere.

Plus, even if you knew where to look API wise — you’d find exactly one mention of AirDrop across all of the docs as far I’ve been able to find:

AirDrop callout in Apple's documentation.

If there are more, please tell me and I’ll include them. I’d love to be wrong about this.

So, how does it work? It’s fairly simple. Consider this model:

struct Person: Identifiable, Codable {
    let name: String
    let id: UUID
}

You would create your own UTType for it within Xcode and using the (very nice and not-talked-about-enough) framework UniformTypeIdentifiers:

Setting up a new type in Xcode

This basically says that this type is from your app — you own it and are the authority over it — and you can import it. And, in code, you reference it when needed:

extension UTType {
    static var personType: UTType = .init(exportedAs: "com.testApp.person")
}

From here, there are two roads you could take:

  • Modern Approach: You use Transferable, which is actually a lovely way to describe how your data should be transported, what kind of data it is, and how it can be represented. You could vend that data with a ShareLink to expose the action sheet, and thus — AirDrop.
extension Person: Transferable {
    static var transferRepresentation: some TransferRepresentation {
        CodableRepresentation(contentType: .personType)
        DataRepresentation(importedContentType: .personType) { data in
            let person = try JSONDecoder().decode(Person.self, from: data)
            return person
        }
        DataRepresentation(exportedContentType: .personType) { person in
            let data = try JSONEncoder().encode(person)
            return data
        }
        FileRepresentation(contentType: .personType) { person in
            let docsURL = URL.temporaryDirectory.appendingPathComponent("\(person.name)\(UUID().uuidString)", conformingTo: .personType)
            let data = try JSONEncoder().encode(person)
            try data.write(to: docsURL)
            return SentTransferredFile(docsURL)
        } importing: { received in
            let data = try Data(contentsOf: received.file)
            let person = try JSONDecoder().decode(Person.self, from: data)
            return person
        }
    }
}

And later in the interface:

ShareLink(item: person, preview: .init(person.name))
  • Classic Approach: Your model adopts UIActivityItemSource and uses UIActivityViewController. For Swift apps, this means you have to leave a Struct behind and use a reference type, notably NSObject:
class PersonObject: NSObject {
    let name: String
    let id: UUID
    
    init(person: Person) {
        self.name = person.name
        self.id = person.id
    }
}

extension PersonObject: UIActivityItemSource {
    func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
        return name
    }
    
    func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
        return self
    }
    
    func activityViewController(_ activityViewController: UIActivityViewController, subjectForActivityType activityType: UIActivity.ActivityType?) -> String {
        return "Sharing Person: \(name)"
    }
    
    func activityViewController(_ activityViewController: UIActivityViewController, dataTypeIdentifierForActivityType activityType: UIActivity.ActivityType?) -> String {
        return UTType.personType.identifier
    }
}

And then it’s just off to the share sheet:

UIActivityViewController(activityItems: [personObj], applicationActivities: nil)

Doing all of this and AirDroppin’ from your app to another version of your app imports it into Files.app.

Why does it do that though!?

Type Handler Jacking

The reason Apple changed this is because any app can say they are the owner of any UTType. That means if you opened a Facebook-specific type, and two apps on the device said they own it — well who knows what opens? Maybe Facebook, maybe the other app. And, well — that is confusing and not great.

This concept is briefly alluded to in (another excellent Tech Talk) here:

The system now knows we can open this file but that we may not be the best choice if the user has Company’s Food installed, since that app is the owner of the file type. We want to be good citizens on the platform, so it’s important to respect type ownership this way even though we both know our code is way better than our competitor’s code.

I personally ran into this at my previous job at Buffer, where confused customers didn’t know why some random app was opening app-specific types. The same kind of thing can happen with custom URL schemes. So in iOS 17, Apple nixed this for that reason:

DTS Engineer responding on a support thread.

And that is where things start to make sense. There are several hits of “My app AirDrops something but it always opens Files, please help” if you search for them.

The Programmer Fix

This feels like what I like to call the “Programmer Fix” solution. I’ve been a part of several. Was the ticket solved by definition? Yes, it was. Apps that aren’t the owner of a specific type can no longer open them by default. Case closed.

But, did it make the user experience much worse? 1,000%. Now, your best bet is to add an action extension to import that data after it’s already imported into Files. Food Noms does this the best from what I’ve seen so far.

So, to recap, the AirDrop flow for 3rd party developers shuttling their data before was:

  1. Open app -> Pick data -> Open share sheet to AirDrop -> AirDrop is handled in the app (via URL app or scene delegate functions).

Now, that same flow is:

  1. Open app -> Pick data -> Open share sheet to AirDrop -> Files opens the data -> 1A. Open the Share Sheet again -> Hope the developer has an action or import extension to bring it into their app, or 1B. Hope the app has document importing and viewing capabilities, or 1C. The user has not a freaking clue what the Files app even is, becomes confused and sends you an email.

If I asked my wife to do this flow, she’ll just stop and decide it’s not worth the hassle. I can’t much blame her.

Apple Should’ve Looked to macOS

How I wish Apple would’ve solved this would’ve been by simply looking at macOS. If there are multiple apps that say they are the handler of a type, or the extension is unknown to be handled by the apps you’ve got installed, you’ll get a nice modal dialog that lets you make a decision on which app should open it. Even better, if you right click it and choose “Open With..” -> “Other…”, you can pick an app and mark it as the default:

Image 1
Image 2

Problem solved. And the problem has been solved for a long time.

I hope to see this implementation in the future on iOS. Or, maybe Apple could even let you officially register a type on App Store Connect or something (though thinking through that route presents other challenges).

I do want to point out that I am not privy to the challenges of developing on an operating system used by billions of people. Maybe their end result (opening AirDropped data in Files) wasn’t a “programmer fix” at all, maybe it was weighed heavily in every which direction and that’s where they ended up.

Who knows? All I can say is that it doesn’t feel great as a developer or end user of iOS.

The Documentation…Or Lack Thereof

It would be one thing if it worked this way and it was clearly documented, it’s another when it “magically changes” and you find out about it within the deep depths of Google search. That’s all I really want to say on this topic.

As far as I can tell, the only mention of this is in the thread I linked above in the developer forums. Is that the right place for it? I dunno, I guess it’s not wrong. Though, I imagine a lot of developers would simply search developer.apple.com, and you won’t find the answer there (even if you select “Forums”!!!):

Searching for AirDrop.

Final Thoughts

Remember the beautiful animation Apple added for AirDrop? It rocks. But now, it really only rocks for Apple. And that’s a shame, because AirDrop embodies that Apple magic of something just working the way it should. “I have some stuff here, I want it there. Boom, done.”

This post probably comes off a bit pessimistic when compared to my usual fare, and perhaps a pinch of agitation shines through. But, you know, we want to make great apps for iOS. Not just okay apps. I want AirDrop to work just as great as it does across the rest of the system in my apps. This platform still remains my favorite place to create things, but I hope Apple can become a bit more proactive with changes like this. Things like this matters a lot to developers who put a lot of work into tiny interactions, like supporting AirDrop.

Here’s hoping this situation changes in the future. Or, maybe I saved you some time. Maybe both!

Until next time ✌️

···

Marking Swift Properties Available by iOS Version

// Written by Jordan Morgan // Aug 21st, 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.

While working with the nascent PencilKit APIs introduced in iOS 18 to create custom tools, I ran into a situation that (gasp!) used to be trivial in Objective-C, but requires a little bit of dancin’ in Swift.

That is, marking properties available by a specific iOS version:

@property (nonatomic, copy) PKCustomToolItem *customItem API_AVAILABLE(ios(18.0));

There is, unfortunately, no corollary to this in Swift. You can mark extensions, classes and structs in a similar manner…

@available(iOS 18.0, *)
struct GetQuoteControlWidget: ControlWidget {
    // code
}

…or even handle control flow:

if #available(iOS 18.0, *) {
    setupCustomTools()
}

But, trying to do something similar with a property in Swift won’t compile:

@available(iOS 18.0, *)
private var customToolWrapper: PKToolPickerCustomItem = .init(configuration: .init(identifier: someID, name: someName))

The above would result in a compiler error, Stored properties cannot be marked potentially unavailable with '@available'.

Drats. But!

It turns out, with a little house call from our old friend NSObject (in my case — any base class of whatever you’re trying to use should do), you can do something similar when you utilize a computed property:

private var _customTool: NSObject? = nil
@available(iOS 18.0, *)
private var customToolWrapper: PKToolPickerCustomItem {
	if _customTool == nil {
		let tool = PKToolPickerCustomItem(configuration: .init(identifier: someID, name: someName))
		_customTool = tool
	}
	return _customTool as! PKToolPickerCustomItem
}

And voilà! You’re free to continue on with your day:

if #available(iOS 18.0, *) {
	customToolWrapper.allowsColorSelection = true 
}

There is an old saying on effective teaching, “First delight — then instruct.” I’ve always resonated with that, and have long considered Swift to embody such a notion. It eased you in, and then let you go a bit deeper.

These days, it’s feeling more like I need a rocket manual as opposed to determined curiosity to control the language (looking at you, Swift 6)1. Here’s hoping this specific situation becomes easier in the future (to wit — could a property wrapper solve this?) but regardless, this approach should work all the same.

Until next time ✌️

  1. Look, I get concurrency is an insanely hard problem to solve. But these errors are all over the place, and it feels like a huge swing in another direction as opposed to progressive disclosure in terms of learning how it should work. But the folks working on it are bright, talented people — so I have no doubt it’ll eventually “click”, even if the road getting there isn’t without its bumps. 

···