[SC]()

iOS. Apple. Indies. Plus Things.

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. 

···

Project Management Flow for Indies

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

I get stressed about when it comes to thought organization. Like, stressed, stressed. Some call it Type-A, some call it being high-strung…I call it sweet, sweet organization and ruthless efficiency. And there is no place where I must embody such a set of principles more critically so than indie project management.

Today, let’s talk about it. I’ll show you how I do it, and it’s incredibly simple and short. Because let’s be real, project management sucks, right? My least favorite part of this industry is spending time in Jira. And, not so much because of the software (though I do have #thoughts there…) but moreso because of what it represents:

  • Story points!
  • User stories!
  • Sprints! Meetings! Retrospectives!

The list goes on, and I’m not even the slighest fan of any of those things.

But wait! What about that mac app you were working on...

...for this exact reason, it even had full Markdown support:
...and a sidebar!


Yeah, I do love that little app. But Elite Hoops happened, and I was in the middle of writing the book series while tinkering on this. I do want to get back to it someday, but I haven't since (as we'll see) I have a good enough flow for now.

Needs

My motto with indie project management is any flow needs to be:

  • Fast
  • Short
  • Organized

That’s it. To that end, I actually use a few different tools because there a few different states of mind I find myself in when it comes to this stuff:

Mind map of indie project management tasks.

Which ends up looking like this:

macOSS apps used to handle project management.

Each of these solve a problem for me:

  • Things start as an idea in Notes (i.e. quick thoughts, ideas or inspiration)
  • If it’s promising, I chew on it more in Craft (i.e. competitors, features, etc.)
  • Websites and design inspiration happen in Arc (i.e. using Easels to collect designs)
  • Mockup and flows happen in Freeform (i.e. it syncs, it works, it’s free)
  • Optionally, HD mocks are made in Sketch (even though I hardly do these)
  • Tasks for actually doing it are in Things 3 (i.e. it’s showtime if we got here)

The bulk of my time is spent in Things 3. It syncs, and forces me to be brief. There are no image attachments, I can’t put projects into projects…things I want to do — but I then later appreciate that I kinda couldn’t. It makes stick to the point, and Things 3 is all about work I want to get done.

I use tags for different tasks (platform stuff, bugs, etc.) and then create seperate headings for each of them and that’s it. Is it ideal? No, but it definitely works, and has worked, for several years. In a weird way, it makes me want to solve this problem even more with the aforementioned app I was working on, but hey - someday, right?

Here’s a quick snapshot of how I use these tools so you can get an idea of how it all looks.

Notes: For thoughts and ideas

macOSS running Notes.

ARC: For Easles to house U.I. direction

macOSS running ARC.

Sidebar: It’s absolutely painful that Arc on iOS doesn’t support opening an easle still. Sigh.

Craft Docs: For cracking open an idea

macOSS running Craft Docs.

Freeform: Let’s wireframe that bad boi

macOSS running Freeform.

Sketch: Now, let’s H.D. that bad boi

macOSS running Sketch.

Things 3: Now, let’s develop it and track tasks

macOSS running Things 3.
- - -

In the world of photography, there’s a saying that goes something like this: “The best camera is the one you have with you.” I get the sentiment, of course a decked out Lecia is objectively better than an iPhone. But, you always have your iPhone. Sooooo.

I think the same is true of project management for indies. Even the phrase itself is perhaps too heavy, project management, though I’m unsure of a better one. Wherever you can quickly toss some thoughts in, see what you are working on and where it’s at — that’s enough.

Until next time ✌️

···

Accessibility Setting Nuggets from iOS 18

// Written by Jordan Morgan // Jul 29th, 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 usual, Cupertino & Friends©️ continue to lead the way in accessibility features on mobile platforms. Though they may not be marquee features meriting keynote time, they are critical to know about. Here’s some of what I found new in iOS 18 after browsing through the documentation (in no particular order).

let concatenatedThoughts = """

Though I won't cover it here, Apple also added the ability to detect if Assistive Access is running or not in iOS 18. I cover that in my post over the matter here.

"""

Deep Linking to Accessibility Settings

How many times have you been testing some obscure (at least, to you) accessibility feature — but you needed to tweak some setting for it after it’s up and running? Aside from testing, what about as an end user — where it can be paramount to tweak these things efficiently?

Now, Apple has API to do just that:

private func openAXSettings(for feature: AccessibilitySettings.Feature) {
    Task {
        do {
            try await AccessibilitySettings.openSettings(for: feature)
        } catch {
            print("Unable to open AX Settings for \(feature): \(error)")
        }
    }
}

Much like how we’ve had ways to direct users to specific parts of the iOS’ Settings app (think push notification permissions), now we can send people straight to accessibility sections. For now, there is only one destination supported — and that’s to allow an app to use personal voice accessibility features (for use with the AVSpeechSynthesizer API).

Of course, we should expect more features to come in future iOS versions. There is a reason it’s built with an enum for features and it’s not called openPersonVoiceSettings(), or something similar.

Respecting Blinking Cursor Settings

People have the ability to toggle the blinking cursor in text fields within Settings. Now, if you’re rolling your own text view or equivalent — you can query for it:

private func checkAXCursorPreference() -> Bool {
    let axSettings = AccessibilitySettings.self
    let prefersNonBlink = axSettings.prefersNonBlinkingTextInsertionIndicator
    return prefersNonBlink
}

As expected, there is also a corresponding accessibility notification to ping you of any changes to said preference. Gear up, it’s a mouthful:

struct AXBlinkingCursor: View {
	@State private var hideCursorBlink: Bool = false
    private static let AXCursorNoteName =
AccessibilitySettings.prefersNonBlinkingTextInsertionIndicatorDidChangeNotification
    private static let AXCursorPublisher = NotificationCenter
                                          .default
                                          .publisher(for: AXBlinkingCursor.AXCursorNoteName)
    var body: some View {
        CustomTextView()
            .onReceive(AXBlinkingCursor.AXCursorPublisher) { _ in
                hideCursorBlink = prefersNonBlinkingCursor()
            }
    }
    
    private func prefersNonBlinkingCursor() -> Bool {
        let axSettings = AccessibilitySettings.self
        let prefersNonBlink = axSettings.prefersNonBlinkingTextInsertionIndicator
        return prefersNonBlink
    }
}

SwiftUI Specific Additions

If you have a custom tab bar in iOS, you can now indicate that to the accessibility engine. Even better, it appears backported to iOS 17:

var body: some View {
    CustomTabBar()
        .accessibilityAddTraits(.isTabBar)
}

You can also interpolate localized descriptions of a Color, too:

@State private var selectedColor: Color = .blue

var body: some View {
    CustomThemePicker(baseColor: $selectedColor)
        .accessibilityLabel(Text("Your custom color palette. Based on \(accessibilityName: selectedColor)"))
}

Curious how this stacks up against AXNameFromColor or UIColor.accessibilityName? Check out this useful gist from Bas.

Another neat one? You can now conditionally apply accessibility labels:

@State private var isDemoEnabled: Bool = false

var body: some View {
    VStack {
        DemoPickerView()
        Button {
            playSelectedDemo()
        } label: {
            Image(systemName: "play.fill")
        }
        .accessibilityLabel(Text("Play demo."), isEnabled: isDemoEnabled)
    }
}

And of course, here are some honorable mentions:

Until next time ✌️

···

@DefaultIfMissing - My Codable Failsafe

// Written by Jordan Morgan // Jun 20th, 2024 // Read it in about 3 minutes // RE: Swift

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

Codable is worth its weight in proverbial gold. Saving developers across the globe the boilerplate code of manual .json serialization and deserialization code — it’s the first choice most of us turn to when decoding models over the wire.

But as mama told me growing up, there’s no such thing as a free lunch.

let concatenatedThoughts = """

If you aren't well-versed in the rigid ways of `Codable`, I'd invite you to read my extensive post on the matter here before continuing on.

"""

If you haven’t read the post linked above, let me sum up the fun part about being an iOS developer — the server will troll you. Maybe not today. Maybe not tomorrow. But probably when it’s a Friday night and you’re on call.

Alas:

struct Person: Codable, CustomStringConvertible {
    let name: String
    let age: Int
    
    var description: String { "\(name): \(age) years old" }
}

let mockResponse: Data = """
[
    {
        "name": "John Doe",
        "age": 30
    },
    {
        "name": "Jane Smith",
        "age": 25
    },
    {
        "name": "Alice Johnson",
        "age": 28
    }
]
""".data(using: .utf8)!

let decoder = JSONDecoder()

do {
    let people = try decoder.decode([Person].self, from: mockResponse)
    print(people)
} catch {
    print(error)
}

…decodes and works great.

But that Int property for age? It shows up in 3 out of 3 entries now. But what about when this happens tomorrow due to a bad deploy:

[
    {
        "name": "John Doe"
    },
    {
        "name": "Jane Smith",
        "age": 25
    },
    {
        "name": "Alice Johnson",
        "age": 28
    }
]

Wupps. Missing key! Now, none of the models would decode. Queue error alerts, or an empty data view, or the classic “Please reach out if this keeps happening!” text labels.

Further, what about when this happens:

[
    {
        "name": "John Doe",
        "age": 30
    },
    {
        "name": "Jane Smith",
        "age": 25
    },
    {
        "name": true,
        "age": 28
    }
]

Bummer, typeMismatch for the last name. Same thing, we get nothin’.

Look, sometimes (though not everytime), all a Swift developer wants is for primitives to freakin’ decode no matter what. And while you could write the encode and decode yourself, I don’t want to.

Enter @DefaultIfMissing, my little concoction to give primitives a default value if they are either missing in the response, or you get a whack value for them:

@propertyWrapper
struct DefaultIfMissing<T: Codable & DefaultValueProvider>: Codable {
    public let wrappedValue: T
    
    public init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        if let value = try? container.decode(T.self) {
            wrappedValue = value
        } else {
            wrappedValue = T.defaultValue
        }
    }
    
    public init(_ wrappedValue: T?) {
        self.wrappedValue = wrappedValue ?? T.defaultValue
    }
    
    public init() {
        self.wrappedValue = T.defaultValue
    }
    
    public func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(wrappedValue)
    }
}

protocol DefaultValueProvider {
    static var defaultValue: Self { get }
}

extension Bool: DefaultValueProvider {
    static var defaultValue: Bool { false }
}

extension String: DefaultValueProvider {
    static var defaultValue: String { "" }
}

extension Int: DefaultValueProvider {
    static var defaultValue: Int { 0 }
}

extension Double: DefaultValueProvider {
    static var defaultValue: Double { 0.0 }
}

extension KeyedDecodingContainer {
    func decode<T: Codable & DefaultValueProvider>(_ type: DefaultIfMissing<T>.Type, forKey key: Key) throws -> DefaultIfMissing<T> {
        return try decodeIfPresent(type, forKey: key) ?? DefaultIfMissing(nil)
    }
}

Now, if I mark up Person like this, it’ll decode successfully with a default value for any responses that hand me back pancakes when I asked for biscuits:

struct Person: Codable, CustomStringConvertible {
    @DefaultIfMissing var name: String
    @DefaultIfMissing var age: Int
    
    var description: String { "\(name): \(age) years old" }
}

Now, if the response changed the type out from under me…

{
    "name": true,
    "age": 28
}

…I’d still get a back Person with a name set to an empty string, but has the age. And so on. In essence, this protects you against missing values or odd types for those values — and there are many times where this is exactly what I want.

Until next time ✌️

···