•5 min read

UIScreen.main Is Deprecated: What to Use Instead on iOS 27

Our photo grid picks its column count from UIScreen.main.bounds.width. That line is six years old and it has never been wrong, because every iPhone we shipped to had one screen of one size. I found it on Tuesday with a grep, along with thirty-one references like it.

A person drawing a floor plan at a desk, measuring along a scale ruler with a rolled set of plans beside the paper

Two things landed this month that turn those thirty-two lines from harmless into expensive. iOS 27 went out on September 14, and the foldable iPhone ships in October with two displays that a running app has to move between. On hardware like that, "the screen" stops being a useful thing to measure.

The part that blocks the build outright

Layout can wait a release. This cannot. Apple's iOS 27 release notes put it in Deprecations, and the sentence is short: apps built with the latest SDK "must adopt the scene-based life cycle or they fail to launch." That is the whole failure mode. The app does not start, and you find out by building against Xcode 27 and hitting Run.

The migration itself is an afternoon. Add a UIApplicationSceneManifest to the plist, write a SceneDelegate, and build the window from the scene instead of from the screen:

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func scene(_ scene: UIScene,
               willConnectTo session: UISceneSession,
               options: UIScene.ConnectionOptions) {
        guard let windowScene = scene as? UIWindowScene else { return }
        window = UIWindow(windowScene: windowScene)   // not UIScreen.main.bounds
        window?.rootViewController = RootViewController()
        window?.makeKeyAndVisible()
    }
}

One thing not to move with everything else: push registration stays in the app delegate, because the token is an app-level concern. Foreground and background transitions, URL handling, and window creation are the parts that go to the scene. While the plist is open: a build made with the 27.0 SDK has to declare a launch screen, one of UILaunchStoryboardName, UILaunchScreen, UILaunchStoryboards, or UILaunchScreens. Upload without one and App Store Connect turns it away.

Why screen bounds stopped being an answer

For a decade UIScreen.main.bounds worked like a spreadsheet. Whatever number your window wanted, you looked it up there, and on a one-screen phone the number was always right. The iOS 27 notes spell out what Apple expects instead: in an app that keeps UIRequiresFullScreen set, each resize arrives as a move to a new UIScreen, and the bounds of UIScreen.main "should remain fixed once the screen connects." Most of those entries showed up under the iPhone Mirroring heading, which is where a phone app has been running in a resizable window for a while now.

Orientation followed the same path. iOS 27 stops treating supported interface orientations as a condition for continuous resizability, which makes the rotation you locked a preference rather than a promise. Size class is the fact. On the foldable's inner display you get regular width, the same as an iPad. Folded, compact.

Three replacements, worst first

Most of our hits were column math. A grid that divides the screen width by a tile size looks reasonable right up until the container stops matching the screen. SwiftUI will do that division for you:

@Environment(\.horizontalSizeClass) private var sizeClass

private var columns: Int { sizeClass == .regular ? 4 : 2 }

LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8),
                        count: columns)) {
    ForEach(photos) { photo in
        Thumbnail(photo: photo)
    }
}

// the carousel that used to compute UIScreen.main.bounds.width - 32 by hand
ScrollView(.horizontal) {
    LazyHStack(spacing: 12) {
        ForEach(featured) { item in
            FeaturedCard(item: item)
                .containerRelativeFrame(.horizontal, count: 2, spacing: 12)
        }
    }
}

containerRelativeFrame measures against the nearest container, so the cards in that carousel stay half the visible width no matter which display they land on. The size class branch is what flips when the device folds. Where a layout only has two shapes, ViewThatFits is shorter still and needs no branch at all.

Hairlines came next. UIScreen.main.scale for a one-pixel separator becomes @Environment(\.displayScale). In UIKit it is traitCollection.displayScale, read from the view instead of the device, so it stays correct when that view moves to a different display.

Sheet heights were the last bucket and the least about geometry. .presentationDetents([.fraction(0.45), .large]) lets the system place the sheet; computing a height from screen bounds is how you end up with a panel that swallows the whole wide display. For a UIKit window that must not be shrunk into nonsense, windowScene.sizeRestrictions?.minimumSize is where that floor goes now, in place of the old flag that meant "do not resize me."

One bug worth knowing about

If you move to containerRelativeFrame and the result looks cropped, check your SDK. The 27.0 notes record an issue where containerRelativeFrame(_:alignment:) counted safe-area insets on a scroll view's non-scrolling axis, so a vertically sized view inside a horizontal scroll view stretched under the navigation bar and the home indicator. It shipped fixed, so if you hit it on a June beta, that is why.

The 27.1 SDK goes further, with reserved regions near the hinge and camera, plus vertical bar placement for standard navigation containers. Those names were still moving while the betas were out, so read the current docs rather than a June conference recap. NavigationSplitView already adapts on its own. Hand-rolled bars do not, and that is the part worth budgeting real time for.

Thirty-two hits, and every one of them was a decision somebody made when there was only ever one answer. Two hours got the grid, the separators, and the sheets off UIScreen.main. The grep took ten seconds:

grep -rn "UIScreen.main" --include="*.swift" .

I keep the before-and-after pairs in Snippet Ark so the next person sees why the line was replaced instead of rediscovering it in two years. If you are working through the rest of the 27 release notes, the reorderable grid post covers the other API that had me deleting code this month.