Reputation: 1107
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).
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?
Upvotes: 28
Views: 8185
Reputation: 92419
In SwiftUI, there are multiple ways to achieve the desired layout.
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))
}
}
}
}
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)
}
}
}
}
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))
}
}
}
}
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
}
}
}
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)
}
}
}
Upvotes: 0
Reputation: 119302
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))
}
}
}
}
Here is an image about the description of the Font
for more information:
Upvotes: 57