iosswiftui

How to create circular text in SwiftUI that goes from a flat line to a perfect circle based on degree changes?


I'm trying to create a circular text view in SwiftUI where the text starts as a flat line and, as I change the arc degree using a slider, it gradually forms a perfect circle when the degree reaches 360. Here's what I have so far:

import SwiftUI

struct CirculerTextView: View {
    @State private var arcDegrees: Double = 0
    let text: String = "Happy Mornings"
    
    var body: some View {
        VStack(spacing: 40) {
            GeometryReader { geometry in
                CirculerText(text: text, arcDegrees: arcDegrees, size: geometry.size)
                    .frame(height: 200)
            }
            .frame(height: 200)
            .border(Color.gray.opacity(0.3))
            
            VStack {
                Slider(value: $arcDegrees, in: 0...360, step: 1)
                    .padding(.horizontal)
                Text("Arc Degrees: \(Int(arcDegrees))")
                    .font(.subheadline)
            }
        }
        .padding()
    }
}

struct CirculerText: View {
    let text: String
    let arcDegrees: Double
    let size: CGSize
    
    var body: some View {
        let letters = Array(text)
        let letterCount = letters.count
        let chordLength = size.width * 0.5
        let spacing = letterCount > 1 ? chordLength / CGFloat(letterCount - 1) : 0
        let startX = (size.width - chordLength) / 2
        let centerY = size.height / 2
        let centerX = size.width / 2
        
        let totalArcRad = arcDegrees * .pi / 360
        let safeArcRad = max(abs(totalArcRad), 0.0001)
        let radius = chordLength / (2 * CGFloat(sin(safeArcRad / 2)))
        let circleCenter = CGPoint(x: centerX, y: centerY)
        
        return ZStack {
            ForEach(0..<letterCount, id: \.self) { index in
                let letterAngle = -totalArcRad / 2 + totalArcRad * Double(index) / Double(letterCount - 1)
                let x = circleCenter.x + radius * CGFloat(sin(letterAngle))
                let y = circleCenter.y - radius * CGFloat(cos(letterAngle))
                
                let finalPos = CGPoint(x: x, y: y)
                let textRotation = arcDegrees == 0 ? 0 : -angleInDegrees(letterAngle)
                
                Text(String(letters[index]))
                    .font(.title)
                    .position(finalPos)
                    .rotationEffect(.degrees(textRotation))
            }
        }
    }
    
    func angleInDegrees(_ radians: Double) -> Double {
        return radians * 360 / .pi
    }
}

struct CirculerTextView_Previews: PreviewProvider {
    static var previews: some View {
        CirculerTextView()
    }
}

The idea is that as I move the slider, the text should gradually transition from a flat line to a full circle as the arcDegrees value increases from 0 to 360. Right now, it works for creating a circular arrangement of text, but I'm struggling to get it to transition smoothly, especially when the degrees are smaller (less than 360), where the text still appears straight.

How can I modify the code so that:

The text smoothly transitions from a flat line to a circular arc. When the degree reaches 360, the text perfectly forms a circle. Any help would be greatly appreciated!

what I am trying to achieve

enter image description here

what I am getting result right now

enter image description here


Solution

  • A SwiftUI solution for curved text can be found in the answer to SwiftUI: How to have equal spacing between letters in a curved text view? (it was my answer).

    That solution takes the circle radius as parameter, from which it computes the angle. Here is an adapted version that uses your naming and takes the angle as parameter, from which it computes the radius. An angle of 0 requires special handling, because the radius would be infinity.

    // Adaption of CurvedText, see
    // https://stackoverflow.com/a/77280669/20386264
    // for code comments and more explanation
    struct CircularText: View {
        let text: String
        let arcAngle: Angle
    
        var body: some View {
            if arcAngle.radians == 0.0 {
                textAsChars
            } else {
                textAsChars
                    .fixedSize()
                    .hidden()
                    .overlay {
                        GeometryReader { fullText in
                            let textWidth = fullText.size.width
                            let startAngle = -(arcAngle.radians / 2)
                            let radius = textWidth / arcAngle.radians
                            HStack(spacing: 0) {
                                ForEach(Array(text.enumerated()), id: \.offset) { index, character in
                                    Text(String(character))
                                        .hidden()
                                        .overlay {
                                            GeometryReader { charSpace in
                                                let midX = charSpace.frame(in: .named("FullText")).midX
                                                let fraction = midX / textWidth
                                                let angle = startAngle + (fraction * arcAngle.radians)
                                                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")
            }
        }
    
        private var textAsChars: some View {
            HStack(spacing: 0) {
                ForEach(Array(text.enumerated()), id: \.offset) { index, character in
                    Text(String(character))
                }
            }
        }
    }
    

    Example use:

    struct CircularTextView: View {
        @State private var arcDegrees: Double = 0
        let text: String = "The quick brown fox jumps over the lazy dog"
    
        var body: some View {
            VStack {
                CircularText(text: text, arcAngle: .degrees(arcDegrees))
                    .frame(height: 300)
                    .border(Color.gray.opacity(0.3))
                Slider(value: $arcDegrees, in: -360...360, step: 1)
                    .padding(.horizontal)
                Text("Arc Degrees: \(Int(arcDegrees))")
                    .font(.subheadline)
            }
            .padding()
        }
    }
    

    Note that a GeometryReader is not needed, because the text adopts its natural width.

    Animation