[SwiftUI] Background image overflowing Spacer container

Hi, I'm facing a strange behaviour (or maybe is the expected one and need to be tuned a bit more) with an image used as a background for a Spacer().

This is my SwiftUI code:

struct ContentView: View {
    
    @State private var text: String = ""

    var body: some View {
        GeometryReader { reader in
            VStack {
                Spacer()
                    .background {
                        Image("header")
                            .resizable()
                            .aspectRatio(contentMode: .fill)
                            .frame(
                                width: reader.size.width,
                                alignment: .bottom
                            )

                    }

                TextField(
                    "Write something",
                    text: $text
                )

                VStack {
                    Text("TEST")
                        .font(.title)
                    Text("TEST")
                        .font(.title2)
                }
                .frame(width: reader.size.width)
                .background(Color.gray)
                
                VStack {
                    Text("TEST")
                        .font(.title)
                    Text("TEST")
                        .font(.title2)
                }
                .frame(width: reader.size.width)
                .background(Color.white)
                
                VStack {
                    Text("TEST")
                        .font(.title)
                    Text("TEST")
                        .font(.title2)
                }
                .frame(width: reader.size.width)
                .background(Color.gray)
            }
            .edgesIgnoringSafeArea(.top)
        }
    }
}

And this is the result in previews (I'm using a grey placeholder image displaying its size):

As you can see, the Image in the background of the Spacer is overlapping with the next element of the VStack, the TextField. Also, the image should fill the width while keeping its aspect ratio, and can't clip the bottom of the image. The overflow should be pushed to the top.

How can I align the image to the exact bottom of the Spacer?

Thanks for your time.

I guess it is the result of the aspectRatio which does some automatism.
By removing the line

.aspectRatio(contentMode: .fill)

the image does end before the TextField.

Yes, but then the image is deformed and needs to keep its aspect ratio.

In that case you may think about a different approach…
Perhaps you can fit the image to screen and use a background:

…
            VStack {
                Spacer()
                    .background {
                        ZStack {
                            Rectangle()
                                .foregroundColor(.red)
                            Image("Header")
                                .resizable()
                                .aspectRatio(contentMode: .fit)
                        }
                        .frame(width: reader.size.width, alignment: .bottom)
                    }

                TextField("Write something",text: $text)
…

Of course you want to use another color. I used .red here as example.

That fixes bottom overlay issue, but is because is using .fit as contentMode.
I need to use .fill to full the width of the Spacer(). With .fit adds a padding on both sides of the container.

What do you think about this View implementation?
Without a Spacer and a no vertical spacing it looks good for me.

struct ContentView: View {
    @State private var text: String = ""
    
    var body: some View {
        GeometryReader { reader in
            VStack(spacing: 0) {
                Image("Header")
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                    .frame(width: reader.size.width, alignment: .bottom)

                TextField("Write something",text: $text)
                
                VStack {
                    Text("TEST")
                        .font(.title)
                    Text("TEST")
                        .font(.title2)
                }
                .frame(width: reader.size.width)
                .background(Color.gray)
                
                VStack {
                    Text("TEST")
                        .font(.title)
                    Text("TEST")
                        .font(.title2)
                }
                .frame(width: reader.size.width)
                .background(Color.white)
                
                VStack {
                    Text("TEST")
                        .font(.title)
                    Text("TEST")
                        .font(.title2)
                }
                .frame(width: reader.size.width)
                .background(Color.gray)
            }
            .edgesIgnoringSafeArea(.top)
        }
    }
}

That was my initial approach, but what happens is that the image is always at the top and all new context is appended below and causing an overflow at the botttom. What I'm trying to achieve is the opposite.

Have to mention that wrapping your code into a ScrollView and then using a ScrollReader to scroll to the bottom when view appears does the trick, but I wanted to avoid the use of ScrollView if possible, since I found some incompatibilities in the past when dissabling the scroll.

If your target is iOS 16.0+ then you can use the Layout protocol.

Then you can do this:

Spacer()
    .background {
        PinBottomLayout {
            Image("header")
                .resizable()
                .aspectRatio(contentMode: .fill)
        }
        .frame(width: reader.size.width)
    }

Where the custom layout is implemented as following:

struct PinBottomLayout: Layout {
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        return proposal.replacingUnspecifiedDimensions(by: CGSize(width: CGFloat.infinity, height: CGFloat.infinity))
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        for subview in subviews {
            let size = subview.sizeThatFits(proposal)
            let x = bounds.minX + (bounds.width - size.width) / 2
            let y = bounds.minY + (bounds.height - size.height)
            subview.place(at: CGPoint(x: x, y: y), anchor: .topLeading, proposal: proposal)
        }
    }
}

Edit (suggestion removed): Sorry, I misread the issue you had.

In the future you might want to ask this kind of question about SwiftUI (the framework, not the Swift language) over on the apple developer forums where it has more visibility to people who have more experience with Apple frameworks. You might not find many people that can help you with SwiftUI on these forums.

I think you can remove the frame from the image and put it inside a background/overlay on Color.clear and set the alignment on that modifier like so:

Color.clear
    .overlay(alignment: .bottom) {
        Image(…)
            .resizable()
            .aspectRatio(contentMode: .fill)
    }

Does that do the trick?

The thing about Spacer() is it doesn’t really have an area. You can see this by adding

Spacer()
    .border(.yellow)

The spacer does its job but there’s no yellow border. Instead you have to force a dimension perpendicular to the direction it’s spacing

Spacer()
    .frame(width: 30)
    .border(.yellow)

and then the yellow appears.

The next thing is using GeometryReader to size things out the full width. GeometryReader should be a tool of last resort and in your example it isn’t needed, instead use this to make things full width

.frame(maxWidth: .infinity)

That’ll push a view outwards/bigger until other views say no more. You can get rid of the GeometryReader and use that instead on your VStacks. And on the Spacer too so it actually fills all that area

Spacer()
    .frame(width: .infinity)
    .border(.yellow)

Now that there’s a correct frame we add the background and use the backgrounds alignment option to position its subview (I’m not sure if adding the alignment on the Images frame does anything). So here’s a final Spacer that does what I think you’re after

Spacer()
    .frame(maxWidth: .infinity)
    .border(.yellow)
    .background(alignment: .bottom) {
        Image("header")
            .resizable()
            .aspectRatio(contentMode: .fill)
    }

Note that the unwanted portion of the Image is conveniently pushed up and/or to the sides of the Spacers frame because of the bottom alignment. But in the case you wanted the Image to be centered then add clipShape to the Spacer to restrict the Image to the Spacers frame

Spacer()
    .frame(maxWidth: .infinity)
    .border(.yellow)
    .background(alignment: .center) {
        Image("header")
            .resizable()
            .aspectRatio(contentMode: .fill)
    }
    .clipShape(Rectangle())

I’m sure there’s many ways to accomplish this and I can’t say this is the best but it works ;)

3 Likes

@trochoid That worked :smiley:

The updated code looks like this (I added a border to show the spacer bounds)

struct ContentView: View {
    
    @State private var text: String = ""
    
    var body: some View {
        
        VStack {
            Spacer()
                .frame(maxWidth: .infinity)
                .border(.blue, width: 10)
                .background(alignment: .bottom) {
                    Image("header")
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                }
            
            TextField(
                "Write something",
                text: $text
            )
            
            VStack(spacing: 0) {
                VStack {
                    Text("TEST")
                        .font(.title)
                    Text("TEST")
                        .font(.title2)
                }
                .frame(maxWidth: .infinity)
                .background(Color.gray)
                
                VStack {
                    Text("TEST")
                        .font(.title)
                    Text("TEST")
                        .font(.title2)
                }
                .frame(maxWidth: .infinity)
                .background(Color.white)
                
                VStack {
                    Text("TEST")
                        .font(.title)
                    Text("TEST")
                        .font(.title2)
                }
                .frame(maxWidth: .infinity)
                .background(Color.gray)
            }
        }
        .edgesIgnoringSafeArea(.top)
    }
    
}

And UI looks like this now

Thanks for all the hints.

1 Like

Tiny amendment: I think you can simply use .clipped() if you only want to clip to the (rectangular) frame anyway, rather than .clipShape(Rectangle())

1 Like