iosswiftswiftui

Why do two VStacks inside a parent HStack have different heights?


I try to create timeline step using the following code.

import SwiftUI

struct ContentView: View {
    var body: some View {
        // Timeline Steps Container
        VStack(alignment: .leading, spacing: 0) {
            TimelineStep(icon: "checkmark.circle.fill", title: "Install & Set up", description: "You've successfully personalized your experience", isCompleted: true, isLast: false)
            TimelineStep(icon: "lock.fill", title: "Today: Get Instant Access", description: "Access 50+ premium actions: professional PDF editing, files converter, and scanner", isCompleted: true, isLast: false)
            TimelineStep(icon: "bell.fill", title: "Day 5: Trial Reminder", description: "We'll send you an email/notification that your trial is ending", isCompleted: false, isLast: false)
            TimelineStep(icon: "star.fill", title: "Day 7: Trial Ends", description: "Your subscription will start on Apr 19", isCompleted: false, isLast: true)
            
            Spacer().layoutPriority(1)
        }
        .padding(.vertical, 30)
        
    }
}

// --- PLEASE REPLACE YOUR OLD TimelineStep WITH THIS ---
struct TimelineStep: View {
    let icon: String
    let title: String
    let description: String
    let isCompleted: Bool
    let isLast: Bool

    var body: some View {
        HStack(alignment: .top, spacing: 20) {
            // --- FIX #1: ICON AND LINE CONNECTION ---
            // This VStack now has spacing set to 0 to remove the gap.
            VStack(alignment: .center, spacing: 0) {
                //Rectangle()
                //    .fill(isCompleted ? Color.blue : Color(UIColor.systemGray5))
                //    .frame(width: 2)
                //    .frame(maxHeight: .infinity)
                
                ZStack {
                    Circle()
                        .fill(isCompleted ? Color.blue : Color(UIColor.systemGray5))
                        .frame(width: 40, height: 40)
                    
                    Image(systemName: icon)
                        .foregroundColor(isCompleted ? .white : .gray)
                        .font(.title3)
                }
                
                if !isLast {
                    Rectangle()
                        .fill(isCompleted ? Color.blue : Color(UIColor.systemGray5))
                        .frame(width: 2)
                }
            }
            .frame(width: 40) // Give the icon column a fixed width
            .frame(maxHeight: .infinity) // 1. Expand the frame to fill the available height
            .background(.red)
            
            // --- FIX #2: TEXT WRAPPING ---
            VStack(alignment: .leading, spacing: 4) {
                Text(title)
                    .font(.headline)
                    .fontWeight(.bold)
                
                Text(description)
                    .font(.subheadline)
                    .foregroundColor(.gray)
                    .fixedSize(horizontal: false, vertical: true)
                // The .lineLimit modifier has been removed to allow wrapping.
            }
            .padding(.bottom, 32)
            .background(.green)
            // This is crucial for the text to wrap correctly by taking available space.
            .frame(maxWidth: .infinity, alignment: .leading)
        }
        .background(.yellow)
        .padding(.horizontal, 30) // Add padding to the whole row
        .padding(.bottom, isLast ? 0 : 0) // Control space between timeline steps
    }
}

#Preview {
    ContentView()
}

I am getting this output.

enter image description here

My expectation is

  1. The red VStack should grow same height as green VStack. It doesn't.
  2. The blue vertical line should grow same height as green VStack. It doesn't.

Why is it so?

Thank you. I have worked together with AI for quite a while. Both of us still can't figure out why 🫣


Solution

  • It seems that the height of a row should be determined by the text with the green background. Then the progress indicator should adopt this height.

    Instead of having two VStack in parallel, the VStack with the progress indicator can be shown as an overlay over the one that determines the height. Padding can be used to reserve space for this overlay. With this approach, the HStack is not needed any more.

    // TimelineStep
    
    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text(title)
                .font(.headline)
                .fontWeight(.bold)
    
            Text(description)
                .font(.subheadline)
                .foregroundStyle(.gray)
                .fixedSize(horizontal: false, vertical: true)
        }
        .padding(.bottom, 32)
        .background(.green)
        .padding(.leading, 40 + 20)
        .overlay(alignment: .leading) {
            VStack(alignment: .center, spacing: 0) {
                ZStack {
                    Circle()
                        .fill(isCompleted ? Color.blue : Color(UIColor.systemGray5))
                        .frame(width: 40, height: 40)
    
                    Image(systemName: icon)
                        .foregroundColor(isCompleted ? .white : .gray)
                        .font(.title3)
                }
                if !isLast {
                    Rectangle()
                        .fill(isCompleted ? Color.blue : Color(UIColor.systemGray5))
                        .frame(width: 2)
                }
            }
            .frame(maxHeight: .infinity, alignment: .top) // 1. Expand the frame to fill the available height
            .background(.red)
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(.yellow)
        .padding(.horizontal, 30) // Add padding to the whole row
    }
    

    You were previously setting a frame with maxHeight: .infinity on each row. Then you had a Spacer with higher layout priority, which consumed all the space below the rows. It was this higher layout priority that was causing the strange heights for the progress indicators before.

    The frame setting maxHeight is still used for the overlay, but it doesn't have any impact on the row height any more. So the layout priority is no longer needed:

    // Timeline Steps Container
    VStack(alignment: .leading, spacing: 0) {
    
        // ... TimelineSteps, as before
    
        Spacer() //.layoutPriority(1) // 👈 layoutPriority not necessary
    }
    .padding(.vertical, 30)
    

    Screenshot