Making iOS app compatible with older iOS versions without hustle
I am an iOS developer developing the best-rated banking app in Germany from before it went beta 🇩🇪 I focus on all the aspects of software engineering, from writing good code to developing scalable architectures to enhancing UX and other not-so-much-code-related things. I also put a lot of attention to establishing communication flows and creating a great atmosphere. One of my other focuses is providing a constant flow of cats and memes all over the place. I think in this part I succeeded the most.
Imagine: there is a cool new view modifier that makes your app way better... but you cannot use it because you still support some not so old iOS versions.
Dropping the those iOS versions is not an option – there are too many customers. And one cannot just abandon people in need [more on the human aspect on of that at the bottom].
Let's explore some ways of how we can use the most modern code in our apps.
Example
scrollDismissesKeyboard is only there for the lucky owners of iOS 16
ScrollView {
TextField("Fluffy", text: $catName)
}
.scrollDismissesKeyboard(.automatic) // 'scrollDismissesKeyboard' is only available in iOS 16.0 or newer
Solutions
Solution 1, in your face
The standard in-your-face solution is to have an OS conditional.
if #available(iOS 16.0, *) {
ScrollView {
TextField("Fluffy", text: $catName)
}
.scrollDismissesKeyboard(.automatic)
} else {
ScrollView {
TextField("Fluffy but older", text: $catName)
}
}
It will work but it adds code duplication. It is especially hard if there is a lot going on, like many modifiers and views all coming together. One option is always to extract it to a subview or a function but doing so only because of the modifier is not a cool idea.
Solution 2, concept that sadly does not work
We can create a special modifier, like a conditional .if/.ifLet [example].
ScrollView {
TextField("Fluffy", text: $catName)
}
.modifiedIfIOS16 { view in
view.background(Color.red)
}
While the .if/.ifLet and the rest of the family suffer from certain bugs, the OS-conditional is fine. It never changes while the app is running! Modifier implementation example:
@ViewBuilder
func modifiedIfIOS16<Content: View>(transform: (Self) -> Content) -> some View {
if #available(iOS 16.0, *) {
transform(self)
} else {
self
}
}
In my opinion, this solution also looks neater than what we had in the first one. But have you spotted the problem? Compiler does not know that inside of modifiedIfIOS16 block it is safe to use iOS16 code. So while it is good for my artificial colouring example, it does not solve our issue. Unless you wrap the thing into another if but then.. what’s the difference, right?
ScrollView {
TextField("Fluffy", text: $catName)
}
.modifiedIfIOS16 { view in
if #available(iOS 16.0, *) {
view
.scrollDismissesKeyboard(.automatic)
} else {
view
}
}
Solution 3, the neat and working one
We just need to create modifiers that conditionally apply the logic that is missing in the older iOS versions. I have a couple of examples that I have written today. You can notice, I was deep into the scroll views ;)
public struct HideScrollIndicatorsIfAvailableModifier: ViewModifier {
public init() {}
public func body(content: Content) -> some View {
if #available(iOS 16.0, *) {
content
.scrollIndicators(.never)
} else {
content
}
}
}
public struct ScrollTargetLayoutIfAvailable: ViewModifier {
public init() {}
public func body(content: Content) -> some View {
if #available(iOS 17.0, *) {
content
.scrollTargetLayout()
} else {
content
}
}
}
public struct ScrollTargetBehaviorViewAlignedIfAvailable: ViewModifier {
public init() {}
public func body(content: Content) -> some View {
if #available(iOS 17.0, *) {
content
.scrollTargetBehavior(.viewAligned)
} else {
content
}
}
}
It’s super straightforward and does not require any hacks or much space. Just drop them into your custom modifiers folder and you have saved your app for thousands of people.
Usage is simple
ScrollView(.horizontal) {
HStack(spacing: 8) {
ForEach(state.bars) { bar in
FancyBarView(bar: bar)
}
.modifier(ScrollTargetLayoutIfAvailable())
}
.modifier(ScrollTargetBehaviorViewAlignedIfAvailable())
.modifier(HideScrollIndicatorsIfAvailableModifier())
Extension to solution 3
You can make the approach more dynamic. Sometimes you might need to specify a parameter. Then you can pass it into init. And you can create special modifiers to not wrap the modifier in .modifier modifier.

Here is an accessibility example from the older days when we also supported iOS 14.
struct AccessibilityHiddenIfAvailable: ViewModifier {
private let isHidden: Bool
init(_ isHidden: Bool) {
self.isHidden = isHidden
}
func body(content: Content) -> some View {
if #available(iOS 14.0, *) {
content.accessibilityHidden(isHidden)
} else {
content
}
}
}
public extension View {
func accessibilityHiddenIfAvailable(_ isHidden: Bool) -> some View {
modifier(AccessibilityHiddenIfAvailable(isHidden))
}
}
Summary and a bit of lyrics
The majority of our users are on the newest iOS versions. But we cannot – and often should not – kick the other people. If it were only about iOS 13 and 14 where everyone can in theory update their phone that is ok. But now, after iOS 15, it feels like each release is there to kick some more people – or squeeze some extra money and make them change the working phones for almost the same phones but with a different number in the model name.
There might be not too many of them. For example, our customer base has around 2% of people that are on iOS 16 or below. And those users might be not the best paying customers. But if we can let them use our apps for a little longer without much hustle, it’s worth it.