This works great with custom fonts and updating the view's frame causing layout changes:
Here's the code:
public struct FirstLineCenterID: AlignmentID {
static func defaultValue(in d: ViewDimensions) -> CGFloat {
d[VerticalAlignment.center]
}
}
/// Custom vertical alignment used to coordinate views against the **first line center**.
extension VerticalAlignment {
static let firstLineCenter = VerticalAlignment(FirstLineCenterID.self)
}
// MARK: - FirstLineCenteredLabel
/// A `Label`-like view that displays a leading icon and a text label, aligning the icon
/// to the **vertical midpoint of the text’s first line**.
struct FirstLineCenteredLabel<Icon>: View where Icon : View {
let text: String
let spacing: CGFloat?
let icon: Icon
/// Cached measured height of a single line for the current font.
@State private var firstLineHeight: CGFloat?
/// The effective font pulled from the environment; used by both visible and measuring text.
@Environment(\.font) var font
init(
_ text: String,
spacing: CGFloat? = nil,
@ViewBuilder icon: () -> Icon
) {
self.text = text
self.spacing = spacing
self.icon = icon()
}
var body: some View {
HStack(alignment: .firstLineCenter, spacing: spacing) {
let text = Text(text)
icon
// aligns by its vertical center
.alignmentGuide(.firstLineCenter) { d in d[.top] + d.height / 2 }
.font(font)
text
.font(font)
.fixedSize(horizontal: false, vertical: true)
// aligns by the first line's vertical midpoint
.alignmentGuide(.firstLineCenter) { d in
let h = firstLineHeight ?? d.height
return d[.top] + h / 2
}
// Measure the natural height of a single line **without impacting layout**:
// a 1-line clone in an overlay with zero frame captures `geo.size.height` for the
// current environment font. This avoids the `.hidden()` pitfall which keeps layout space.
.overlay(alignment: .topLeading) {
text.font(font).lineLimit(1).fixedSize()
.overlay(
GeometryReader { g in
Color.clear
.onAppear { firstLineHeight = g.size.height }
.onChange(of: g.size.height) { firstLineHeight = $0 }
}
)
.opacity(0)
.frame(width: 0, height: 0)
.allowsHitTesting(false)
.accessibilityHidden(true)
}
}
}
}
Usage:
var body: some View {
VStack {
FirstLineCenteredLabel(longText, spacing: 8) {
Image(systemName: "star.fill")
}
FirstLineCenteredLabel(shortText, spacing: 8) {
Image(systemName: "star.fill")
}
Divider()
// Just to showcase that it can handle various font sizing.
FirstLineCenteredLabel(longText, spacing: 8) {
Image(systemName: "star.fill")
.font(.largeTitle
}
.font(.caption)
}
}
private var longText: String {
"This is a new label view that places an image/icon to the left of the text and aligns it to the text's first line vertical midpoint."
}
private var shortText: String {
"This should be one line.
}