Supporting older iOS versions in apps
This article is based on an idea by David Smith as presented in an Under the Radar podcast. His pitch there was to support, for example, new iOS 26 UI features without loosing users who still use older devices that won’t support iOS 26 – while keeping the new iOS 26 code and the pre-iOS 26 code nicely separated.
Each time you release an app, you have to decide what the minimum version of the target operating system. This can be iOS, but all this also applies to MacOS and more.
If you choose a too old version, your code by definition needs to run on that and above. And any newer features you may want to use from newer versions can be wrapped in #available or @available because you have a single app in the App Store that needs to run under a range of iOS versions.
If you choose a too new version, users who haven’t or cannot upgrade can continue running a previously installed old version of your app. But any potential new users CANNOT access that older version of the app: the App Store will not offer any version of your app (and will simply not mention your app at all). This is an Apple Story thing: Apple could offer ancient app versions for (new) installation on ancient devices. But it doesn’t.
So you can decide to simply give up on acquiring the part of your potential users who happen to run ancient devices. Costing you a few percent of market share. Or in my case, disappointing users who search for the app, can’t find it, and have no alternative to install instead. Or you can decide that your most current app version needs to support older iOS versions (say 17) up to and including the latest iOS version (say 26). By default this is nice for users but not great for the developers.
Requirements
The use case presented by Smith was pretty specific (but the solution has more applications):
- Smith has an app (a hiking app, Pedometer++) that supports both iOS with an optional WatchOS add-on. WatchOS versioning apparently gets particularly nasty because WatchOS and iOS versions don’t (didn’t?) have to match. I don’t have the WatchOS complication (no pun intended).
- Smith wants to deliver apps to first-time users, even if they are still on older hardware.
- Smith prefers, for development reasons, to develop new code in the app without having to constantly check whether the code breaks for users of lots of iOS versions.
- As a compromise between user needs and developer needs, Smith considers that it is ok if (new) users on older devices do not receive regular updates (of the UI side of the code).
All this means that users on older platforms have a user interface that is essentially frozen while users on newer platforms get a stream of updates that keep the app updated. Multiple target platforms itself implies having multiple versions of code (if only that APIs get deprecated and ultimately obsoleted), but the questions is how fine or course-grained to make the code that deals with the different environments.
Fine-grained conditional code
Swift allows you do code variants at a fine-grained level. This means enabling/disabling one or more lines of code:
func myFunction() {
if #available (iOS 26, *)
/* iOS 26 code goes here */
else {
/* fallback code for all pre-26 versions supported by app goes here */
}
}
Medium-grained conditional code
Alternatively you go for medium–grained granularity where you essentially prefix specific functions, structs or classes with information about where to run. This saves you an additional nesting level because you only specify the condition at the start, and use some existing Swift block scopes to know when the condition ends:
@available(iOS 26)
func myFunction2626() { /* one or more iOS 26 lines of code go here */ }
@available(iOS, obsoleted: 26.0, message: "Please use 'myFunction2626' for iOS26 and up")
func myFunction1718() { /* code for iOS 17 and iOS 18 goes here */ }
// note: You will need to use "obsoleted: 19.0". Obsoleting in iOS26 isn't supported yet.
// note: Versions before iOS 17 are blocked by the minimum iOS req'd for the entire app.
// note: This would be cleaner with (non-existent) Swift support @unavailable(iOS 26).
Course-grained conditional code
If I understand Smith’s explanation correctly, he take this even further in some apps: have two (or potentially more) copies of the SwiftUI files where the a file name suffix indicates which version of iOS are being targeted.
This introduces lots of redundancy in the code, but gives a clear isolation between the variants. Code redundancy is (as Smith states in the podcast) historically considered to be evil because multiple locations need to maintained per change. But what if we simply decide to in principle stop maintaining the older UI code?
In the most extreme case you could compare this to having multiple app versions in the App Store – thus caterings to (very) old device and new devices. And only the latest app version gets updated: normal feature enhancements, use of latest iOS features, most bug fixes, and general code maintainace. But remember that Apple doesn’t provide this service in the App Store. You could have a MyApp v2 and MyApp v3 in the App Store to achieve this but this has other implications for licensing, reviews and statistics.
So how can we make this model work without too many hacks? The Podcast mentions that the entire tree of dependent UI Views gets duplicated and renamed. And thus the tree’s root or entry point View needs to be selected conditionally:
// code for top level View or Scene where the UI is rooted
var body: some Scene {
WindowGroup {
if #unavailable(iOS 26.0) {
MyContentView1718()
} else {
MyContentView2626()
}
}
}
Note that this section makes on-time use of fine-grained granularity. But is also the place where the code switches over to course-grained granularity: the Views are separate, are labelled with a suffix, and can be contained in separate files with names like MyContentView1718.swift.
Obviously renaming the files is with a suffix is not enough because the compiler doesn’t really care about file names. So you you need to mark all top level declarations with course grained conditions
@available(iOS 26.0, *)
struct MyContentView2626: View {
// declarations, body() and supporting functions and types in MyContentView go here
}
The @available(iOS 26.0, *) isn’t strictly needed because adherence to the naming convention “should” keep everything separate. But @available helps avoid the iOS 17/18 version from accessing the iOS 26 version’s code with a compile-time error (‘MyContentView2626’ is only available in iOS 26.0 or newer”).
This pattern applies to any other top-level declarations that may be in your source file. This will often include a struct needed for Previewing. Fortunately the compiler will give you with an invalid redeclaration error if you forget to rename the item.
@available(iOS 26.0, *)
struct MyVector2626 {
// swiftlint:disable:next identifier_name
var x, y: Int
}
Open questions
- Do we need a suffix on the most current file names and defintions?
Technically it is not needed: MyContentView1617 and MyContentView are just as distinguishable as MyContentView1617 and MyContentView2626. But having the suffix helps document the validity and prevent errors if you start off with code without a suffix and forget to make a change. - Create variants of files that would be identical except for their names?
Say a View is indentical in all variants. Should we create and rename copies even if they are identical? Maybe best to perform the conversion lazily rather than eagerly. In any case it helps document code if there is only MyStruct in a single file rather than MyStruct1618 and MyStruct2626 that are identical except for 1618 and 2626 suffixes.