BS
BleepingSwift
Published on
7 min read
Intermediate

> Replacing UIScreen.main Before iPhone Duo Arrives

Share:

Search almost any iOS codebase that's been around for a few years and you'll find UIScreen.main.bounds.width somewhere. It sizes a grid, sets the width of a card, or decides whether to show two columns. It has always been a shortcut, and with iPhone Duo it becomes a bug.

iPhone Duo, which Apple announced on September 9, has a compact outer display and a large inner one. As someone opens and closes it, your app moves from one display to the other, and in some poses the system also places bars along the side of the display. Apple's guidance for the device says to make layout calculations from your scene or containing view's bounds rather than screen dimensions, and the "Prepare your app for iPhone Duo" tech talk includes a segment on replacing main screen references. The same advice applies to iPhone Mirroring on the Mac and resizable windows on iPad, so this cleanup pays off well beyond one device.

What's Wrong With UIScreen.main

The documentation for UIScreen.main describes it as the screen object for "the device's screen." That singular phrasing is the problem. On a device with two displays there isn't one screen, and even on a single-display device, the screen's size tells you nothing about the size of your window.

The iOS 27 release notes make that concrete for apps that set UIRequiresFullScreen. When someone resizes such an app on iPad or in iPhone Mirroring, each resize is supposed to arrive as a move to a new UIScreen with updated bounds, while the bounds of UIScreen.main stay fixed once the screen connects. Code that reads UIScreen.main.bounds in that situation is measuring something your window isn't.

Apple formally deprecated the property in iOS 26, and the SDK's deprecation message spells out the replacement: use a UIScreen found through context, such as view.window.windowScene.screen, or for things like scale, use the trait collection. One reason these calls linger is that the warning only appears once your deployment target reaches iOS 26. If your app still supports older versions, the compiler stays quiet. A quick search finds them anyway:

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

Most of what that turns up falls into three buckets: sizing layout, getting the display scale, and the rare case where you really do need the screen object.

Size Layout From the Container

The most common misuse is sizing content from the screen instead of from the view that holds it. A photo grid that picks its column count from UIScreen.main.bounds.width looks fine on a regular iPhone and wrong everywhere else.

If you're using a compositional layout, the section provider already hands you the size that matters. NSCollectionLayoutEnvironment describes the layout's container, including its size after content insets:

Swift
@MainActor
func makePhotoGridLayout() -> UICollectionViewCompositionalLayout {
    UICollectionViewCompositionalLayout { _, environment in
        let width = environment.container.effectiveContentSize.width
        let columns = max(2, Int(width / 180))

        let item = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .fractionalHeight(1.0)
        ))
        item.contentInsets = NSDirectionalEdgeInsets(top: 2, leading: 2, bottom: 2, trailing: 2)

        let group = NSCollectionLayoutGroup.horizontal(
            layoutSize: NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1.0),
                heightDimension: .fractionalWidth(1.0 / CGFloat(columns))
            ),
            repeatingSubitem: item,
            count: columns
        )
        return NSCollectionLayoutSection(group: group)
    }
}

When the window changes size, the layout asks for new sections and the column count follows. The same idea applies to plain views: do the math in layoutSubviews() or viewWillLayoutSubviews() using bounds, and base branching decisions on size classes rather than on the device. Apple's iPhone Duo guidance specifically calls out userInterfaceIdiom and interface orientation as things not to use for layout decisions.

Get the Scale From Traits

The other classic use of UIScreen.main is UIScreen.main.scale, usually to draw a one-pixel line. The trait collection carries the same value as displayScale, and it describes the environment your view is actually in:

Swift
final class HairlineSeparatorView: UIView {
    private let line = CALayer()

    override init(frame: CGRect) {
        super.init(frame: frame)
        layer.addSublayer(line)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func layoutSubviews() {
        super.layoutSubviews()

        // Reading traits here lets UIKit re-run layout when they change.
        let scale = max(traitCollection.displayScale, 1)
        let thickness = 1 / scale
        line.backgroundColor = UIColor.separator.resolvedColor(with: traitCollection).cgColor
        line.frame = CGRect(x: 0, y: bounds.height - thickness, width: bounds.width, height: thickness)
    }
}

Because the traits are read inside layoutSubviews(), UIKit's automatic trait tracking invalidates the layout when they change, so there's no need to override traitCollectionDidChange(_:). The max(_, 1) guards against 0.0, which is how a trait collection represents an unspecified scale.

When You Really Need the Screen

A few things genuinely belong to the screen. The usual example is brightness: a boarding pass or loyalty card screen turns brightness up so a scanner can read the barcode. Get the screen from the window scene that's showing your view, and remember which screen you changed:

Swift
final class BoardingPassViewController: UIViewController {
    private var brightenedScreen: (screen: UIScreen, originalBrightness: CGFloat)?

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        guard let screen = view.window?.windowScene?.screen else { return }
        brightenedScreen = (screen, screen.brightness)
        screen.brightness = 1.0
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)

        if let brightenedScreen {
            brightenedScreen.screen.brightness = brightenedScreen.originalBrightness
        }
        brightenedScreen = nil
    }
}

The work happens in viewDidAppear(_:) because by then the view is in a window, so the window scene and its screen are available. Holding on to the screen you brightened means you restore the right one, even if the scene is showing somewhere else by the time the view disappears.

The SwiftUI Side

SwiftUI code tends to reach for UIScreen.main.bounds to make something "80 percent of the screen wide." The fix is to ask for a fraction of the container instead. containerRelativeFrame does exactly that, and it pairs well with paging scroll views:

Swift
struct PosterCarousel: View {
    let posters: [Poster]

    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: 12) {
                ForEach(posters) { poster in
                    PosterCard(poster: poster)
                        .containerRelativeFrame(.horizontal) { width, _ in
                            width * 0.8
                        }
                }
            }
            .scrollTargetLayout()
        }
        .scrollTargetBehavior(.viewAligned)
    }
}

For grids, you often don't need any measurement at all. An adaptive GridItem fits as many columns as the space allows, which is the same result as the compositional layout above with a fraction of the code:

Swift
struct PhotoGrid: View {
    let photos: [Photo]

    var body: some View {
        ScrollView {
            LazyVGrid(columns: [GridItem(.adaptive(minimum: 160), spacing: 4)], spacing: 4) {
                ForEach(photos) { photo in
                    PhotoThumbnail(photo: photo)
                        .aspectRatio(1, contentMode: .fit)
                }
            }
        }
    }
}

When you need a decision rather than a size, onGeometryChange(for:of:action:) lets you derive a small value from the view's geometry and only react when that value changes. This keeps a reading column from getting too wide on a large display:

Swift
struct ReadableColumn<Content: View>: View {
    @State private var isWide = false
    @ViewBuilder var content: Content

    var body: some View {
        content
            .frame(maxWidth: isWide ? 640 : .infinity)
            .frame(maxWidth: .infinity)
            .onGeometryChange(for: Bool.self) { proxy in
                proxy.size.width > 700
            } action: { newValue in
                isWide = newValue
            }
    }
}

And for the hairline case, SwiftUI exposes the scale through the environment:

Swift
struct Hairline: View {
    @Environment(\.displayScale) private var displayScale

    var body: some View {
        Rectangle()
            .fill(.separator)
            .frame(height: 1 / displayScale)
    }
}

Sample Project

Want to see this code in action? Check out the complete sample project on GitHub:

View on GitHub

The repository includes a working Xcode project with all the examples from this article, plus unit tests you can run to verify the behavior.

Wrapping Up

Every one of these replacements answers the question the original code was really asking. You wanted to know how much room your view has, what scale it's drawn at, or which screen it's showing on, and UIScreen.main could only ever answer for "the device." Once your layout reads from its container and its traits, iPhone Duo's two displays, a resizable iPhone Mirroring window, and an iPad in Stage Manager all look the same to your code. For the rest of the Duo preparation checklist, see how to get your app ready for iPhone Duo, and for the related topic of insets, understanding safe areas in SwiftUI.

subscribe.sh

// Stay Updated

Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.

>

By subscribing, you agree to our Privacy Policy.