I'm drawing a curved text view, where I would like the spacing between each of the letters to be equal.
So far, I tried updating the font to .font(.system(size: 14, design: .monospaced))
which gives me equal spacing between the letters, but its not the correct font.
Currently, the spacing looks like this:
Ideally, I would like the spacing to look like this (with the correct custom font):
struct ContentView: View {
@State private var letterWidths: [Int: Double] = [:]
private let title = "AVENIRNEXT"
var body: some View {
ZStack {
ForEach(Array(title.enumerated()), id: \.offset) { index, letter in
VStack {
Text(String(letter))
.font(.custom("AvenirNext-DemiBold", size: 14))
.kerning(3)
.background(
GeometryReader { geometry in // using this to get the width of each letter
Color.clear
.preference(
key: LetterWidthPreferenceKey.self,
value: geometry.size.width
)
}
)
.onPreferenceChange(LetterWidthPreferenceKey.self, perform: { width in
letterWidths[index] = width
})
Spacer()
}
.rotationEffect(fetchAngle(at: index))
}
.frame(width: 300, height: 300 * 0.75)
.rotationEffect(.degrees(335))
}
}
func fetchAngle(at letterPosition: Int) -> Angle {
let timesPi: (Double) -> Double = { $0 * .pi }
let radius: Double = 125
let circumference = timesPi(radius)
let finalAngle = timesPi(
letterWidths
.filter { $0.key < letterPosition }
.map(\.value)
.reduce(0, +) / circumference
)
return .radians(finalAngle)
}
}
struct LetterWidthPreferenceKey: PreferenceKey {
static var defaultValue: Double = 0
static func reduce(value: inout Double, nextValue: () -> Double) {
value = nextValue()
}
}
Here is a solution for you that uses overlays and GeometryReader
to get the frame size of the base text and also to get the frame size and relative position of each character in the text. With this information, it is possible to compute the required angle and offset for each character.
What I noticed is that when you use an HStack
to put together individual characters to form some text, the spacing does not exactly match what you get when the text is formed from a single string. However, it's a pretty close approximation. Also, I found that kerning works, even when building up the string using individual characters. So the spacing for the HStack
can always be 0.
The curved result is always centered around the vertical axis, so if you need it to be displayed at a different angle, just rotate the whole result.
struct CurvedText: View {
let string: String
let radius: CGFloat
var body: some View {
// Build the text as single characters, hidden
HStack(spacing: 0) {
ForEach(Array(string.enumerated()), id: \.offset) { index, character in
Text(String(character))
}
}
.fixedSize()
.hidden()
.overlay {
GeometryReader { fullText in
let textWidth = fullText.size.width
let arcAngle = radius == 0 ? 0 : (textWidth / radius)
let startAngle = -(arcAngle / 2)
// Build the text using single characters again
HStack(spacing: 0) {
ForEach(Array(string.enumerated()), id: \.offset) { index, character in
// Each character in the HStack is hidden
Text(String(character))
.hidden()
.overlay {
// Overlay with the same character, this time
// visible and with rotation and offset
GeometryReader { charSpace in
let midX = charSpace.frame(in: .named("FullText")).midX
let fraction = midX / textWidth
let angle = startAngle + (fraction * arcAngle)
let xOffset = (textWidth / 2) - midX
Text(String(character))
.offset(y: -radius)
.rotationEffect(.radians(angle))
.offset(x: xOffset, y: radius)
}
}
}
}
.fixedSize()
.frame(width: textWidth)
}
}
.coordinateSpace(name: "FullText")
}
}
struct ContentView: View {
var body: some View {
VStack {
CurvedText(
string: "The quick brown fox",
radius: 120
)
CurvedText(
string: "jumps over the lazy dog",
radius: 120
)
.font(.footnote)
ZStack {
CurvedText(
string: "AvenirNext-DemiBold",
radius: 100
)
.font(.custom("AvenirNext-DemiBold", size: 14))
.kerning(3)
.padding(.leading, 150)
.padding(.bottom, 75)
CurvedText(
string: "AvenirNext-DemiBold",
radius: 100
)
.font(.custom("AvenirNext-DemiBold", size: 14))
.kerning(3)
.rotationEffect(.degrees(-90))
.padding(.trailing, 150)
}
.padding(.top, 100)
.padding(.trailing, 75)
}
}
}
EDIT For more examples of how to use CurvedText
, see How to create equal spacing between letters in a curved text view?