Search code examples
swiftuitextview

SwiftUI: How to have equal spacing between letters in a curved text view?


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:

enter image description here

Ideally, I would like the spacing to look like this (with the correct custom font):

enter image description here

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()
    }
}

Solution

  • 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)
            }
        }
    }
    

    CurvedText

    EDIT For more examples of how to use CurvedText, see How to create equal spacing between letters in a curved text view?