Search code examples
iosswiftuilayout

Make SwiftUI Shape take entire screen width minus padding while using position modifier


I need to position a view based on a given CGRect frame and a few pixels below render a RoundedRectangle that always takes the entire width of the screen minus 20pts horizontal padding (regardless of the inner Text length). To position the first View I am using the position modifier which seems to work just fine, but when it comes to drawing the second RoundedRectangle and make it always take the entire width minus 20 pts horizontal padding its not working as for whatever reason the View is positioned all the way to the left and paddings ignored (check the outcome picture).

Code is simple and the following:

struct TestView: View {
    
    let testFrame = CGRect(x: 20, y: 200, width: 240, height: 48)
    
    var body: some View {
            VStack(spacing: 20) {
                RoundedRectangle(cornerRadius: 16)
                    .stroke(.purple, lineWidth: 4)
                    .frame(width: testFrame.width, height: testFrame.height)
                //This should always take the entire width of the screen minus 20 pts horizontal padding and height be dynamic depending on inner Text / View sizes and lengths.
                VStack(alignment: .leading) {
                    Text("Some title")
                    Text("Some subtitle")
                }
                .frame(maxWidth: .infinity)
                .padding()
                .background(RoundedRectangle(cornerRadius: 16).foregroundColor(.red))
                .padding(.horizontal, 20)
            }
            .position(x: testFrame.midX, y: testFrame.midY)
    }
}

Outcome:

enter image description here


Solution

  • The issue there, I think, is that you are trying to align the red caption "bubble" with both the purple highlight and the container. Regularly, instead of wrangling with "built-in" layouts, I would approach it with either the GeometryReader or a custom Layout just for mental clarity but here's both ways:

    import SwiftUI
    
    struct E25: View {
        
        let testFrame = CGRect(x: 20, y: 200, width: 240, height: 48)
        
        var body: some View {
            RoundedRectangle(cornerRadius: 16)
                .stroke(.purple, lineWidth: 4)
                .frame(width: testFrame.width, height: testFrame.height)
                .position(x: testFrame.midX, y: testFrame.midY)
                .overlay(alignment: .top) {
                    VStack(alignment: .leading) {
                        Text("Some title")
                        Text("Some subtitle")
                    }
                    .frame(maxWidth: .infinity)
                    .padding()
                    .background(RoundedRectangle(cornerRadius: 16).foregroundColor(.red))
                    .padding(.horizontal, 20)
                    .offset(y: testFrame.maxY + 20)
                }
        }
        
        var altBody: some View {
            CustomLayout(rect: testFrame) {
                RoundedRectangle(cornerRadius: 16)
                    .stroke(.purple, lineWidth: 4)
                
                ZStack {
                    Color.red
                    VStack(alignment: .leading) {
                        Text("Some title")
                        Text("Some subtitle")
                    }
                    .padding()
                }
                .clipShape(RoundedRectangle(cornerRadius: 16))
                .padding(.horizontal, 20)
            }
        }
        
        struct CustomLayout: Layout {
            let rect: CGRect
            var captionGap: CGFloat = 20
            
            func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
                
                // The highligh view
                guard let highlight = subviews.first else { return }
                highlight.place(
                    at: CGPoint(
                        x: bounds.minX + rect.minX,
                        y: bounds.minY + rect.minY
                    ),
                    anchor: .topLeading,
                    proposal: ProposedViewSize(rect.size)
                )
                
                // The optional caption view
                guard subviews.count >= 2 else { return }
                let caption = subviews[1]
                
                caption.place(
                    at: CGPoint(
                        x: bounds.minX,
                        y: bounds.minY + rect.maxY + captionGap
                    ),
                    anchor: .topLeading,
                    proposal: ProposedViewSize(width: bounds.width, height: .none)
                )
            }
            
            func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
                proposal.replacingUnspecifiedDimensions()
            }
        }
    }
    
    #Preview {
        E25()
    }
    

    The first approach is a slight modification of your code but with an overlay. Somewhat counter-intuitively, it works because position make the modified view "greedy" (e.g. the view now takes all available space).

    The second approach, (the altBody) doesn't save any code, but, in my opinion, is somewhat easier to understand and reuse.

    Let me know is this resolves the issue or if you have further questions.

    Regards, –Baglan