swiftuiswiftui-alignment-guide

Alignment across different VStacks and HStacks


I would like achieve the following:

The following SwiftUI layout satisfies all but the last of these constraints. Is there any way to automatically satisfy all 4 constraints (without using a GeometryReader but only AlignmenGuides)?

struct AlignmentTestView: View {
    var body: some View {
        HStack(alignment: .top) {
            VStack(alignment: .center) {
                Rectangle()
                    .fill(.blue)
                    .frame(width: 50, height: 50)
                
                Rectangle()
                    .fill(.green)
                    .frame(width: 30, height: 30)
            }
            
            VStack(alignment: .center) {
                Rectangle()
                    .fill(.red)
                    .frame(width: 200, height: 40)
                
                Rectangle()
                    .fill(.purple)
                    .frame(width: 200, height: 20)
            }
        }
        .padding()
        .background(.yellow)
    }
}

enter image description here


Solution

  • (Assuming the sizes of the boxes are dynamic and can be any value)

    This looks like a Grid. Each box can be "anchored" to a point in their own grid cell.

    Grid {
        GridRow {
            Rectangle()
                .fill(.blue)
                .frame(width: 50, height: 50)
                .gridCellAnchor(.top)
            Rectangle()
                .fill(.red)
                .frame(width: 200, height: 40)
                .gridCellAnchor(.topLeading)
        }
        GridRow {
            Rectangle()
                .fill(.green)
                .frame(width: 30, height: 30)
                .gridCellAnchor(.center)
            Rectangle()
                .fill(.purple)
                .frame(width: 200, height: 20)
                .gridCellAnchor(.leading)
        }
    }
    .padding()
    .background(.yellow)
    

    Alignment guides won't help here, because they don't work "across" containers. You can't use the alignmentGuide + overlay method to lay out the views relative to one another either, because the location of each view depends on two other views. Without a custom layout like Grid, you need to read the geometry of the views in one way or another - either using a GeometryReader or onGeometryChange.

    If you allow the use of onGeometryChange (not exactly GeometryReader), then you can read the sizes of the boxes and offset them appropriately. However, this won't affect the size of the outermost stack (the yellow area). At that point, it's easier to just use a Grid. If you want to reinvent the wheel that is Grid, you can put everything in a ZStack and position everything manually,

    @State private var blueSize = CGSize.zero
    @State private var redSize = CGSize.zero
    @State private var greenSize = CGSize.zero
    @State private var purpleSize = CGSize.zero
    
    var body: some View {
        ZStack {
            Rectangle()
                .fill(.blue)
                .frame(width: 50, height: 50)
                .onGeometryChange(for: CGSize.self, of: \.size) { blueSize = $0 }
                .position(
                    x: max(blueSize.width, greenSize.width) / 2,
                    y: blueSize.height / 2
                )
            
            Rectangle()
                .fill(.green)
                .frame(width: 30, height: 30)
                .onGeometryChange(for: CGSize.self, of: \.size) { greenSize = $0 }
                .position(
                    x: max(blueSize.width, greenSize.width) / 2,
                    y: max(blueSize.height, redSize.height) + max(greenSize.height, purpleSize.height) / 2 + 8
                )
            
            
            Rectangle()
                .fill(.red)
                .frame(width: 200, height: 40)
                .onGeometryChange(for: CGSize.self, of: \.size) { redSize = $0 }
                .position(
                    x: max(blueSize.width, greenSize.width) + redSize.width / 2 + 8,
                    y: redSize.height / 2
                )
            
            Rectangle()
                .fill(.purple)
                .frame(width: 200, height: 20)
                .onGeometryChange(for: CGSize.self, of: \.size) { purpleSize = $0 }
                .position(
                    x: max(blueSize.width, greenSize.width) + purpleSize.width / 2 + 8,
                    y: max(blueSize.height, redSize.height) + max(greenSize.height, purpleSize.height) / 2 + 8
                )
        }
        .frame(
            width: max(blueSize.width, greenSize.width) + max(redSize.width, purpleSize.width) + 8,
            height: max(blueSize.height, redSize.height) + max(greenSize.height, purpleSize.height) + 8
        )
        .padding()
        .background(.yellow)
    }
    

    This approach works with iOS 15 or earlier, where Grid is not available. You just need to change onGeometryChange to a background + GeometryReader + onChange combination.