Search code examples
swiftuitextview

How to determine the angle of the first character in a curved text view in SwiftUI?


How can I dynamically update the foreground color of the titleView to change from black to white when the filled shape extends beyond the position of the first character (ex: 'H' in 'HELLO')? So far, I tried to determine the position of the first character using determineTitleStartAngle() but it only works in some cases

Currently, the animation looks like this (but the text should be black instead of white since the fill shape is before the position of the first character):

enter image description here

This is what the animation should look like if the fill shape passes the position of the first character:

enter image description here

struct ContentView: View {

    let size: CGFloat = 300
    let startAngle: CGFloat = 180
    let endAngle: CGFloat = 240
    var midpointAngle: CGFloat {
        (startAngle + endAngle) / 2
    }

    @State private var progressAngle: CGFloat = 180
    @State private var filledEndAngle: CGFloat = 195
    @State private var titleColor: Color = .black

    var body: some View {
        ZStack {
            // background shape
            ArcShape(start: startAngle, end: endAngle)
                .foregroundColor(.gray)
            
            // fill shape
            ArcShape(start: startAngle, end: progressAngle)
                .onAppear {
                    withAnimation(.easeInOut(duration: 0.6)) {
                        self.progressAngle = filledEndAngle
                    }
                }
                .foregroundColor(.green)

            titleView(string: "HELLO")
                .rotationEffect(.degrees((midpointAngle + 90)))
                .font(.custom("AvenirNext-DemiBold", size: 12))
                .foregroundColor(titleColor)
                .onChange(of: progressAngle) { _, newValue in
                    if newValue >= determineTitleStartAngle() {
                        withAnimation(.easeInOut.delay(0.3)) {
                            titleColor = .white
                        }
                    }
                }
        }
        .frame(width: size)
    }

    func determineTitleStartAngle() -> CGFloat {
        let midpointAngle = (startAngle + endAngle) / 2
        let titleStartAngle = (startAngle + midpointAngle) / 2

        return titleStartAngle
    }

    func titleView(string: String) -> some View {
        HStack(spacing: 2) {
            ForEach(Array(string.enumerated()), id: \.offset) { index, character in
                Text(String(character))
            }
        }
        .hidden()
        .overlay {
            GeometryReader { fullText in
                let textWidth = fullText.size.width
                let radius = size * 0.36
                let arcAngle = textWidth / radius
                let startAngle = -(arcAngle / 2)
                HStack(spacing: 2) {
                    ForEach(Array(string.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)
                                    let xOffset = (textWidth / 2) - midX
                                    Text(String(character))
                                        .offset(y: -radius)
                                        .rotationEffect(.radians(angle))
                                        .offset(x: xOffset)
                                }
                            }
                    }
                }
            }
        }
        .coordinateSpace(name: "FullText")
    }

}

struct ArcShape: Shape {
    var start: CGFloat
    var end: CGFloat
    var animatableData: CGFloat {
        get { end }
        set { end = newValue }
    }

    func path(in rect: CGRect) -> Path {
        let shorterLength = min(rect.width, rect.height)
        let path = UIBezierPath(
            roundedArcCenter: rect.center,
            innerRadius: (shorterLength / 2) * 0.54,
            outerRadius: (shorterLength / 2) * 0.90,
            startAngle: Angle(degrees: start),
            endAngle: Angle(degrees: end),
            cornerRadiusPercentage: 0.01
        )
        return Path(path.cgPath)
    }
}


#Preview {
    ContentView()
}

extension UIBezierPath {
    public convenience init(roundedArcCenter center: CGPoint, innerRadius: CGFloat, outerRadius: CGFloat, startAngle: Angle, endAngle: Angle, cornerRadiusPercentage: CGFloat) {
        let maxCornerRadiusBasedOnInnerArcLength = abs((endAngle - startAngle).radians) * innerRadius / 2
        let maxCornerRadiusBasedOnOuterArcLength = abs((endAngle - startAngle).radians) * outerRadius / 2
        let maxCornerRadiusBasedOnEndCapLength = (outerRadius - innerRadius) / 2
        let outerCornerRadius = min(2 * .pi * outerRadius * cornerRadiusPercentage, maxCornerRadiusBasedOnOuterArcLength, maxCornerRadiusBasedOnEndCapLength)
        let outerCornerRadiusPercentage = outerCornerRadius / (2 * .pi * outerRadius)
        let innerCornerRadius = min(2 * .pi * innerRadius * outerCornerRadiusPercentage, maxCornerRadiusBasedOnInnerArcLength, maxCornerRadiusBasedOnEndCapLength)
        let innerInsetAngle = Angle(radians: innerCornerRadius / innerRadius)
        let outerInsetAngle = Angle(radians: outerCornerRadius / outerRadius)
        self.init()
        var arcStartAngle = (startAngle + outerInsetAngle).radians
        var arcEndAngle = (endAngle - outerInsetAngle).radians
        addArc(
            withCenter: .zero,
            radius: outerRadius,
            startAngle: min(arcStartAngle, arcEndAngle),
            endAngle: max(arcStartAngle, arcEndAngle),
            clockwise: true
        )
        addCorner(
            to: .pointOnCircle(radius: outerRadius - outerCornerRadius, angle: endAngle),
            controlPoint: .pointOnCircle(radius: outerRadius, angle: endAngle)
        )
        addLine(to: .pointOnCircle(radius: innerRadius + innerCornerRadius, angle: endAngle))
        addCorner(
            to: .pointOnCircle(radius: innerRadius, angle: endAngle - innerInsetAngle),
            controlPoint: .pointOnCircle(radius: innerRadius, angle: endAngle)
        )
        arcStartAngle = (endAngle - innerInsetAngle).radians
        arcEndAngle = (startAngle + innerInsetAngle).radians
        addArc(
            withCenter: .zero,
            radius: innerRadius,
            startAngle: max(arcStartAngle, arcEndAngle),
            endAngle: min(arcStartAngle, arcEndAngle),
            clockwise: false
        )
        addCorner(
            to: .pointOnCircle(radius: innerRadius + innerCornerRadius, angle: startAngle),
            controlPoint: .pointOnCircle(radius: innerRadius, angle: startAngle)
        )
        addLine(to: .pointOnCircle(radius: outerRadius - outerCornerRadius, angle: startAngle))
        addCorner(
            to: .pointOnCircle(radius: outerRadius, angle: startAngle + outerInsetAngle),
            controlPoint: .pointOnCircle(radius: outerRadius, angle: startAngle)
        )
        apply(.init(translationX: center.x, y: center.y))
    }
    private func addCorner(to: CGPoint, controlPoint: CGPoint) {
        let circleApproximationConstant = 0.551915
        addCurve(
            to: to,
            controlPoint1: currentPoint + (controlPoint - currentPoint) * circleApproximationConstant,
            controlPoint2: to + (controlPoint - to) * circleApproximationConstant
        )
    }

}

private extension CGPoint {
    static func pointOnCircle(radius: CGFloat, angle: Angle) -> CGPoint {
        CGPoint(x: radius * Darwin.cos(angle.radians), y: radius * Darwin.sin(angle.radians))
    }
    static func + (lhs: CGPoint, rhs: CGPoint) -> CGPoint {
        CGPoint(x: lhs.x + rhs.x, y: lhs.y + rhs.y)
    }
    static func - (lhs: CGPoint, rhs: CGPoint) -> CGPoint {
        CGPoint(x: lhs.x - rhs.x, y: lhs.y - rhs.y)
    }
    static func * (lhs: CGPoint, rhs: CGFloat) -> CGPoint {
        CGPoint(x: lhs.x * rhs, y: lhs.y * rhs)
    }
}

public extension CGRect {
    var center: CGPoint {
        CGPoint(x: size.width / 2.0, y: size.height / 2.0)
    }
}

Solution

  • The start-of-text angle is known inside the function that shows the text, so it works best if you let this function apply the color change when the threshold is reached.

    However, the hardest part of this problem is to detach the animation of the text color from the animation of the shape. Normally, when something is animated, SwiftUI inspects the start state and the end state and then interpolates between them. Here, the start state has dark text and the end state has white text. If the full animation is performed by SwiftUI, you see the color changing from the first moment, not from the moment when the progress angle is at start-of-text. I think you must have discovered this too, which is perhaps why you added a delay to the color animation!

    In order that the color change only happens when the threshold is reached, I think an Animatable view modifier is required. At least, I managed to get it working this way.

    Here you go:

    struct ContentView: View {
    
        let size: CGFloat = 300
        let startAngle: CGFloat = 180
        let endAngle: CGFloat = 240
        var midpointAngle: CGFloat {
            (startAngle + endAngle) / 2
        }
    
        @State private var progressAngle: CGFloat = 180
        @State private var titleColor: Color = .black
    
        var body: some View {
            ZStack {
                // background shape
                ArcShape(start: startAngle, end: endAngle)
                    .foregroundColor(.gray)
    
                // fill shape
                ArcShape(start: startAngle, end: progressAngle)
                    .onAppear {
                        withAnimation(.linear(duration: 3)) {
                            self.progressAngle = endAngle
                        }
                    }
                    .foregroundColor(.green)
    
                titleView(string: "HELLO")
                    .rotationEffect(.degrees((midpointAngle + 90)))
                    .font(.custom("AvenirNext-DemiBold", size: 12))
            }
            .frame(width: size)
        }
    
        // Ref. https://stackoverflow.com/q/77277211/20386264
        func titleView(string: String) -> some View {
            HStack(spacing: 2) {
                ForEach(Array(string.enumerated()), id: \.offset) { index, character in
                    Text(String(character))
                }
            }
            .hidden()
            .overlay {
                GeometryReader { fullText in
                    let textWidth = fullText.size.width
                    let radius = size * 0.36
                    let arcAngle = textWidth / radius
                    let startAngle = -(arcAngle / 2)
                    HStack(spacing: 2) {
                        ForEach(Array(string.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)
                                        let xOffset = (textWidth / 2) - midX
                                        Text(String(character))
                                            .offset(y: -radius)
                                            .rotationEffect(.radians(angle))
                                            .offset(x: xOffset)
                                    }
                                }
                        }
                    }
                    .modifier(
                        ForegroundColorModifier(
                            foregroundBegin: titleColor,
                            foregroundThreshold: .white,
                            thresholdDegrees: startAngle * 180 / CGFloat.pi,
                            progressDegrees: progressAngle - midpointAngle
                        )
                    )
                }
            }
            .coordinateSpace(name: "FullText")
        }
    
        struct ForegroundColorModifier: ViewModifier, Animatable {
            let foregroundBegin: Color
            let foregroundThreshold: Color
            let thresholdDegrees: CGFloat
            var progressDegrees: CGFloat
    
            /// Implementation of protocol property
            var animatableData: CGFloat {
                get { progressDegrees }
                set { progressDegrees = newValue }
            }
    
            private var isThresholdReached: Bool {
                progressDegrees >= thresholdDegrees
            }
    
            func body(content: Content) -> some View {
                content
                    .foregroundColor(
                        isThresholdReached ? foregroundThreshold : foregroundBegin
                    )
                    .animation(
                        .easeInOut(duration: 0.15),
                        value: isThresholdReached
                    )
            }
        }
    }
    

    Animation


    EDIT You might notice that the change of color seems to happen a bit late. This is because, the animated change only begins when the start-of-text angle is reached, so by the time the animation has completed, the progress has already gone past start-of-text. It looks a bit better if you allow the animation to start ahead of time by giving "advance warning" of progress.

    If you wanted the animation to be finished by the time the progress reaches start-of-text, then the advance warning would be calculated as:

    advance warning degrees =
        (angle degrees) * (color animation time) / (arc animation time)
    

    So if the color animation time would be increased to 0.3s, the arc animation time is 3.0s and the angle is 60 degrees, the advance warning would be 6 degrees.

    I found that it works quite well if you use just ⅔ of this value. To try it, change the function isThresholdReached to the following (and increase the duration of the color animation from 0.15 to 0.3):

    private var isThresholdReached: Bool {
        let advanceWarning = (0.3 / 3.0) * (60 * 2 / 3)
        return (progressDegrees + advanceWarning) >= thresholdDegrees
    }
    

    It would probably be best if the arc angle and maybe even the arc animation time would be passed as init parameters to ForegroundColorModifier.