Stop Adding a Ternary Every Time a New iPhone Ships: A Table-Driven Resolution Design
Every time a new resolution arrived—iPhone Air, 17 Pro—I was bolting another screen-size ternary onto a 29-branch pile. Here is how I reshaped that into a table-driven design where adding the next device is a one-line data change, with the actual Swift from my wallpaper apps.
As an indie developer maintaining four wallpaper apps in parallel, the chore I most wanted gone was the per-device resolution work that lands with every new iPhone. When the iPhone Air (420×912 pt) and 17 Pro (402×874 pt) appeared, the if and ternary checks that select a background image by screen size were scattered across 29 spots inside a single DefineManager file. Fixing one meant hunting through the other 28 for the spot I had forgotten.
This article is the record of replacing that "branches multiply with every device" structure with a small table-driven design. The code is written so you can lift it straight into your own app.
When branches scatter, new models bite back
The fastest way to show the problem is the original code itself. Back then the check looked like this.
// Before: the same height check lived in 29 places across DefineManagerlet bgName: Stringlet h = UIScreen.main.bounds.heightif h == 956 { // Pro Max bgName = "bg@3x-max"} else if h == 912 { // iPhone Air <- add a line here for each new model bgName = "bg@3x-air"} else if h == 874 { // 17 Pro <- and here bgName = "bg@3x-pro"} else if h == 852 { // 15 / 16 standard bgName = "bg@3x-standard"} else { bgName = "bg@2x-classic"}
The pain is not that the logic is wrong—it is correct. The pain is that the same decision is duplicated, so the "unit of change" no longer matches the shape of the code. One decision (support a new device) explodes into 29 code edits. Miss one during the rewrite and that single device shows a blurry or stretched asset. I once shipped exactly that gap and got crash reports where one model loaded the wrong asset.
There is a second trap hiding in the strict == match. You cannot know the logical resolution of Apple's next device in advance. An unknown device falls into the else branch and grabs the lowest-resolution bg@2x-classic—so the newest, largest screens get the worst image. That is the exact behavior you least want.
Think in "which profile," not "which device"
The pivot was changing the subject of the decision. The old code asked, "Is this device a Pro Max?"—the device is the subject. Replace that with, "Which delivery profile does this screen size belong to?"
What the app actually needs is never the marketing name of the device. It needs only the operational attributes: which asset suffix to load, and what safe area to assume when laying out. So gather those attributes into one value object, put them in a table, and make that table the single source of truth.
With that shift, the number of branches stops depending on the number of devices. There is exactly one decision in code, and adding a device becomes "one more row in the table."
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Move from rewriting screen-size checks in dozens of places on every new device to adding support with a single line of data
✦Learn the concrete Swift implementation—profile table, resolver, launch-time cache—built around device profiles instead of device names, ready to copy into your app
✦Apply a resolver that safely snaps unknown future devices to the nearest profile, plus an XCTest that verifies every simulator size, to your own App Store app today
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
First, define a value object that holds only the attributes the app needs. Skip fuzzy data like the product name; keep what ties directly to asset selection and layout.
import CoreGraphics/// A value object holding only the device attributes the app truly needs.struct DeviceProfile: Equatable { let id: String // internal id, used for logging and asset selection let pointSize: CGSize // logical resolution (pt), normalized to portrait let assetSuffix: String // suffix of the delivered asset let hasDynamicIsland: Bool}/// The single source of truth. A new device is one added row, nothing else.enum DeviceCatalog { static let profiles: [DeviceProfile] = [ DeviceProfile(id: "se_classic", pointSize: CGSize(width: 375, height: 667), assetSuffix: "2x-classic", hasDynamicIsland: false), DeviceProfile(id: "standard", pointSize: CGSize(width: 393, height: 852), assetSuffix: "3x-standard", hasDynamicIsland: true), DeviceProfile(id: "pro_17", pointSize: CGSize(width: 402, height: 874), assetSuffix: "3x-pro", hasDynamicIsland: true), DeviceProfile(id: "air", pointSize: CGSize(width: 420, height: 912), assetSuffix: "3x-air", hasDynamicIsland: true), DeviceProfile(id: "pro_max", pointSize: CGSize(width: 440, height: 956), assetSuffix: "3x-max", hasDynamicIsland: true), ] /// Safety net if nothing matches: a middle-of-the-road profile. static let fallback = profiles[1] // standard}
As the table grows, only data grows. The logic never does. When a new iPhone is announced, append one row with its logical resolution, drop the matching asset into Assets, and you are done. That is the heart of turning 29 edits into one line.
Resolve once at launch, then reuse
Next, a resolver that looks up a profile from a screen size. Two traps get handled here: orientation breaking the equality check, and unknown devices falling to the lowest resolution.
import UIKitenum DeviceProfileResolver { private static var cached: DeviceProfile? /// Resolve once at launch, then return the cached value. static func current(screenSize: CGSize) -> DeviceProfile { if let cached { return cached } let resolved = resolve(for: screenSize) cached = resolved return resolved } static func resolve(for size: CGSize) -> DeviceProfile { let target = normalized(size) // 1. Prefer an exact match. if let exact = DeviceCatalog.profiles.first(where: { normalized($0.pointSize) == target }) { return exact } // 2. Otherwise snap to the profile with the closest area. // This stops an unknown large screen from dropping to the lowest asset. return DeviceCatalog.profiles.min(by: { abs(area($0.pointSize) - area(target)) < abs(area($1.pointSize) - area(target)) }) ?? DeviceCatalog.fallback } /// Normalize to short-edge x long-edge so orientation does not matter. private static func normalized(_ s: CGSize) -> CGSize { CGSize(width: min(s.width, s.height), height: max(s.width, s.height)) } private static func area(_ s: CGSize) -> CGFloat { s.width * s.height }}
The call site shrinks to this. For reading the screen size I recommend pulling it from the active window scene rather than depending on UIScreen.main, which can point at an unintended screen under multi-window or external-display setups.
// At the call site (from a view or view controller)let screenSize = view.window?.windowScene?.screen.bounds.size ?? UIScreen.main.bounds.size // legacy fallback pathlet profile = DeviceProfileResolver.current(screenSize: screenSize)imageView.image = UIImage(named: "bg_\(profile.assetSuffix)")topInset = profile.hasDynamicIsland ? 59 : 47
At this point every resolution check that was sprayed across the app collapses into one line: "resolve the profile once." Asset names and safe areas all come from the profile, in one place.
Migrate off the ternaries safely
Replacing everything in one shot risks regressions, so in this case I migrated in stages, verifying behavior at each step. That staged approach is the safe play for a production app.
Add the new DeviceProfile, DeviceCatalog, and DeviceProfileResolver. Touch no existing code yet.
Replace just the single most frequently called ternary with a profile.assetSuffix lookup, and confirm nothing renders differently across devices.
Replace the remaining spots in batches, starting with the semantically closest ones. Grep for UIScreen.main.bounds.height == NNN to confirm there are no leftovers.
Delete every old DefineManager height constant and fix the resulting build errors. The compiler tells you which references remain, so deleting aggressively here is the safe move.
The mechanical grep-and-replace in step 3 and the build-error cleanup in step 4 were a fast lane to hand to an Antigravity agent. Ask it to "enumerate every strict-equality resolution branch like UIScreen.main.bounds.height == 956 and produce a plan to route them through DeviceProfileResolver," and it hands back a change plan with diffs.
This migration cut the duplicated branch code by about 90% (29 spots down to one resolver plus the call sites). Just as importantly, the psychological barrier to supporting a new device basically vanished.
What I handed to Antigravity, and what stayed with me
To be honest, what you can delegate to the agent stops at "mechanical replacement." The parts that need judgment stay with a human.
What worked well to delegate: exhaustively finding the branch sites, doing the rote replacements, and chasing build errors after deletion. These are simple tasks prone to oversight, so the agent's accuracy pays off.
The logical resolution numbers that go into the table, though, I verified myself one by one in the simulator and on device. Trusting published specs verbatim can drift from measured values once safe area and scale enter the picture. Profile granularity—how far to merge devices and where to split—is a design decision too, so a human owns it. For the testing philosophy here, I also touch on it in the Swift Testing and AI-driven test design article.
Verify across every simulator at once
The biggest payoff of going table-driven is how easy verification becomes. You can guarantee, without launching the app, that every profile in the table resolves to itself and that unknown sizes snap deterministically to the nearest row.
import XCTest@testable import WallpaperAppfinal class DeviceProfileResolverTests: XCTestCase { /// Every cataloged size resolves to its own profile. func testEveryCatalogedSizeResolvesToItself() { for profile in DeviceCatalog.profiles { let resolved = DeviceProfileResolver.resolve(for: profile.pointSize) XCTAssertEqual(resolved.id, profile.id, "\(profile.id) resolved to a different profile") } } /// Orientation does not change the resolved profile. func testLandscapeResolvesSameProfile() { let air = CGSize(width: 420, height: 912) let airLandscape = CGSize(width: 912, height: 420) XCTAssertEqual( DeviceProfileResolver.resolve(for: air).id, DeviceProfileResolver.resolve(for: airLandscape).id ) } /// An unknown device (assume 408x884) snaps to the nearest, 17 Pro. func testUnknownSizeFallsBackToNearest() { let resolved = DeviceProfileResolver.resolve(for: CGSize(width: 408, height: 884)) XCTAssertEqual(resolved.id, "pro_17") }}
The third test is a direct regression guard against the very behavior that once caused crashes: an unknown device dropping to the lowest resolution. The first test also protects you when you add a new profile, catching the case where an existing device's resolution target silently shifts. With the table and the tests paired, adding a row finally feels safe.
If similar screen-size branches are scattered across your app right now, the first step is not "replace everything." Build one DeviceProfile table, replace only the single most-called spot, and confirm nothing renders differently across devices—start there to land it safely in production. Unlike the density splitting on the Google Play side, the App Store side rarely flags a wrong asset during review, so you have to own that verification yourself.
Once the table and tests are in place, the next time Apple ships a new iPhone, support is one row of data and one asset. Since switching to this shape, I read new-device news without bracing for it. If you go through the same chore every year, I hope this helps.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.