If you’re an iOS developer, it’s important to know how to check the version of iOS that is available. This can be helpful when you need to adapt your code for different versions of the operating system. Luckily, checking the iOS version is a relatively easy process with Swift. In this blog post, we’ll show you how to check the version and offer some tips on easily adapting your code.
https://youtu.be/2IB4CuSRea4
What is the minimum iOS target?
The minimum iOS version is the lowest version your app supports. People with devices for older versions will not be able to see your app in the app store. Your existing users who have older iOS versions installed, won’t be able to download newer updates from the App Store.
How do I change my app’s deployment target?
If you want to change your current target, you’ll need to open your project in Xcode and navigate to the General tab. There, you’ll find a Deployment Info section with a field for setting the iOS deployment target.
Which iOS target should my app support?
If you want to reach as many people as possible, choose a lower iOS version. iOS has a very high adaptation rate than for example Android or macOS users. Most iPhone users update to the newest iOS version in a year. As of February 2023, iPhones are running iOS 16 with 78% and iOS 15 with a 17% share (source: mixpanel). That means setting your app’s target to iOS 15 will allow 95.1% of all users to get the newest updates.
If you set your target lower, you need to conditionally use any APIs that are available only in newer versions. Code maintenance can become more difficult. The overall development cost can increase significantly depending on the complexity of the change.
Typically companies support the current and the 2 previous versions.
How do I check the current iOS version of an API?
If you want to find out for what version you can use a certain tool in Xcode, there are a couple of resources you can use. First, you can jump to definition:
The second option would be to use the developer documentation:
Conditional Handling with if #available
You can check the iOS version with the available attribute. Here’s an example of how you can check the iOS version and adapt your code accordingly:
if #available(iOS 14, *) {
// Use iOS 14 APIs
} else {
// Fallback to earlier iOS versions
}
In this example, the code inside the if
block will only be executed if the device is running iOS 14 or later. If the device is running an earlier version of iOS, the code inside the else
block will be executed instead.
This allows you to write code that takes advantage of new APIs in the latest version of iOS, while still providing a fallback for earlier versions.
How to only apply code for the non-available case?
The most obvious solution is to use an if/else statement and ignore the if case.
if #available(iOS 14, *) { } else {
// API for earlier versions
}
You can also use a guard statement like the following:
guard #available(iOS 14, *) { } else {
// API for earlier versions
return
}
Swift 5.6 introduces the unavailable attributed. The following example illustrates how #unavailble can make your code more readable and clean.
if #unavailable(iOS 14, *) { } {
// API for earlier versions
}
Version check with multiple platforms
If you have a multi-platform SwiftUI project, you likely need to also include the other platform checks:
if #available(iOS 14.0, macOS 11.0, watchOS 8.0, *) {
// Use new APIs
} else {
// alternative for earlier versions
}
Available Attribute
If you want to make a struct or class available only for higher versions you can use the available attribute:
@available(iOS 10.0, macOS 10.12, *)
class MyClass {
// ....
}
Similarly, the same works for a function or property marked with @available:
@available(iOS 15.0, macOS 12.0, *)
func createFormattedDate() -> String {
//...
}
@available(iOS 16.0, macOS 13.0, *)
var myProperty: Bool = false
What is the difference between @available and #available Swift?
The difference between the two attributes is that @available applies to an entire class, property, or function. On the other hand, #available can be used to conditionally execute a certain piece of code.
How to effectively provide backward compatibility for SwiftUI
SwiftUI was introduced in 2019 and we saw quite a few changes. It can be very difficult to keep up with all these updates. If you try to use available checks in your project code, the complexity can increase significantly. This can make your code hard to read and maintain. But don´t panic, I will show you typical examples and some handy techniques to stay ahead of the SwiftUI updating madness.
Example 1: Using a Color that is only available for iOS 15+
As an example, I created a project with a minimum deployment target of 14. In one of the views, I want to use the color cyan, which is only available for iOS 15 and higher. Xcode will quickly show me an error:
Xcode gives me 3 solutions for this error. Choosing the first suggestion if #available, will change the code like so:
struct ColorView: View {
var body: some View {
Group {
if #available(iOS 15.0, *) {
Color.cyan
} else {
Color.blue
}
}
.frame(width: 100, height: 100)
}
}
This code uses cyan for iOS 15 and higher. I choose the color blue as a fallback for lower versions. Notice that I embedded the conditional code in a Group to apply the view modifier frame to both conditions.
I can put ColorView now in any of my other views without needing to think of version checking.
If you choose the second option with @available for the enclosing property, Xcode will add an available attribute to the body property. This will give you a new error because now the SwiftUI view does not have a body for lower iOS versions. This strategy does not work. Unfortunately, Xcode is not smart enough to hide this solution.
Choosing the solution to add @available to the enclosing structure, will make the view itself not available for lower versions. Every time you create an instance of this view now you have to add checks with #available.
The preview is getting an error, which can be easily solved by adding the available attribute:
import SwiftUI
@available(iOS 15.0, *)
struct ColorView: View {
var body: some View {
Color.cyan
.frame(width: 100, height: 100)
}
}
@available(iOS 15.0, *)
struct ColorView_Previews: PreviewProvider {
static var previews: some View {
ColorView()
}
}
You can use ColorView like so:
struct ContentView: View {
var body: some View {
if #available(iOS 15.0, *) {
ColorView()
} else {
// Fallback on earlier versions
}
}
}
In this particular case, I would prefer to use Color like the default Color.cyan. How would we create our own color that is version safe. I start to look at the default implementation. This is where I jump to the definition. Colors are defined in an extension of Color:
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension Color {
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
public static let cyan: Color
}
To define my own color, I would therefore also write a static property in an extension of Color. Because I need to deal with version checking, I compute the Color depending on the version. Now viCyan (version independent cyan) can be used like the default colors. I deld with available once and can use the color implementation everywhere. This solution is great if you plan to use the code a lot.
extension Color {
static let viCyan: Color = {
if #available(iOS 15.0, *) {
return Color.cyan
} else {
return Color.blue
}
}()
}
struct ColorView: View {
var body: some View {
Color.viCyan
.frame(width: 100, height: 100)
}
}
Another case for color is for shapes to fill or stroke, where a ShapeStyle is expected. You can adapt this by adding another extension to ShapeStyle:
extension ShapeStyle where Self == Color {
public static var viCyan: Color { .viCyan }
}
Circle()
.fill(.viCyan)
Example 2: View modifiers
Conditionally using view modifiers is tricky. Oftentimes you end up with code duplication and hard-to-read code. As an example, I will use presentation detents. Starting with iOS 16, you can now set the height of a sheet. This is interesting if what you show is just a small view.
In the following, you can see an example implementation:
struct ContentVieww: View {
@State private var sheetIsShown: Bool = false
var body: some View {
VStack(spacing: 20) {
Text("Presentation detents are new for iOS 16")
.font(.title)
.padding(.horizontal, 50)
Button {
sheetIsShown.toggle()
} label: {
Text("Show sheet")
}
}
.padding(.bottom)
.sheet(isPresented: $sheetIsShown) {
SheetView()
.presentationDetents([.medium, .fraction(0.85)])
}
}
}
struct SheetView: View {
var body: some View {
VStack {
Text("this is the sheet")
.font(.title)
Group {
Color.red
Color.yellow
Color.green
}
.frame(height: 100)
}
}
}
Using Xcode recommendation to handle the version with an availability condition, will give the following:
.sheet(isPresented: $sheetIsShown) {
if #available(iOS 16.0, *) {
SheetView()
.presentationDetents([.medium, .fraction(0.85)])
} else {
SheetView()
}
}
Although it works, if you want to use presentation detents in a complex view it can become much harder to read. I would much prefer to keep the availability checks out of my main view logic. A different approach would be to write a custom view modifier that handles this once. I can then use the custom view modifier similarly to the original presentationDetents modifier:
extension View {
func viPresentationDetents() -> some View {
if #available(iOS 16, macOS 13, *) {
return self.presentationDetents([.medium, .fraction(0.8)])
} else {
return self
}
}
}
.sheet(isPresented: $sheetIsShown) {
SheetView()
.viPresentationDetents()
}
If we want to pass the variables for detent of type Set<PresentationDetent>, we will run into a problem, because PresentationDetent is only available for iOS 16. We can write our own type that acts as a wrapper:
enum VIPresentationDetent: Hashable {
case medium
case large
case fraction(CGFloat)
case height(CGFloat)
@available(iOS 16.0, *)
func detents() -> PresentationDetent {
switch self {
case .medium:
return PresentationDetent.medium
case .large:
return PresentationDetent.large
case .fraction(let number):
return PresentationDetent.fraction(number)
case .height(let number):
return PresentationDetent.fraction(number)
}
}
}
In order to convert the wrapper to the actual type, I added a function that returns PresentationDetent. I had to mark this with an available attribute. Now I can rewrite the view modifier as:
extension View {
func viPresentationDetents(_ detents: Set) -> some View {
if #available(iOS 16, macOS 13, *) {
let detents = detents.map({ $0.detents() })
return self.presentationDetents(Set(detents))
} else {
return self
}
}
}
The custom view modifier can be used in the same way as the original presentationDetents. I am handling the availability check once, and use the custom view modifier everywhere. For versions where presentation detents are unavailable, the sheet is presented at the full height.
.sheet(isPresented: $sheetIsShown) {
SheetView()
.viPresentationDetents([.medium, .fraction(0.85)])
}
Example 3: View modifiers that are deprecated
Often enough when a version update is released, certain code is deprecated and replaced by new code. You can find a list of the deprecated SwiftUI view modifiers here https://developer.apple.com/documentation/swiftui/view-deprecated
As an example, I will use the accent color modifier, which is deprecated with iOS 16.2 and replaced by tint. With this, you can set some styles of controls.
We could implement the availability checking directly in code:
if #available(iOS 16.0, *) {
Button(action: {}) {
Text("My Button")
}
.tint(Color.purple)
} else {
Button(action: {}) {
Text("My Button")
}
.accentColor(Color.purple)
}
A more reusable approach is to create a custom view modifier:
extension View {
@ViewBuilder
func viAccentColor(_ color: Color?) -> some View {
if #available(iOS 16.0, *) {
self.tint(color)
} else {
self.accentColor(color)
}
}
}
Updates for the Navigation API
Navigation was one of the APIs that got the most changes. In iOS 16.2 and macOS 13.1 NavigationView. We now have to replace it with NavigationStack and NavigationSplitView if we want the best experience for our users. Additionally, the following NavigationLink initializers were also deprecated:
- NavigationLink(isActive:destination:label:)
- NavigationLink(_:isActive:destination:)
- NavigationLink((_:tag:selection:destination:)
- NavigationLink(tag:selection:destination:label:)
If I use a simple example with NavigationView like:
struct ContentView: View {
let emojis = ["😀","😇","😅","🥳","🤬","😠"]
var body: some View {
NavigationView {
List(emojis, id: .self) { emoji in
NavigationLink {
EmojiDetailView(emoji: emoji)
} label: {
Text(emoji)
}
}
.navigationTitle("Title")
}
}
}
struct EmojiDetailView: View {
let emoji: String
var body: some View {
VStack {
Text(emoji)
.font(.title)
}
}
}
Using a basic availability check to replace the NavigationView with NavigationStack will result in:
struct ContentView: View {
var body: some View {
Group {
if #available(iOS 16.0, *) {
NavigationStack {
EmojisListView()
}
} else {
NavigationView {
EmojisListView()
}
}
}
}
}
This works if I use NavigationLink(label:destination). But I lose the possibility to programmatically navigate. With NavigationView I have to use NavigationLink with a binding and for NavigationStack I have to use the path argument and the navigationDestination modifier. I am using 2 state properties that I place inside a class:
class NavigationStateManager: ObservableObject {
@Published var path: [String] = []
@Published var isDetailShown: Bool = false
func goBackToRoot() {
path = []
isDetailShown = false
}
}
struct ContentView: View {
@StateObject var manager = NavigationStateManager()
var body: some View {
Group {
if #available(iOS 16.0, *) {
NavigationStack(path: $manager.path) {
EmojisListView()
.navigationDestination(for: String.self) { emoji in
EmojiDetailView(emoji: emoji)
}
}
} else {
NavigationView {
EmojisListView()
}
}
}
.overlay(GoBackButton()
.frame(maxHeight: .infinity, alignment: .bottom))
.environmentObject(manager)
}
}
struct EmojisListView: View {
@EnvironmentObject var manager: NavigationStateManager
let emojis = ["😀","😇","😅","🥳","🤬","😠"]
var body: some View {
List(emojis, id: .self) { emoji in
if #available(iOS 16.0, *) {
NavigationLink(value: emoji) {
Text(emoji)
}
} else {
NavigationLink(emoji,
isActive: $manager.isDetailShown) {
EmojiDetailView(emoji: emoji)
}
}
}
.navigationTitle("Title")
}
}
I wanted to include an example of programmatic navigation. The GoBackButton needs to push the navigation state back to the root view. I added a handy function in the NavigationStateManager that handles the state:
struct GoBackButton: View {
@EnvironmentObject var manager: NavigationStateManager
var body: some View {
Button(action: {
manager.goBackToRoot()
}, label: {
Text("Go back")
})
}
}
I am not happy with the code, because it is looking too complex. Unfortunately, the types for NavigationStack, which is a collection, and for NavigationView, which is an optional value, don’t go well together. So making this reusable was not possible for me.
Although I much appreciate the new navigation API, I don´t like updating it. I prefer to keep NavigationView in my app until the next update with iOS 17 and then move completely over to NavigationStack and drop support for iOS 15.
Conclusion
Checking the iOS version programmatically with Swift is a straightforward process. It’s important to keep in mind that you should set your minimum iOS deployment target based on your user base and the API features you need. This way, you can ensure that all users have access to the latest updates and features.
We hope this blog post has been helpful in understanding how to check the iOS version with Swift and helping you handle version adaptation with more ease in SwiftUI.
Additional information