swiftswiftui

How to have a SwiftUI Shape View conform to dimensions of a neighboring view


Simple Explanation:

I am displayed a SwiftUI Capsule() to display a status color next to a Text() view. In all cases so far the capsule is used inside an HStack with the Text view. I would like to have the height of the Capsule to match the height of the text. This has not been a problem in some situations, such as in a List(). In other cases, however, the capsule view will take up more vertical space than the text.

Additional Complexities

I have wrapped the Capsule() view into a custom view called StatusCapsule() to allow me to take a color and width parameter to adjust more easily. I have tested with just the Capsule() view and experienced the same outcomes. In my problematic view, the HStack is placed within a GridRow(), within a Grid(), within a ScrollView(), within a NavigationStack(). My confusion also comes from that my first and second GridRow() views both had this issue, however, after placing the Grid() in a ScrollView(), the top GridRow() output StatusCapsule() views to my expectation, but the second GridRow() did not.

Below is the code for the StatusCapsule View and a simplified AnalyticsView() view, as well as a screenshot of the results of the code

StatusCapsule() View:

struct StatusCapsule: View {
    
    var color: Color = .white
    var width: CGFloat = 4
    
    var body: some View {
        Capsule()
            .fill(color)
            .frame(width: width)
    }
}

Problematic view with grid and scroll views:

I replaced all dynamic code in the Text views and color parameter fields with static values to simply the code. I did test this simplified code and received the same result as my dynamic view, so to my understanding the two are fundamentally the same.

struct AnalyticsView: View {
    
    @State private var path = NavigationPath()
    @State private var nums: [String] = ["5","9","4","7","33","37","81"]

    var body: some View {
        NavigationStack(path: $path) {
            ScrollView {
                Grid(alignment: .center, horizontalSpacing: 5, verticalSpacing: 5) {
                    GridRow(alignment: .center) {
                        ZStack {
                            RoundedRectangle(cornerRadius: 20)
                                .foregroundStyle(Color(.secondarySystemBackground))
                                .padding(2)
                            VStack {
                                HStack {
                                    StatusCapsule(color: .cyan)
                                    StatusCapsule(color: .cyan)
                                    Text("Total Expected: $1,350.65")
                                        .font(.headline)
                                }
                                HStack {
                                    StatusCapsule(color: .cyan)
                                    Text("Total Completed: $25.44")
                                        .font(.subheadline)
                                    Text("|")
                                        .bold()
                                    StatusCapsule(color: .blue)
                                    Text("Total Invoiced: 45.67")
                                        .font(.subheadline)
                                }
                            }
                            .padding()
                        }.gridCellColumns(2)
                    } // Headline Cell
                    GridRow() {
                        ZStack {
                            RoundedRectangle(cornerRadius: 20)
                                .foregroundStyle(Color(.secondarySystemBackground))
                                .padding(2)
                            VStack(alignment: .leading) {
                                Text("In Progress: 5").font(.headline)
                                ForEach(nums.prefix(5), id: \.self) { num in
                                    HStack {
                                        StatusCapsule(color: .red)
                                        Text("\(num)")
                                    }
                                }
                            }
                            .padding(.vertical)
                            .scaledToFit()
                        }.gridCellColumns(1)
                        ZStack {
                            RoundedRectangle(cornerRadius: 20)
                                .foregroundStyle(Color(.secondarySystemBackground))
                                .padding(2)
                        }
                    }
                }
            }
            .navigationTitle("Analytics")
        }
    }
}

I have been looking into controlling the frame of views and have not found a solution I'm satisfied with other than delving deep into UI code from before SwiftUI. I would like the capsule view to dynamically conform to the height of the text view in its associated HStack.

I'm expecting the result I see in the first row of the grid, and I'm also confused as to why I'm seeing different results between the two rows once placed in the ScrollView. In regards to this, the capsules in the top GridRow acted exactly as they do in the second GridRow when the entire Grid was not placed in the ScrollView


Solution

  • Explanation

    The reason why the capsule would sometimes take up more vertical space than the text is because a Capsule, like all shapes, is greedy. It will normally use as much space as possible.

    Forcing ideal size as a way to fix

    One way to fix is to force the containers to adopt their ideal vertical size by applying a .fixedSize modifier, as explained in another answer by Sweeper. Putting the content inside a (vertical) ScrollView has the same effect, see also How to use ideal size for HStack and VStack layout, instead of max size.

    However, setting .fixedSize might have side effects on other content. In particular, if you have any Spacer, Divider, other shapes or colors then these might not expand as you would like them to. The RoundedRectangle in the cells of your Grid are cases in point.

    Using an overlay to fix

    Another way to fix is to apply the StatusCapsule as an overlay to the Text:

    Text("The quick brown fox\njumps over the lazy dog")
        .padding(.leading, 12)
        .overlay(alignment: .leading) { StatusCapsule(color: .cyan) }
    

    Screenshot

    More concise code

    As the example above illustrates, if you just want to add a status bar to a single Text item then an HStack is no longer needed to combine them. You could also consider adding a View extension to apply the modifiers:

    private extension View {
        func statusCapsule(color: Color) -> some View {
            self
                .padding(.leading, 12)
                .overlay(alignment: .leading) { StatusCapsule(color: color) }
        }
    }
    

    This makes it possible to apply a status capsule in a concise way to any text (or in fact, to any view), with the added benefit that the spacing will always be consistent. Here is how the top VStack of your example could be adapted to use this approach:

    VStack {
        Text("Total Expected: $1,350.65")
            .font(.headline)
            .statusCapsule(color: .cyan)
            .statusCapsule(color: .cyan)
        HStack {
            Text("Total Completed: $25.44")
                .font(.subheadline)
                .statusCapsule(color: .cyan)
            Text("|")
                .bold()
            Text("Total Invoiced: 45.67")
                .font(.subheadline)
                .statusCapsule(color: .blue)
        }
    }
    

    Screenshot