How to manage SwiftUI updates with Swift available

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.

Xcode target settings
how to set the minimum deployment target in Xcode

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:

Xcode shortcut to jump to definition
Control-click on the code, will show you more options. Tap on ´Jump to Definition´.
Reading Swift declaration code to check availability
Looking at the definition of the SwiftUI Color cyan. On the top, before the extension, you can see that it is available for iOS 13. Some colors like cyan were added later. One line above cyan, you can see it is available for iOS 15.

The second option would be to use the developer documentation:

how to open the xcode developer documentation
Option-click in Xcode shows you a little helper info. There you can find a link to the developer documentation.
How to read platform availablity in Xcode documentation
When you are opening a page in the Xcode documentation, you can find all the important information about an API. At the top of the page is shown what platform versions the API is available. For cyan, we find that it is available for iOS 15, macOS 12, tvOS 15, watchOS 8, and higher.

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:

example for a Xcode error with unavailable
Error warning in Xcode, if you use a new tool than your mimimum deployment target.

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.

available attribute with Swiftui view body
Adding an available attribute in front of the SwiftUI view body will give an error.

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.

available attribute with Swiftui view
Adding an available attribute in front of a SwiftUI view.

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.

SwiftUI presentation detents example
For iOS 16+, you can use presentation detents with SwiftUI sheet and popover.

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<VIPresentationDetent>) -> 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.

example of a deprecated API in SwiftUI
example of a new API in SwiftUI 4

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

Leave a Comment

Subscribe to My Newsletter

Want the latest iOS development trends and insights delivered to your inbox? Subscribe to our newsletter now!

Newsletter Form