A tiny, dependency-free Swift Package for supporting multiple iOS versions in
SwiftUI - without breaking modifier chains, duplicating whole views, or
scattering if #available checks across a codebase.
It implements the patterns from two articles as a real, tested API instead of helpers you copy-paste into every project:
- Supporting Multiple iOS Versions in SwiftUI Without Turning Your Views Into a Mess
- Handling different iOS versions in a View body
.modify { }- branch on#availableinside a modifier chain instead of breaking it.backport- a namespace for reusable, version-gated shims that read like the real APIplatformValue(_:ifAtLeast:else:)- pick a plain value by OS version without a new function per threshold- Four backports included out of the box:
onChange,scrollBounceBehaviorBasedOnSize,scrollClipDisabled,presentationDetents - Zero dependencies, fully unit tested, with a runnable example app
- The problem
- Requirements
- Install
- Quick start
- API
- Which one do I use?
- Writing your own backport
- Full example
- Project layout
- Tests
- Example app
- Contributing
- License
A SwiftUI modifier chain is one continuous expression - every modifier returns a new view that becomes the input to the next one:
Text("Hi")
.font(.title)
.foregroundStyle(.primary)
.padding()Dropping an if #available into the middle of that breaks it - an if
statement isn't a modifier and can't be chained:
// Does not compile.
Text("Hi")
.font(.title)
if #available(iOS 17, *) {
.contentTransition(.numericText())
}
.padding()So teams end up either duplicating the entire view per OS-version branch:
// Works, but now every future change to this view has to happen twice.
if #available(iOS 17, *) {
Text("Hi")
.font(.title)
.foregroundStyle(.primary)
.contentTransition(.numericText())
.padding()
} else {
Text("Hi")
.font(.title)
.foregroundStyle(.primary)
.padding()
}or restructuring the whole view around the check, which gets worse every time a new OS version adds one more thing that needs gating. SwiftUIBackportKit exists so neither of those has to happen.
- iOS 15.0+
- Swift 5.9+
- Xcode 15+
macOS 12+ is also declared in Package.swift - not because this is a
macOS-focused package, but so the package (and its test suite) builds and
runs via plain swift build / swift test on a Mac without needing an iOS
simulator. Every backport checks both platforms' actual minimum OS version
(e.g. #available(iOS 17, macOS 14, *)), so behavior on each platform
matches that platform's real API availability.
Xcode: File → Add Package Dependencies… and enter:
https://github.com/EmadBeyrami/SwiftUIBackportKit.git
Package.swift:
dependencies: [
.package(url: "https://github.com/EmadBeyrami/SwiftUIBackportKit.git", from: "1.0.0")
].target(
name: "YourApp",
dependencies: ["SwiftUIBackportKit"]
)Then in any file:
import SwiftUIBackportKitimport SwiftUI
import SwiftUIBackportKit
struct RowView: View {
let title: String
var body: some View {
Text(title)
.font(.headline)
.modify {
if #available(iOS 17, *) {
$0.contentTransition(.numericText())
} else {
$0
}
}
.padding(.vertical, platformValue(12, ifAtLeast: OSVersion(17), else: 8))
}
}The core building block every other API in this package is built on. It
wraps self in a @ViewBuilder closure, so the branch happens inside the
chain instead of breaking it:
Text("Hi")
.font(.title)
.modify {
if #available(iOS 17, *) {
$0.contentTransition(.numericText())
} else {
$0
}
}
.padding()It composes anywhere a modifier would - mid-chain, nested, repeated:
VStack {
Text("Total")
Text(amount, format: .currency(code: "USD"))
}
.modify {
if #available(iOS 17, *) {
$0.symbolEffect(.bounce, value: amount)
} else {
$0
}
}
.modify {
if #available(iOS 16, *) {
$0.scrollBounceBehavior(.basedOnSize)
} else {
$0
}
}It also works for more than two branches:
Image(systemName: "star")
.modify {
if #available(iOS 18, *) {
$0.symbolEffect(.wiggle)
} else if #available(iOS 17, *) {
$0.symbolEffect(.bounce)
} else {
$0
}
}The one rule: every branch must return a view, including else.
Omitting else - or forgetting to return $0 - silently removes the view on
whichever OS versions take that branch:
// Bug: renders nothing at all pre-iOS 17.
.modify {
if #available(iOS 17, *) {
$0.contentTransition(.numericText())
}
}.modify is great for a one-off check. Once the same #available branch
starts showing up in more than one place, promote it into .backport - a
namespace that reads exactly like the real SwiftUI API and hides the
branching entirely:
List(items) { item in
Row(item)
}
.backport.scrollBounceBehaviorBasedOnSize()
.backport.scrollClipDisabled()
.backport.presentationDetents([.medium, .large])
.backport.onChange(of: selectedTab, initial: true) { old, new in
analytics.track(from: old, to: new)
}Every member of Backport is responsible for its own availability check and
its own pre-availability fallback - call sites never think about #available
at all.
| API | Backports | Pre-availability behavior |
|---|---|---|
.backport.onChange(of:initial:_:) |
iOS 17's two-value onChange |
Synthesizes (old, new) on top of the single-value onChange that's always existed |
.backport.scrollBounceBehaviorBasedOnSize(axes:) |
iOS 16.4's scrollBounceBehavior(_:axes:) |
No-op - keeps the default bounce behavior |
.backport.scrollClipDisabled(_:) |
iOS 17's scrollClipDisabled(_:) |
No-op - content stays clipped, the universal pre-17 default |
.backport.presentationDetents(_:) |
iOS 16.4's presentationDetents(_:) |
No-op - sheet renders at its default size |
onChange - the one every app hits. Before iOS 17, onChange only
handed you the new value; getting the old one meant hand-rolling your own
@State cache at every call site:
// Before - repeated at every call site that needs the old value.
struct SearchView: View {
@State private var query = ""
@State private var previousQuery = ""
var body: some View {
TextField("Search", text: $query)
.onChange(of: query) { newValue in
logSearch(from: previousQuery, to: newValue)
previousQuery = newValue
}
}
}// After.
struct SearchView: View {
@State private var query = ""
var body: some View {
TextField("Search", text: $query)
.backport.onChange(of: query) { old, new in
logSearch(from: old, to: new)
}
}
}On iOS 17+ this forwards straight to the system API (including initial:).
Below that, it caches the previous value internally and calls your closure
with (old, new) - you never see the difference.
presentationDetents - bridging a type that doesn't exist pre-16.4.
PresentationDetent itself isn't available before iOS 16.4, so the backport
defines its own PresentationDetentCompat enum that mirrors the real cases
without ever naming the unavailable type outside an #available-guarded
context:
.sheet(isPresented: $showingSheet) {
DetailView()
.backport.presentationDetents([.medium, .large, .fraction(0.4), .height(220)])
}Pre-16.4, the sheet just renders at its default size - there's no equivalent API to fall back to, so this is a no-op rather than an approximation.
scrollBounceBehaviorBasedOnSize / scrollClipDisabled - the common
case. No new type appears in either signature, so the shim is a plain
#available branch with a no-op fallback:
ScrollView {
content
}
.backport.scrollBounceBehaviorBasedOnSize()
.backport.scrollClipDisabled()Sometimes the only thing that changes between OS versions is a constant - a
corner radius, a font size, a spacing value. #available is overkill here,
and the naive fix (a hardcoded function per version pair, e.g.
func value(new: T, old: T) -> T { if #available(iOS 26, *) { new } else { old } })
means writing a brand-new function every time a new OS version needs a new
threshold. platformValue uses a runtime version check instead, so any
version works without a new declaration:
.cornerRadius(platformValue(20, ifAtLeast: OSVersion(17), else: 12))
.font(.system(size: platformValue(34, ifAtLeast: OSVersion(26), else: 28)))
.padding(platformValue(16, ifAtLeast: OSVersion(16, 4), else: 12))OSVersion takes major, minor, and patch, so precise thresholds like
"iOS 16.4" are just OSVersion(16, 4).
You can also call the underlying check directly when you just need a
Bool:
if OS.isAtLeast(OSVersion(17)) {
startNewOnboardingFlow()
} else {
startLegacyOnboardingFlow()
}Important caveat: this is a runtime check, not a compile-time one.
It's only safe for choosing between two already-available values - two
CGFloats, two Colors, two cases of an enum that exists on every
deployment target you support. It does not teach the compiler anything about
API availability. This will not compile, and shouldn't - the compiler can't
verify a runtime condition:
// Won't compile: `ScrollBounceBehavior` isn't available pre-iOS 16.4,
// and `platformValue` can't tell the compiler otherwise.
let behavior = platformValue(ScrollBounceBehavior.basedOnSize, ifAtLeast: OSVersion(16, 4), else: .automatic)For anything that references a version-gated type or method, use
.modify or .backport so #available does the real compile-time check.
| Situation | Use |
|---|---|
| One-off check, used in a single place | .modify { } |
| Same check reused across multiple views | .backport, with a new extension on Backport where Content: View |
| Only a constant changes between OS versions | platformValue(_:ifAtLeast:else:) |
Need a plain Bool for non-view logic (e.g. picking an onboarding flow) |
OS.isAtLeast(_:) |
Every built-in shim follows the same shape, so adding one is mechanical.
Here's the actual source for scrollClipDisabled, included in this package,
as a template:
import SwiftUI
public extension Backport where Content: View {
/// Backport of `.scrollClipDisabled(_:)`, added in iOS 17. A no-op
/// before that - content stays clipped to the scroll view's bounds,
/// which is the pre-17 default everywhere.
@ViewBuilder
func scrollClipDisabled(_ disabled: Bool = true) -> some View {
if #available(iOS 17, macOS 14, *) {
content.scrollClipDisabled(disabled)
} else {
content
}
}
}The recipe:
extension Backport where Content: View { ... }- this is what makes.backport.yourMethod()resolve.@ViewBuilder func yourMethod(...) -> some View- mirror the real API's parameter list as closely as you can, except drop any parameter whose type doesn't exist pre-availability (see the next point).- Inside, branch on
#availableand call the real API oncontentin thetruebranch; returncontentunmodified (or the closest equivalent) in theelse. - If the real API takes a type that doesn't exist on older OS versions
(like
PresentationDetent), don't put that type in your signature - define your own compat enum/struct with the same cases, and convert to the real type only inside an@available-guarded computed property. SeeSources/SwiftUIBackportKit/Modifiers/Backport+PresentationDetents.swiftfor the full pattern. - Add a compile-smoke test in
Tests/SwiftUIBackportKitTests/CompileSmokeTests.swiftthat calls the new method - SwiftUI view trees aren't easily inspectable without a hosting environment, so these tests exist to catch signature drift, not to assert on pixels.
A settings row that uses all three APIs together:
import SwiftUI
import SwiftUIBackportKit
struct SettingsRow: View {
@State private var isEnabled = true
@State private var selectedTheme = "System"
var body: some View {
VStack(alignment: .leading, spacing: platformValue(12, ifAtLeast: OSVersion(17), else: 8)) {
Toggle("Enable Notifications", isOn: $isEnabled)
.modify {
if #available(iOS 17, *) {
$0.symbolEffect(.bounce, value: isEnabled)
} else {
$0
}
}
Picker("Theme", selection: $selectedTheme) {
Text("System").tag("System")
Text("Light").tag("Light")
Text("Dark").tag("Dark")
}
.backport.onChange(of: selectedTheme) { old, new in
applyTheme(from: old, to: new)
}
}
.padding(platformValue(16, ifAtLeast: OSVersion(16, 4), else: 12))
.backport.scrollBounceBehaviorBasedOnSize()
}
private func applyTheme(from old: String, to new: String) {
// ...
}
}Sources/SwiftUIBackportKit/
Core/
View+Modify.swift - .modify { }
Backport.swift - the Backport<Content> namespace + .backport
PlatformValue.swift - OSVersion, OS.isAtLeast, platformValue
Modifiers/
Backport+OnChange.swift - .backport.onChange(of:initial:_:)
Backport+ScrollBounceBehavior.swift
Backport+ScrollClipDisabled.swift
Backport+PresentationDetents.swift
Tests/SwiftUIBackportKitTests/
PlatformValueTests.swift - logic tests for OS.isAtLeast / platformValue
CompileSmokeTests.swift - signature/composition tests for every public API
Example/
SwiftUIBackportKitExample.xcodeproj - depends on this package via a local path
SwiftUIBackportKitExample/Demos/ - one SwiftUI view per API, see Example/README.md
swift test
If your command-line toolchain doesn't include XCTest (only Command Line Tools installed, no Xcode), point at an installed Xcode for just this command:
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test
Example/ is a runnable iOS app that depends on this package locally and has
one screen per API - see Example/README.md for what's
in it. Open Example/SwiftUIBackportKitExample.xcodeproj in Xcode and run it
on any iOS 15+ simulator or device.
Issues and pull requests are welcome. If you're adding a new backport, follow the recipe in Writing your own backport and include a compile-smoke test alongside it.
SwiftUIBackportKit is available under the MIT license. See LICENSE for details.