swiftuiswiftui-zstack

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.


Solution

  • 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 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:

    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