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:

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:

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()
}
}