swiftuivstackswiftui-alignment-guide

SwiftUI VStack alignment guide with other views


I'm struggling to achieve the following custom alignment in a SwiftUI VStack:

Screenshot with correctly aligned subviews

I've created a custom alignment guide to use in the Event view, as follows:

extension HorizontalAlignment {
    struct EventLeading: AlignmentID {
        static func defaultValue(in d: ViewDimensions) -> CGFloat {
            d[HorizontalAlignment.leading]
        }
    }

    static let eventLeading = HorizontalAlignment(EventLeading.self)
}

struct Event: View {
    
    let date: String
    let event: String
    
    var body: some View {
        HStack(alignment: .firstTextBaseline) {
            Text(date)
                .font(.caption)
            Text(event)
                .alignmentGuide(.eventLeading) { d in d[.leading]}
        }
    }
}

However, in the same VStack are Divider views between adjacent Events, and these do not specify the eventLeading alignment guide and assume the default (.leading in this case):

struct VStackAlignedToGuide: View {
    
    var body: some View {
        VStack(alignment: .eventLeading) {
            Event(date: "12 Oct 1568", event: "Born")
            Divider()
            Event(date: "2 Feb 1612", event: "Ate porridge for breakfast")
            Divider()
            Event(date: "1613", event: "Lost a shoe")
        }
    }
}

let test = VStackAlignedToGuide()
    .frame(width: 400)
PlaygroundPage.current.setLiveView(test)

This causes the events to shift left and the dividers to shift right:

Screenshot with events shifted left and dividers shifted right

Is there a way to achieve this simply and cleanly? I've read lots of articles online. This excellent guide has one possible solution I haven't tried... but my layout requirement seems so simple there must be an easier way!

I thought I might manage it by expanding the frame of the event to the width of the container, and then using the alignment guide within the frame. That way it would not interfere with the alignment of the VStack. I've tried lots of combinations without success. This is one:

struct Event: View {
    
    let date: String
    let event: String
    
    var body: some View {
        HStack(alignment: .firstTextBaseline) {
            Text(date)
                .font(.caption)
            Text(event)
                .alignmentGuide(.eventLeading) { d in d[.leading]}
        }
        .border(.blue)
        .frame(maxWidth: .infinity,
               alignment:
                Alignment(horizontal: .eventLeading,
                          vertical: .top))
        .border(.black)
    }
}

This fails because the .eventLeading alignment guide in the individual Events is aligned to the leading edge of the container:

Screenshot of experiment with maxWidth frame and alignment.

Any help very much appreciated. Thank you.


Solution

  • iOS 16 and later

    If your deployment target is iOS 16 (released in 2022) or later, use Grid:

    struct EventRow: View {
        let date: String
        let event: String
    
        var body: some View {
            GridRow {
                Text(date)
                    .font(.caption)
                    .gridColumnAlignment(.trailing)
                Text(event)
                    .gridColumnAlignment(.leading)
            }
        }
    }
    
    struct RootView: View {
        var body: some View {
            Grid(alignment: .leadingFirstTextBaseline) {
                EventRow(date: "12 Oct 1568", event: "Born")
                Divider()
                EventRow(date: "2 Feb 1612", event: "Ate porridge for breakfast")
                Divider()
                EventRow(date: "1613", event: "Lost a shoe")
            }
            .padding()
        }
    }
    

    Result:

    grid rows aligned as requested

    Older than iOS 16

    There's no elegant way to do this on older versions of iOS. Apple didn't add good layout tools (like Grid and Layout) until iOS 16.

    One hacky way to do it is to put short, hidden dividers in the VStack and collect their y positions. Then, overlay the VStack with visible dividers at those positions. The visible dividers aren't inside the VStack so they aren't subject to its alignment, and they pick up its width automatically. However, you cannot set a visible divider's y position without also setting its x position (lest the divider's x position be set to zero), so you also need to pick up the VStack's x position.

    We'll use the eventLeading alignment you defined already:

    extension HorizontalAlignment {
        private enum EventLeading: AlignmentID {
            static func defaultValue(in d: ViewDimensions) -> CGFloat { d[.leading] }
        }
    
        static let eventLeading = HorizontalAlignment(EventLeading.self)
    }
    
    struct EventRow: View {
        let date: String
        let event: String
    
        var body: some View {
            HStack(alignment: .firstTextBaseline) {
                Text(date)
                    .font(.caption)
                Text(event)
                    .alignmentGuide(.eventLeading) { d in d[.leading] }
            }
        }
    }
    

    We also need a structure to collect the y positions of the hidden dividers and the x position of the VStack:

    struct DividerLayoutInfo {
        var x: CGFloat? = nil
        var ys: [CGFloat] = []
    }
    

    To actually collect the positions, we need to use SwiftUI's preference modifier, which means we need a type that conforms to PreferenceKey. We might as well use DividerLayoutInfo for that too:

    extension DividerLayoutInfo: PreferenceKey {
        static var defaultValue: Self = .init()
    
        static func reduce(value: inout Self, nextValue: () -> Self) {
            let nextValue = nextValue()
            value.x = value.x ?? nextValue.x
            value.ys += nextValue.ys
        }
    }
    

    We'll also need a name for the coordinateSpace from which we collect positions and in which we lay out the visible dividers:

    private let geometryName = "myGeometry"
    

    We'll overlay the VStack with this view to copy the VStack's x center position into a preference:

    fileprivate struct XReader: View {
        var body: some View {
            GeometryReader {
                Color.clear.preference(
                    key: DividerLayoutInfo.self,
                    value: .init(x: $0.frame(in: .named(geometryName)).midX)
                )
            }
        }
    }
    

    We'll use this view to place each hidden divider into the VStack and copy its y center into a preference:

    fileprivate struct HiddenDivider: View {
        var body: some View {
            Divider()
                .frame(width: 10) // SHORT! So it doesn't mess up the VStack layout.
                .hidden() // Hidden views are still part of layout.
                .overlay {
                    GeometryReader {
                        let y = $0.frame(in: .named(geometryName)).midY
                        Color.clear
                            .preference(
                                key: DividerLayoutInfo.self,
                                value: .init(ys: [y])
                            )
                    }
                }
        }
    }
    

    We'll overlay the VStack with this view to draw the visible dividers:

    fileprivate struct VisibleDividers: View {
        let info: DividerLayoutInfo
    
        var body: some View {
            if let x = info.x {
                ForEach(info.ys, id: \.self) {
                    Divider()
                        .position(x: x, y: $0)
                }
            }
        }
    }
    

    Finally, here's the view that puts it all together, using overlayPreferenceValue to access the collected DividerLayoutInfo:

    struct RootView: View {
        var body: some View {
            VStack(alignment: .eventLeading) {
                EventRow(date: "12 Oct 1568", event: "Born")
                HiddenDivider()
                EventRow(date: "2 Feb 1612", event: "Ate porridge for breakfast")
                HiddenDivider()
                EventRow(date: "1613", event: "Lost a shoe")
            }
            .overlay { XReader() }
            .overlayPreferenceValue(DividerLayoutInfo.self) {
                VisibleDividers(info: $0)
            }
            .coordinateSpace(name: geometryName)
            .padding()
        }
    }
    

    Result:

    another grid as requested

    Here's all the code together for your convenience:

    extension HorizontalAlignment {
        private enum EventLeading: AlignmentID {
            static func defaultValue(in d: ViewDimensions) -> CGFloat { d[.leading] }
        }
    
        static let eventLeading = HorizontalAlignment(EventLeading.self)
    }
    
    struct EventRow: View {
        let date: String
        let event: String
    
        var body: some View {
            HStack(alignment: .firstTextBaseline) {
                Text(date)
                    .font(.caption)
                Text(event)
                    .alignmentGuide(.eventLeading) { d in d[.leading]}
            }
        }
    }
    
    fileprivate struct DividerLayoutInfo {
        var x: CGFloat? = nil
        var ys: [CGFloat] = []
    }
    
    extension DividerLayoutInfo: PreferenceKey {
        static var defaultValue: Self = .init()
    
        static func reduce(value: inout Self, nextValue: () -> Self) {
            let nextValue = nextValue()
            value.x = value.x ?? nextValue.x
            value.ys += nextValue.ys
        }
    }
    
    private let geometryName = "myGeometry"
    
    fileprivate struct XReader: View {
        var body: some View {
            GeometryReader {
                Color.clear.preference(
                    key: DividerLayoutInfo.self,
                    value: .init(x: $0.frame(in: .named(geometryName)).midX)
                )
            }
        }
    }
    
    fileprivate struct HiddenDivider: View {
        var body: some View {
            Divider()
                .frame(width: 10)
                .hidden()
                .overlay {
                    GeometryReader {
                        let y = $0.frame(in: .named(geometryName)).midY
                        Color.clear
                            .preference(
                                key: DividerLayoutInfo.self,
                                value: .init(ys: [y])
                            )
                    }
                }
        }
    }
    
    fileprivate struct VisibleDividers: View {
        let info: DividerLayoutInfo
    
        var body: some View {
            if let x = info.x {
                ForEach(info.ys, id: \.self) {
                    Divider()
                        .position(x: x, y: $0)
                }
            }
        }
    }
    
    struct RootView: View {
        var body: some View {
            VStack(alignment: .eventLeading) {
                EventRow(date: "12 Oct 1568", event: "Born")
                HiddenDivider()
                EventRow(date: "2 Feb 1612", event: "Ate porridge for breakfast")
                HiddenDivider()
                EventRow(date: "1613", event: "Lost a shoe")
            }
            .overlay { XReader() }
            .overlayPreferenceValue(DividerLayoutInfo.self) {
                VisibleDividers(info: $0)
            }
            .coordinateSpace(name: geometryName)
            .padding()
        }
    }