- Published on
- 7 min read Intermediate
> Replacing UIScreen.main Before iPhone Duo Arrives
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:
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:
@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:
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:
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:
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:
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:
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:
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:
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.
// Continue_Learning
How to Get Your App Ready for iPhone Duo
iPhone Duo ships on October 23 with a folding inner display, a compact outer display, and toolbars that move to the side. Here's what to check in your app first, and where the new iOS 27.1 APIs fit in.
Reading the iPhone Duo Hinge Angle with onHingeChange in SwiftUI
iOS 27.1 lets you read iPhone Duo's hinge angle and fold status with onHingeChange in SwiftUI and UIHingeInteraction in UIKit. Here's how the APIs work, why they're for interactive effects rather than layout, and a fold-to-scrub example.
Keeping Content Clear of the iPhone Duo Fold with ReservedRegion
iPhone Duo reserves parts of its displays for the fold and the front cameras. Learn which regions exist, when system views handle them for you, and how to query ReservedRegion in SwiftUI and UIKit to move custom content out of the way.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.