Simplifying Backwards Compatibility in Swift

Every year as new OS and Swift versions are released, the question comes up over and over again: “how do I use this new thing while also supporting older versions?”. While we have a bunch of “availability” tools at our disposal (and I’ll be using them in this post), they always come across as somewhat cumbersome: we need to do inline checks, or we have conditional logic flow that obfuscates the intent of some of our code, and so on.

A little while ago I was thinking about this problem and came up with a technique that helps clarify some (but not all) aspects of this problem.

When I design APIs, I like to start from the end result and design backwards. I start with the question of “how do I want to use this?” or “what is the least-intrusive way to make this?”. Good APIs have minimal interfaces that allow clients to quickly solve their problems without unnecessarily burdening them with boilerplate or complicated configuration and control flow.

With this in mind, I came up with Backport.

The definition of Backport is trivial:

public struct Backport<Content> {
    public let content: Content

    public init(_ content: Content) {
        self.content = content
    }
}

At first glance, this doesn’t look very useful; it’s a struct that holds a single value, and it doesn’t do anything. This is by design. Backport exists to serve as a holding space (namespace) for shims: the conditional code we must write in order to do proper availability checking. Let’s look at a specific example for how we can do this.

Backporting Methods

In iOS 15, SwiftUI added some modifiers to describe badges on list rows and tab items:

extension View {
    public func badge(_ count: Int) -> some View
    public func badge(_ label: Text?) -> some View
    public func badge(_ key: LocalizedStringKey?) -> some View
    public func badge<S>(_ label: S?) -> some View where S : StringProtocol
}

These are really useful, but have the downside that, if you’re supporting iOS 14 or earlier, you get a compiler error if you try to use these directly:

someTabView
    .badge(42) // error: 'badge' is only available in iOS 15.0 or newer

This is where Backport can help:

extension View {
    var backport: Backport<Self> { Backport(self) }
}

extension Backport where Content: View {
    @ViewBuilder func badge(_ count: Int) -> some View {
        if #available(iOS 15, *) {
            content.badge(count)
        } else {
            content 
        }
    }
}

Here we’re doing a couple of things:

  1. We’re saying that every View has a .backport property that returns a Backport which holds the view
  2. When a Backport holds a view, it gets this badge(_ count: Int) method.
  3. This method does the check to see if the SwiftUI version of .badge() is available. If it is, it passes the parameter on to the system implementation
  4. If the SwiftUI method is not available, it falls back (“backports”) to some other implementation, which is up to you. In the example above, it simply ignores the parameter and returns the un-modified view.

This means that we can now do:

someTabView
    .backport.badge(42) // no error, and will have a badge if the app is running on iOS 15 or later

Backporting Types

This idea of using Backport as a namespace can apply to types as well. iOS 15 also added a new kind of view, called AsyncImage. This is a view that can handle loading an image from an asynchronous source, like a URL. In many respects, it is similar to the popular SDWebImage (and similar) package.

However, like with .badge(…), if you try to use it while also supporting iOS 14, you’ll get a compiler error:

AsyncImage(url: someImageURL) // error: 'AsyncImage' is only available in iOS 15.0 or newer

Again, let’s start with what we want the final result to look like and use that to work towards an implementation:

Backport.AsyncImage(url: someImageURL)

There are two ways we could implement this; I’ll show both. The first way is what you might expect, with a nested type:

// a same-type requirement of "Content == Any" is needed to help 
// the compiler figure out what to do.
// Thanks to type inference, we won't need to actually type 
// "Backport<Any>"; the compiler can figure it out when we 
// type `Backport.AsyncImage…`
//
// We'd only need to specify the `<Any>` parameter if a different
// extension also declared a nested `AsyncImage` symbol.

extension Backport where Content == Any {

    struct AsyncImage: View {
        let url: URL?

        var body: some View {
            if #available(iOS 15, *) {
                SwiftUI.AsyncImage(url: url)
            } else {
                MyCustomAsyncImage(url: url)
            }
        }
    }

}

This approach works well when you have a new type that has a single main entry point for configuration (ie, some sort of designated initializer). However AsyncImage also lets you specify other parameters and options, and it might make the intermediate Backport.AsyncImage struct a bit unwieldy to support them all. So, another approach you could take is to use a static function that looks like a type:

extension Backport where Content == Any {
    
    @ViewBuilder static func AsyncImage(url: URL?) -> some View {
        if #available(iOS 15, *) {
            SwiftUI.AsyncImage(url: url)
        } else {
            MyCustomAsyncImage(url: url)
        }
    }

}

This has the same callsite syntax as the nested type (ie, they look identical at the point-of-use), but it doesn’t have the intermediate struct that has to potentially worry about holding a bunch of different configuration values or variations.

Pick the approach that feels simplest and easiest to maintain to you.

Note: This trick of using a capitalized function to “fake” a type name is one I find pretty handy. It bends the naming convention rules a bit, but in my opinion, the goal of “clarity at the callsite” is one that overrides all others.

KeyPaths and Property Wrappers

Unfortunately, I have not come up with a good way to backport things like specific properties on SwiftUI’s EnvironmentValues, such as .headerProminence. In theory it should be similar to backporting methods, however every time I’ve gotten into attempting to implement this, I’ve run up against walls. The main problem I’ve found is one of types; for pre-iOS 15 devices, I’d need the type of the property to be Backport.Prominence, and in the other case I’d want it to be SwiftUI.Prominence. I’ve yet to find a satisfactory way of making this work.

Of course, if I ever come up with a way, I’ll make another post.

Upgrading

Eventually, your app will drop support for iOS 14 and these shims will no longer be necessary. At this point, the easiest thing to do is to delete the no-longer-necessary types in your Backport extensions and rebuild your app. You’ll get errors that (for example) Value of type 'Backport<Text>' has no member 'badge'. This will reveal all the points in your code where you were using the backported .badge method. Go through these one by one, delete the intermediate .backport part of the call, and everything should compile again.

Alternatively, you could remove the if #available(…) check from the backport implementation and leave all those calls in place, but you would end up with an extraneous level of indirection in a bunch of callsites.


Even though I’ve not found a way to use this in every scenario, I have found this to be useful in most scenarios. Using Backport like this has some really nice advantages:

  • if I keep my backport implementations organized, I have a one-stop shop in my codebase for where all my compatibility shims exist. This makes keeping track of them a bit easier.
  • identifying usage throughout my codebase is also pretty easy. I search in my project for backport. and that finds methods and types.
  • since Backport<Content> isn’t a SwiftUI View, it doesn’t interfere with SwiftUI’s underlying graph engine. In fact, SwiftUI never sees any intermediate view (unless I explicitly make one, such as one of the Backport.AsyncImage options).
  • since Backport<Content> has no limits on its Content, this can be used for backporting just about any kind of new functionality from a system framework.
  • by naming the methods and properties on Backport the same as their framework counterparts, searching through the codebase for how a system API gets used still points to the “right” points in my code
  • using the same names also makes migrating off of this trivial: I delete the backport. characters and it Just Works™.

How do you do compatibility shims and availability checking in your apps? Have you found something that works well for you? What challenges do you face adopting new APIs each year?


This originally appeared as a few posts on my twitter account:

Also as a blog post from Ralf Ebert, which he wrote with my blessing after learning about this technique from me:


Related️️ Posts️

Core Data and SwiftUI
Custom Property Wrappers for SwiftUI
Exploiting String Interpolation For Fun And For Profit
HTTP in Swift, Part 18: Wrapping Up
HTTP in Swift, Part 17: Brain Dump
HTTP in Swift, Part 16: Composite Loaders
HTTP in Swift, Part 15: OAuth
HTTP in Swift, Part 14: OAuth Setup
HTTP in Swift, Part 13: Basic Authentication
HTTP in Swift, Part 12: Retrying
HTTP in Swift, Part 11: Throttling
HTTP in Swift, Part 10: Cancellation
HTTP in Swift, Part 9: Resetting
HTTP in Swift, Part 8: Request Options
HTTP in Swift, Part 7: Dynamically Modifying Requests
HTTP in Swift, Part 6: Chaining Loaders
HTTP in Swift, Part 5: Testing and Mocking
HTTP in Swift, Part 4: Loading Requests
HTTP in Swift, Part 3: Request Bodies
HTTP in Swift, Part 2: Basic Structures
HTTP in Swift, Part 1: An Intro to HTTP
Anything worth doing...
Introducing Time
Conditional Compilation, Part 3: App Extensions
Conditional Compilation, Part 2: Including and Excluding Source Files
Conditional Compilation, Part 1: Precise Feature Flags
Swift Protocols Wishlist