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)
}
}
}
Spacer()
between the 2 views inVStack
, also remove spacing.