Content in a full-screen ZStack not centering as expected

Consider the following (in a landscape-only app):

struct ContentView: View {

var body: some View {
    ZStack {
        Rectangle()
            .foregroundColor(.blue)
        .frame(width: UIScreen.main.bounds.size.width * 0.9, height: UIScreen.main.bounds.size.height * 0.9)
    }
    .frame(maxWidth: .infinity, maxHeight: .infinity)
    .ignoresSafeArea(.all)
    .background(.gray)
}

}

Which results in this:
enter image description here

Why is the rectangle not centered? Why is there more space on the right than on the left? Happens on a device too.

  • Adding a border to the ZStack shows that there are some space on the right that is not occupied by the ZStack. Why are you doing layout with UIScreen bounds anyway?

    – 

This is an interesting one. If you change the scaling factor from 0.9 to 0.8 then the blue area is centered perfectly. But when you set a frame size that needs to use the safe areas to fit, the position is not centered.

I did some measurements of the screen size of an iPhone 15 in landscape mode (which is what you appear to be using in your screenshot):

  • the full screen size is 852 x 393
  • there are leading and trailing safe areas of 59
  • there is a bottom safe area of 21 (for the home bar)
  • the central part within the safe areas is therefore 734 x 372.

The sizes delivered by the (deprecated) UIScreen.main.bounds.size are the full screen sizes. So when a factor of 0.9 is applied, the size of the blue area is being fixed at 766.8 x 353.7. This fits within the available height (of 372), but not within the available width (of 734). The excess width is 32.8 pt.

It is perhaps useful to note that the gray background is giving the impression that the ZStack is filling the screen, but this is not the case. You have applied the background using .background(.gray), which ignores safe areas by default – see
background(_:ignoresSafeAreaEdges:). If you change it to .background { Color.gray } then safe areas are not ignored and the background shows you the exact size and position of the ZStack. The result is quite interesting:

Screenshot

What we see is:

  • The frame of the ZStack is still observing the safe areas as far as possible.
  • The width of the ZStack exactly matches the width of the blue area, because the blue content is forcing it to be wider than the area within the safe insets. So the ZStack has expanded into both the leading and trailing safe areas by half the excess width or 16.4 pt on both sides.
  • The blue content slightly overlaps the bottom edge of the ZStack. This content is being vertically centered within the full screen height, so the gap to the bottom of the screen is (393 – 353.7) / 2 = 19.65. This is slightly less than the bottom safe inset of 21.
  • Most interesting of all is the horizontal offset of the blue rectangle. Since it doesn’t fit within the safe width, it needs to extend into the safe areas by 32.8 pt. The surprise is that it only extends into the leading safe area! So the gap on the leading side is (59 – 32.8) = 26.2. On the trailing side, the gap is still 59.

I think that what is happening is that the ZStack is calculating the horizontal offset for the content based on the assumption that the ZStack is in its default position. So the relative offset is half the excess width or 16.4 pt. But since the ZStack itself is already displaced by this much, it means the blue content has a double displacement. The result is that the correction for the excess width is fully applied on the leading side and the gap on the trailing side is the default safe area inset.

Workarounds

1. Only ignore safe areas on the top and bottom edges

ZStack {
    Rectangle()
        .foregroundColor(.blue)
        .frame(width: UIScreen.main.bounds.size.width * 0.9, height: UIScreen.main.bounds.size.height * 0.9)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea(edges: [.top, .bottom]) // <- HERE
.background(.gray)

The effect of this change is to stop the ZStack from applying an offset to the content, so the horizontal position of the ZStack becomes the horizontal position of the content too (they are horizontally aligned and also horizontally centered).

This workaround is based on the assumption that the leading and trailing safe area insets are always matched. However, this might not be the case on all devices.

2. Apply maximum size to the ZStack again after the modifier .ignoresSafeArea

ZStack {
    Rectangle()
        .foregroundColor(.blue)
        .frame(width: UIScreen.main.bounds.size.width * 0.9, height: UIScreen.main.bounds.size.height * 0.9)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea()
.frame(maxWidth: .infinity, maxHeight: .infinity) // ADDED
.background(.gray)

The first frame modifier on the ZStack extends the ZStack up to the safe areas. After ignoring safe areas, the second frame modifier extends the ZStack to the screen size.

3. Apply max size and ignoresSafeArea to the blue rectangle

ZStack {
    Rectangle()
        .foregroundColor(.blue)
        .frame(width: UIScreen.main.bounds.size.width * 0.9, height: UIScreen.main.bounds.size.height * 0.9)
        .frame(maxWidth: .infinity, maxHeight: .infinity) // ADDED
        .ignoresSafeArea() // ADDED
}
.ignoresSafeArea()
.background(.gray)

A frame with max size no longer needs to be set on the ZStack, because its size is dictated by the content. But the ZStack still needs to ignore safe areas.

4. Use a GeometryReader as the parent container

Using a GeometryReader is a better way of getting the screen size (because UIScreen.main is deprecated and it doesn’t work properly with split screen on iPad). If the modifier .ignoresSafeArea is moved from the ZStack to the GeometryReader then the ZStack extends to the full screen size when the frame with max size is applied:

GeometryReader { proxy in
    ZStack {
        Rectangle()
            .foregroundColor(.blue)
            .frame(width: proxy.size.width * 0.9, height: proxy.size.height * 0.9)
    }
    .frame(maxWidth: .infinity, maxHeight: .infinity)
    .background(.gray)
}
.ignoresSafeArea()

I would suggest, this is the best workaround.

All workarounds give the same result:

Screenshot

Leave a Comment