28

I'm trying to achieve a SwiftUI text alignment as illustrated by this image. The goal is to align the top of text ".0" with the top edge of "7" (purple line) and the bottom edge of "kts" with the bottom edge of "7" (red line).

Desired result

Here is my current SwiftUI code:

HStack(alignment: .lastTextBaseline, spacing: 3) {
    
    Text("7")
    .font(.system(size: 70))
    .foregroundColor(Color.green)
    .multilineTextAlignment(.center)
    .minimumScaleFactor(0.3)
    
    VStack(alignment: .leading, spacing: 5) {

        Text(".7")
        .font(.system(size: 24))
        .foregroundColor(Color.green)
                        
        Text("kts")
        .font(.system(size: 18))
        .foregroundColor(Color.white)
                            
    }
                
}

This code works for the alignment shown by the red line.

What approach would you recommend to also align the top of "7" and ".0" as shown by the purple line?

1
  • Try add Spacer() between the 2 views in VStack, also remove spacing.
    – Tj3n
    Commented Sep 11, 2020 at 9:03

2 Answers 2

57

You can use Stacks to stack font. There is no issue for the bottom line, but You can get help from the UIFont that gives you the information you need like:

struct ContentView: View {
    let bigFont = UIFont.systemFont(ofSize: 70)
    let smallFont = UIFont.systemFont(ofSize: 24)

    var body: some View {
        ZStack(alignment: Alignment(horizontal: .leading, vertical: .center)) {
            HStack(alignment: .firstTextBaseline, spacing: 0) {
                Text("7").font(Font(bigFont))
                Text("kts").font(Font(smallFont))
            }
            HStack(alignment: .firstTextBaseline, spacing: 0) {
                Text("7")
                    .font(Font(bigFont))
                    .opacity(0)
                Text(".0")
                    .font(Font(smallFont))
                    .baselineOffset((bigFont.capHeight - smallFont.capHeight))
            }
        }
    }
}

Preview


More information:

Here is an image about the description of the Font for more information:

Info

1
  • 3
    Cute trick. This kind of stuff still drives me a little nuts with SUI. Nicely done, though. Commented May 6, 2021 at 0:15
0

In SwiftUI, there are multiple ways to achieve the desired layout.

Example #1

In this example, we create a ZStack container. Inside it, the first HStack contains the "integer" and "decimal" views, where the "decimal" view is vertically aligned using an alignmentGuide (or baselineOffset). In the second HStack, a hidden duplicate of the "integer" view is rendered to align the "unit" view with it.

struct ContentView: View {
    let bigFont = UIFont.systemFont(ofSize: 100)
    let smallFont = UIFont.systemFont(ofSize: 24)

    var body: some View {
        ZStack(alignment: .leading) {
            HStack(alignment: .firstTextBaseline, spacing: 5) {
                Text("7")
                    .font(Font(bigFont))
                Text(".0")
                    .font(Font(smallFont))
                    .alignmentGuide(.firstTextBaseline) { dimensions in
                        dimensions[.firstTextBaseline] + bigFont.capHeight - smallFont.capHeight
                    }
                    // As an alternative to alignmentGuide(_:computeValue:):
                    // .baselineOffset(bigFont.capHeight - smallFont.capHeight)
            }
            HStack(alignment: .firstTextBaseline, spacing: 5) {
                // Render a hidden version of "7" to align "kts" with its baseline.
                Text("7")
                    .font(Font(bigFont))
                    .hidden()
                Text("kts")
                    .font(Font(smallFont))
            }
        }
    }
}

Example #2

This approach is straightforward and involves placing the "decimal" view in an overlay and applying an offset to it.

struct ContentView: View {
    let bigFont = UIFont.systemFont(ofSize: 100)
    let smallFont = UIFont.systemFont(ofSize: 24)

    var body: some View {
        HStack(alignment: .firstTextBaseline, spacing: 5) {
            Text("7")
                .font(Font(bigFont))

            Text("kts")
                .font(Font(smallFont))
                .overlay(alignment: .leading) {
                    Text(".0")
                        .font(Font(smallFont))
                        .offset(y: smallFont.capHeight - bigFont.capHeight)
                }
        }
    }
}

Example #3

Here, we create an HStack container with .firstTextBaseline alignment, place a ZStack with .bottomLeading alignment for the "decimal" and "unit" views, and apply a baselineOffset to the "decimal" view.

struct ContentView: View {
    let bigFont = UIFont.systemFont(ofSize: 100)
    let smallFont = UIFont.systemFont(ofSize: 24)

    var body: some View {
        HStack(alignment: .firstTextBaseline, spacing: 5) {
            Text("7")
                .font(Font(bigFont))
            ZStack(alignment: .bottomLeading) {
                Text(".0")
                    .font(Font(smallFont))
                    .baselineOffset(bigFont.capHeight - smallFont.capHeight)
                Text("kts")
                    .font(Font(smallFont))
            }
        }
    }
}

Example #4

In this approach, we place all views inside an HStack, use alignmentGuide to align the "decimal" view vertically, utilize PreferenceKey to capture the width of the "decimal" view, and apply a horizontal offset to the "unit" view based on that width.

struct WidthReaderPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}

struct WidthReader: ViewModifier {
    func body(content: Content) -> some View {
        content
            .overlay(
                GeometryReader { proxy in
                    Color.clear
                        .preference(
                            key: WidthReaderPreferenceKey.self,
                            value: proxy.size.width
                        )
                }
            )
    }
}

extension View {
    func widthReader() -> some View {
        modifier(WidthReader())
    }
}
struct ContentView: View {
    let bigFont = UIFont.systemFont(ofSize: 100)
    let smallFont = UIFont.systemFont(ofSize: 24)
    @State private var offset: CGFloat? = nil
    let spacing: CGFloat = 5

    var body: some View {
        HStack(alignment: .firstTextBaseline, spacing: spacing) {
            Text("7")
                .font(Font(bigFont))

            Text(".0")
                .font(Font(smallFont))
                .alignmentGuide(.firstTextBaseline) { dimensions in
                    dimensions[.firstTextBaseline] + bigFont.capHeight - smallFont.capHeight
                }
                .widthReader()
            Text("kts")
                .font(Font(smallFont))
                .offset(x: -(offset ?? 0) - spacing)
        }
        .onPreferenceChange(WidthReaderPreferenceKey.self) { width in
            self.offset = width
        }
    }
}

Example #5 (iOS 18+)

Here, we use a custom layout object that conforms to the Layout protocol to manually position the views.

extension ContainerValues {
    @Entry var uiFont = UIFont.preferredFont(forTextStyle: .body)
}

extension View {
    func uiFont(_ uiFont: UIFont) -> some View {
        self
            .font(Font(uiFont))
            .containerValue(\.uiFont, uiFont)
    }
}
struct SpeedStackLayout {
    var spacing: CGFloat? = nil

    private func arrangedSubviews(_ subviews: Subviews) -> (LayoutSubview, LayoutSubview, LayoutSubview) {
        (subviews[0], subviews[1], subviews[2])
    }

    private func arrangedSizes(for subviews: Subviews) -> (CGSize, CGSize, CGSize) {
        return (
            subviews[0].sizeThatFits(.unspecified),
            subviews[1].sizeThatFits(.unspecified),
            subviews[2].sizeThatFits(.unspecified)
        )
    }

    private func arrangedFonts(for subviews: Subviews) -> (UIFont, UIFont, UIFont) {
        return (
            subviews[0].containerValues.uiFont,
            subviews[1].containerValues.uiFont,
            subviews[2].containerValues.uiFont
        )
    }

    private func horizontalSpacing(between subviews: Subviews) -> CGFloat {
        let (integerView, decimalView, _) = arrangedSubviews(subviews)
        return spacing ?? integerView.spacing.distance(to: decimalView.spacing, along: .horizontal)
    }
}
extension SpeedStackLayout: Layout {
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
        let (integerViewSize, decimalviewSize, unitViewSize) = arrangedSizes(for: subviews)
        let spacing = horizontalSpacing(between: subviews)
        let totalWidth = integerViewSize.width + spacing + max(decimalviewSize.width, unitViewSize.width)
        let totalHeight = integerViewSize.height
        return CGSize(width: totalWidth, height: totalHeight)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
        let (integerView, decimalView, unitView) = arrangedSubviews(subviews)
        let (integerSize, decimalSize, unitSize) = arrangedSizes(for: subviews)
        let (integerFont, decimalFont, unitFont) = arrangedFonts(for: subviews)
        let spacing = horizontalSpacing(between: subviews)

        let integerViewPosition = CGPoint(x: bounds.minX, y: bounds.midY)
        let integerViewProposedViewSize = ProposedViewSize(width: integerSize.width, height: integerSize.height)
        integerView.place(at: integerViewPosition, anchor: .leading, proposal: integerViewProposedViewSize)

        let decimalViewPosition = CGPoint(
            x: bounds.minX + integerSize.width + spacing,
            y: bounds.minY + integerFont.ascender - integerFont.capHeight - decimalFont.ascender + decimalFont.capHeight
        )
        let decimalViewProposedViewSize = ProposedViewSize(width: decimalSize.width, height: decimalSize.height)
        decimalView.place( at: decimalViewPosition, anchor: .topLeading, proposal: decimalViewProposedViewSize)

        let unitViewPosition = CGPoint(
            x: bounds.minX + integerSize.width + spacing,
            y: bounds.minY + integerFont.ascender - unitFont.ascender
        )
        let unitViewProposedViewSize = ProposedViewSize(width: unitSize.width, height: unitSize.height)
        unitView.place(at: unitViewPosition, anchor: .topLeading, proposal: unitViewProposedViewSize)
    }
}
struct ContentView: View {
    let bigFont = UIFont.systemFont(ofSize: 100)
    let smallFont = UIFont.systemFont(ofSize: 24)

    var body: some View {
        SpeedStackLayout(spacing: 5) {
            Text("7")
                .uiFont(bigFont)
            Text(".0")
                .uiFont(smallFont)
            Text("kts")
                .uiFont(smallFont)
        }
    }
}

Not the answer you're looking for? Browse other questions tagged or ask your own question.