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:
- We’re saying that every
View
has a.backport
property that returns aBackport
which holds the view - When a
Backport
holds a view, it gets thisbadge(_ count: Int)
method. - 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 - 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 SwiftUIView
, 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 theBackport.AsyncImage
options). - since
Backport<Content>
has no limits on itsContent
, 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:
A handy Swift convenience thing I came up with recently (1/n):
— Dave DeLong (@davedelong) October 7, 2021
struct Backport<Content> {
let content: Content
}
extension View {
var backport: Backport<Self> { Backport(content: self) }
}
Also as a blog post from Ralf Ebert, which he wrote with my blessing after learning about this technique from me:
There is an even better way to use iOS-15-only View modifiers in older iOS versions: https://www.ralfebert.de/swiftui/backporting-ios-view-modifiers/
— Ralf Ebert (@RalfEbert) October 7, 2021
Thanks @davedelong https://twitter.com/davedelong/status/1446151822800945155 pic.twitter.com/ELsFjWuuGM