Draw text along circular path in Swift for iOS
Asked Answered
G

8

41

I am looking for some up to date help/hints on how to draw simple single line strings around the edge of a circle using Swift2 for iOS9. I see quite dated examples involving old ObjC fragments, and oft limited to OS X only. Is this even possible in iOS within a custom UIView subclass's drawRect() method?

Goethe answered 24/9, 2015 at 22:19 Comment(0)
C
156

I was going to say "What have you tried?", but it's Friday afternoon and I got off work early, so I took the opportunity to translate my old ObjC code. Here it is, suitable for Playground. It should be trivial to put it in your UIView.

Swift 2
See below for Swift 3 & Swift 4 updates...

import UIKit

func centreArcPerpendicularText(str: String, context: CGContextRef, radius r: CGFloat, angle theta: CGFloat, colour c: UIColor, font: UIFont, clockwise: Bool){
    // *******************************************************
    // This draws the String str around an arc of radius r,
    // with the text centred at polar angle theta
    // *******************************************************

    let l = str.characters.count
    let attributes = [NSFontAttributeName: font]

    var characters: [String] = [] // This will be an array of single character strings, each character in str
    var arcs: [CGFloat] = [] // This will be the arcs subtended by each character
    var totalArc: CGFloat = 0 // ... and the total arc subtended by the string

    // Calculate the arc subtended by each letter and their total
    for i in 0 ..< l {
        characters += [String(str[str.startIndex.advancedBy(i)])]
        arcs += [chordToArc(characters[i].sizeWithAttributes(attributes).width, radius: r)]
        totalArc += arcs[i]
    }

    // Are we writing clockwise (right way up at 12 o'clock, upside down at 6 o'clock)
    // or anti-clockwise (right way up at 6 o'clock)?
    let direction: CGFloat = clockwise ? -1 : 1
    let slantCorrection = clockwise ? -CGFloat(M_PI_2) : CGFloat(M_PI_2)

    // The centre of the first character will then be at
    // thetaI = theta - totalArc / 2 + arcs[0] / 2
    // But we add the last term inside the loop
    var thetaI = theta - direction * totalArc / 2

    for i in 0 ..< l {
        thetaI += direction * arcs[i] / 2
        // Call centerText with each character in turn.
        // Remember to add +/-90ΒΊ to the slantAngle otherwise
        // the characters will "stack" round the arc rather than "text flow"
        centreText(characters[i], context: context, radius: r, angle: thetaI, colour: c, font: font, slantAngle: thetaI + slantCorrection)
        // The centre of the next character will then be at
        // thetaI = thetaI + arcs[i] / 2 + arcs[i + 1] / 2
        // but again we leave the last term to the start of the next loop...
        thetaI += direction * arcs[i] / 2
    }
}

func chordToArc(chord: CGFloat, radius: CGFloat) -> CGFloat {
    // *******************************************************
    // Simple geometry
    // *******************************************************
    return 2 * asin(chord / (2 * radius))
}

func centreText(str: String, context: CGContextRef, radius r:CGFloat, angle theta: CGFloat, colour c: UIColor, font: UIFont, slantAngle: CGFloat) {
    // *******************************************************
    // This draws the String str centred at the position
    // specified by the polar coordinates (r, theta)
    // i.e. the x= r * cos(theta) y= r * sin(theta)
    // and rotated by the angle slantAngle
    // *******************************************************

    // Set the text attributes
    let attributes = [NSForegroundColorAttributeName: c,
        NSFontAttributeName: font]
    // Save the context
    CGContextSaveGState(context)
    // Undo the inversion of the Y-axis (or the text goes backwards!)
    CGContextScaleCTM(context, 1, -1)
    // Move the origin to the centre of the text (negating the y-axis manually)
    CGContextTranslateCTM(context, r * cos(theta), -(r * sin(theta)))
    // Rotate the coordinate system
    CGContextRotateCTM(context, -slantAngle)
    // Calculate the width of the text
    let offset = str.sizeWithAttributes(attributes)
    // Move the origin by half the size of the text
    CGContextTranslateCTM (context, -offset.width / 2, -offset.height / 2) // Move the origin to the centre of the text (negating the y-axis manually)
    // Draw the text
    str.drawAtPoint(CGPointZero, withAttributes: attributes)
    // Restore the context
    CGContextRestoreGState(context)
}

// *******************************************************
// Playground code to test
// *******************************************************
let size = CGSize(width: 256, height: 256)

UIGraphicsBeginImageContextWithOptions(size, true, 0.0)
let context = UIGraphicsGetCurrentContext()!
// *******************************************************************
// Scale & translate the context to have 0,0
// at the centre of the screen maths convention
// Obviously change your origin to suit...
// *******************************************************************
CGContextTranslateCTM (context, size.width / 2, size.height / 2)
CGContextScaleCTM (context, 1, -1)

centreArcPerpendicularText("Hello round world", context: context, radius: 100, angle: 0, colour: UIColor.redColor(), font: UIFont.systemFontOfSize(16), clockwise: true)
centreArcPerpendicularText("Anticlockwise", context: context, radius: 100, angle: CGFloat(-M_PI_2), colour: UIColor.redColor(), font: UIFont.systemFontOfSize(16), clockwise: false)
centreText("Hello flat world", context: context, radius: 0, angle: 0 , colour: UIColor.yellowColor(), font: UIFont.systemFontOfSize(16), slantAngle: CGFloat(M_PI_4))


let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()

Output is: Output

Update Added clockwise / anticlockwise & straight example.

Update Swift 3

func centreArcPerpendicular(text str: String, context: CGContext, radius r: CGFloat, angle theta: CGFloat, colour c: UIColor, font: UIFont, clockwise: Bool){
    // *******************************************************
    // This draws the String str around an arc of radius r,
    // with the text centred at polar angle theta
    // *******************************************************

    let l = str.characters.count
    let attributes = [NSFontAttributeName: font]

    let characters: [String] = str.characters.map { String($0) } // An array of single character strings, each character in str
    var arcs: [CGFloat] = [] // This will be the arcs subtended by each character
    var totalArc: CGFloat = 0 // ... and the total arc subtended by the string

    // Calculate the arc subtended by each letter and their total
    for i in 0 ..< l {
        arcs += [chordToArc(characters[i].size(attributes: attributes).width, radius: r)]
        totalArc += arcs[i]
    }

    // Are we writing clockwise (right way up at 12 o'clock, upside down at 6 o'clock)
    // or anti-clockwise (right way up at 6 o'clock)?
    let direction: CGFloat = clockwise ? -1 : 1
    let slantCorrection = clockwise ? -CGFloat(M_PI_2) : CGFloat(M_PI_2)

    // The centre of the first character will then be at
    // thetaI = theta - totalArc / 2 + arcs[0] / 2
    // But we add the last term inside the loop
    var thetaI = theta - direction * totalArc / 2

    for i in 0 ..< l {
        thetaI += direction * arcs[i] / 2
        // Call centerText with each character in turn.
        // Remember to add +/-90ΒΊ to the slantAngle otherwise
        // the characters will "stack" round the arc rather than "text flow"
        centre(text: characters[i], context: context, radius: r, angle: thetaI, colour: c, font: font, slantAngle: thetaI + slantCorrection)
        // The centre of the next character will then be at
        // thetaI = thetaI + arcs[i] / 2 + arcs[i + 1] / 2
        // but again we leave the last term to the start of the next loop...
        thetaI += direction * arcs[i] / 2
    }
}

func chordToArc(_ chord: CGFloat, radius: CGFloat) -> CGFloat {
    // *******************************************************
    // Simple geometry
    // *******************************************************
    return 2 * asin(chord / (2 * radius))
}

func centre(text str: String, context: CGContext, radius r:CGFloat, angle theta: CGFloat, colour c: UIColor, font: UIFont, slantAngle: CGFloat) {
    // *******************************************************
    // This draws the String str centred at the position
    // specified by the polar coordinates (r, theta)
    // i.e. the x= r * cos(theta) y= r * sin(theta)
    // and rotated by the angle slantAngle
    // *******************************************************

    // Set the text attributes
    let attributes = [NSForegroundColorAttributeName: c,
                      NSFontAttributeName: font]
    // Save the context
    context.saveGState()
    // Undo the inversion of the Y-axis (or the text goes backwards!)
    context.scaleBy(x: 1, y: -1)
    // Move the origin to the centre of the text (negating the y-axis manually)
    context.translateBy(x: r * cos(theta), y: -(r * sin(theta)))
    // Rotate the coordinate system
    context.rotate(by: -slantAngle)
    // Calculate the width of the text
    let offset = str.size(attributes: attributes)
    // Move the origin by half the size of the text
    context.translateBy (x: -offset.width / 2, y: -offset.height / 2) // Move the origin to the centre of the text (negating the y-axis manually)
    // Draw the text
    str.draw(at: CGPoint(x: 0, y: 0), withAttributes: attributes)
    // Restore the context
    context.restoreGState()
}

// *******************************************************
// Playground code to test
// *******************************************************
let size = CGSize(width: 256, height: 256)

UIGraphicsBeginImageContextWithOptions(size, true, 0.0)
let context = UIGraphicsGetCurrentContext()!
// *******************************************************************
// Scale & translate the context to have 0,0
// at the centre of the screen maths convention
// Obviously change your origin to suit...
// *******************************************************************
context.translateBy (x: size.width / 2, y: size.height / 2)
context.scaleBy (x: 1, y: -1)

centreArcPerpendicular(text: "Hello round world", context: context, radius: 100, angle: 0, colour: UIColor.red, font: UIFont.systemFont(ofSize: 16), clockwise: true)
centreArcPerpendicular(text: "Anticlockwise", context: context, radius: 100, angle: CGFloat(-M_PI_2), colour: UIColor.red, font: UIFont.systemFont(ofSize: 16), clockwise: false)
centre(text: "Hello flat world", context: context, radius: 0, angle: 0 , colour: UIColor.yellow, font: UIFont.systemFont(ofSize: 16), slantAngle: CGFloat(M_PI_4))


let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()

Swift 4
Yet again, minor changes, this time fixing the deprecation of M_PI, String's abandonment of .characters, the parameter label change in .size(withAttributes..., and the change in text attributes to the NSAttributedStringKey enum...

import UIKit

func centreArcPerpendicular(text str: String, context: CGContext, radius r: CGFloat, angle theta: CGFloat, colour c: UIColor, font: UIFont, clockwise: Bool){
    // *******************************************************
    // This draws the String str around an arc of radius r,
    // with the text centred at polar angle theta
    // *******************************************************

    let characters: [String] = str.map { String($0) } // An array of single character strings, each character in str
    let l = characters.count
    let attributes = [NSAttributedStringKey.font: font]

    var arcs: [CGFloat] = [] // This will be the arcs subtended by each character
    var totalArc: CGFloat = 0 // ... and the total arc subtended by the string

    // Calculate the arc subtended by each letter and their total
    for i in 0 ..< l {
        arcs += [chordToArc(characters[i].size(withAttributes: attributes).width, radius: r)]
        totalArc += arcs[i]
    }

    // Are we writing clockwise (right way up at 12 o'clock, upside down at 6 o'clock)
    // or anti-clockwise (right way up at 6 o'clock)?
    let direction: CGFloat = clockwise ? -1 : 1
    let slantCorrection: CGFloat = clockwise ? -.pi / 2 : .pi / 2

    // The centre of the first character will then be at
    // thetaI = theta - totalArc / 2 + arcs[0] / 2
    // But we add the last term inside the loop
    var thetaI = theta - direction * totalArc / 2

    for i in 0 ..< l {
        thetaI += direction * arcs[i] / 2
        // Call centerText with each character in turn.
        // Remember to add +/-90ΒΊ to the slantAngle otherwise
        // the characters will "stack" round the arc rather than "text flow"
        centre(text: characters[i], context: context, radius: r, angle: thetaI, colour: c, font: font, slantAngle: thetaI + slantCorrection)
        // The centre of the next character will then be at
        // thetaI = thetaI + arcs[i] / 2 + arcs[i + 1] / 2
        // but again we leave the last term to the start of the next loop...
        thetaI += direction * arcs[i] / 2
    }
}

func chordToArc(_ chord: CGFloat, radius: CGFloat) -> CGFloat {
    // *******************************************************
    // Simple geometry
    // *******************************************************
    return 2 * asin(chord / (2 * radius))
}

func centre(text str: String, context: CGContext, radius r: CGFloat, angle theta: CGFloat, colour c: UIColor, font: UIFont, slantAngle: CGFloat) {
    // *******************************************************
    // This draws the String str centred at the position
    // specified by the polar coordinates (r, theta)
    // i.e. the x= r * cos(theta) y= r * sin(theta)
    // and rotated by the angle slantAngle
    // *******************************************************

    // Set the text attributes
    let attributes = [NSAttributedStringKey.foregroundColor: c, NSAttributedStringKey.font: font]
    //let attributes = [NSForegroundColorAttributeName: c, NSFontAttributeName: font]
    // Save the context
    context.saveGState()
    // Undo the inversion of the Y-axis (or the text goes backwards!)
    context.scaleBy(x: 1, y: -1)
    // Move the origin to the centre of the text (negating the y-axis manually)
    context.translateBy(x: r * cos(theta), y: -(r * sin(theta)))
    // Rotate the coordinate system
    context.rotate(by: -slantAngle)
    // Calculate the width of the text
    let offset = str.size(withAttributes: attributes)
    // Move the origin by half the size of the text
    context.translateBy (x: -offset.width / 2, y: -offset.height / 2) // Move the origin to the centre of the text (negating the y-axis manually)
    // Draw the text
    str.draw(at: CGPoint(x: 0, y: 0), withAttributes: attributes)
    // Restore the context
    context.restoreGState()
}

// *******************************************************
// Playground code to test
// *******************************************************
let size = CGSize(width: 256, height: 256)

UIGraphicsBeginImageContextWithOptions(size, true, 0.0)
let context = UIGraphicsGetCurrentContext()!
// *******************************************************************
// Scale & translate the context to have 0,0
// at the centre of the screen maths convention
// Obviously change your origin to suit...
// *******************************************************************
context.translateBy (x: size.width / 2, y: size.height / 2)
context.scaleBy(x: 1, y: -1)

centreArcPerpendicular(text: "Hello round 🌏 world", context: context, radius: 100, angle: 0, colour: UIColor.red, font: UIFont.systemFont(ofSize: 16), clockwise: true)
centreArcPerpendicular(text: "Anticlockwise", context: context, radius: 100, angle: CGFloat(-M_PI_2), colour: UIColor.red, font: UIFont.systemFont(ofSize: 16), clockwise: false)
centre(text: "Hello flat world", context: context, radius: 0, angle: 0 , colour: UIColor.yellow, font: UIFont.systemFont(ofSize: 16), slantAngle: .pi / 4)


let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()

Update to show use in UIView

Commentator @RitvikUpadhyaya asks how to do this in a UIView - obvious to old hands, but not perhaps to beginners. The trick is to get the right context using UIGraphicsGetCurrentContext without calling UIGraphicsBeginImageContextWithOptions (which overrides the UIView's context as the current context) - therefore your UIView should look like this:

class MyView: UIView {
    override func draw(_ rect: CGRect) {
        guard let context = UIGraphicsGetCurrentContext() else { return }
        let size = self.bounds.size

        context.translateBy (x: size.width / 2, y: size.height / 2)
        context.scaleBy (x: 1, y: -1)

        centreArcPerpendicular(text: "Hello round world", context: context, radius: 100, angle: 0, colour: UIColor.red, font: UIFont.systemFont(ofSize: 16), clockwise: true)
        centreArcPerpendicular(text: "Anticlockwise", context: context, radius: 100, angle: CGFloat(-M_PI_2), colour: UIColor.red, font: UIFont.systemFont(ofSize: 16), clockwise: false)
        centre(text: "Hello flat world", context: context, radius: 0, angle: 0 , colour: UIColor.yellow, font: UIFont.systemFont(ofSize: 16), slantAngle: CGFloat(M_PI_4))
    }
}
Copal answered 25/9, 2015 at 15:28 Comment(29)
Upvoted for including the most amazingly extensive commenting I've seen in 40 years... – Edsel
I'm telling you, when you get to my age, there is /no way/ I could remember what all my code does without it!!! :) – Copal
This is an absolutely stunning answer! Would you please add the license under which your snippet is available? Corporate friendly MIT, BSD or Apache would be great :) – Appellative
Thank you! My understanding is that the SE terms (stackexchange.com/legal) automatically apply Creative Commons Attribution Share Alike license to all material submitted... – Copal
In case you don't need the text to be centered, but start from the initial coordinate, change the line var thetaI = theta - direction * totalArc / 2 to var thetaI = theta; – Daiquiri
In Swift 3, UIColor.red() should be UIColor.red – Hatbox
This answer looks like it might help me, but how do I apply it to a UILabel. Do I add these functions into a UILabel subclass? How do I get the context of a UILabel? – Additament
UILabel inherits from UIView, so subclass UILabel then put this in your override func draw(_ rect: CGRect). – Copal
hey, thx for yr help. I have added this to drawRect... but how do I link to the label.text?? let context = UIGraphicsGetCurrentContext() let size = CGSize(width: self.frame.width, height: self.frame.height) CGContextTranslateCTM (context, size.width / 2, size.height / 2) CGContextScaleCTM (context, 1, -1) centreArcPerpendicularText("Hello round world", context: context!, radius: 100, angle: 0, colour: UIColor.redColor(), font: UIFont.systemFontOfSize(16), clockwise: true) – Additament
If you have subclassed UILabel then the text will be self.text. – Copal
ahhh! The text had been translated so out of view. Now sorted. Superb answers! Thx. – Additament
Why don't you add your implementation as a second answer? It's a non-trivial addition! – Copal
@Copal could you tell me how to put this in a view. I tried putting the code in the draw(_rect: CGRect) func but the text does not draw! The translation is context.translateBy (x: size.width / 2, y: size.height / 2) so that should draw it in the centre! Sorry I dont have much experience with coreanimation so it is not as trivial to me yet! – Hereditary
@RitvikUpadhyaya - the trick is not to call UIGraphicsBeginImageContextWithOptions but to use the UIView's own context which is current at that point - have updated the answer with example... – Copal
An epic answer - thank you so much for the work you put in to this! – Plashy
@MarkBrittingham - you're very welcome. I wrote it for an astrolabe app years ago, glad you liked it, and very happy to share! – Copal
@Grimxn, I accepted your challenge to add the UILabel implementation! πŸ˜‰ – Septuagesima
It there any xamarin implementation? – Adduction
@Adduction - I don't use Xamarin, but the logic of drawing each character shouldn't change, only the centre:... function which actually renders each glyph. – Copal
I have tried same code in my project usinf xcode 9.3. The text color not changing. The default text showing black. Any suggestion? – Salta
@Salta - I have updated it to Swift 4. The handling of Attributes (font colour, etc.) changed in Swift 4... – Copal
@Copal Thanks for your reply. I have written like same but label text color not changing here – Salta
How can I draw a new line character??? I need to draw text in multiline – Wildee
@RajAggrawal - the code already shows you how to get the size of text (it uses it for the width, but it also gets the height). From that it should be trivial to call with different radii for each line. – Copal
Outstanding bit of work! (voted). You forgot to update your UIView code for Swift 4 however. It still references M_PI constants. – Dory
I thought my trig was pretty good, but it took me 15 minutes to figure out what the chordToArc(_: radius:) function does, exactly. I forgot that the chord line for a character would be tangent to the circle, and therefore form a right triangle, so I couldn't make sense of the math. – Dory
Great for Apple Watch OS7 new Extra Large Circular Graphic Complication! Thanks! – Luannaluanne
@Adduction - did you see the additional answer by @wolfgang-schreurs? – Copal
@Copal I tried your example. but it is not centering the middle text. Here is video that i have tried. drive.google.com/file/d/1MjAaDDrnuXdE61J0cdSF5i4MuwWx61Ob/… and here is the video which I am trying to acheive drive.google.com/file/d/1Qzj1vInxDRz9uFI4mzNw5CR0h49rAC6w/… – Defoe
S
38

@IBDesignable For UILabel on Circular Path

First of all, I think we can all agree that @Grimxn is THE MAN! His solution kicks butt. I took his work and refactored it into a custom UILabel control that you can set and edit on the Storyboard. If you guys watch my videos you know how much I love to do this stuff! πŸ˜€

Swift 3 Code for Custom UILabel

import UIKit

@IBDesignable
class UILabelX: UILabel {
    // *******************************************************
    // DEFINITIONS (Because I'm not brilliant and I'll forget most this tomorrow.)
    // Radius: A straight line from the center to the circumference of a circle.
    // Circumference: The distance around the edge (outer line) the circle.
    // Arc: A part of the circumference of a circle. Like a length or section of the circumference.
    // Theta: A label or name that represents an angle.
    // Subtend: A letter has a width. If you put the letter on the circumference, the letter's width
    //          gives you an arc. So now that you have an arc (a length on the circumference) you can
    //          use that to get an angle. You get an angle when you draw a line from the center of the
    //          circle to each end point of your arc. So "subtend" means to get an angle from an arc.
    // Chord: A line segment connecting two points on a curve. If you have an arc then there is a
    //          start point and an end point. If you draw a straight line from start point to end point
    //          then you have a "chord".
    // sin: (Super simple/incomplete definition) Or "sine" takes an angle in degrees and gives you a number.
    // asin: Or "asine" takes a number and gives you an angle in degrees. Opposite of sine.
    //          More complete definition: http://www.mathsisfun.com/sine-cosine-tangent.html
    // cosine: Also takes an angle in degrees and gives you another number from using the two radiuses (radii).
    // *******************************************************
    
    @IBInspectable var angle: CGFloat = 1.6
    @IBInspectable var clockwise: Bool = true
    
    override func draw(_ rect: CGRect) {
        centreArcPerpendicular()
    }
    
    /**
     This draws the self.text around an arc of radius r,
     with the text centred at polar angle theta
     */
    func centreArcPerpendicular() {
        guard let context = UIGraphicsGetCurrentContext() else { return }
        let str = self.text ?? ""
        let size = self.bounds.size
        context.translateBy(x: size.width / 2, y: size.height / 2)
        
        let radius = getRadiusForLabel()
        let l = str.characters.count
        let attributes: [String : Any] = [NSFontAttributeName: self.font]
        
        let characters: [String] = str.characters.map { String($0) } // An array of single character strings, each character in str
        var arcs: [CGFloat] = [] // This will be the arcs subtended by each character
        var totalArc: CGFloat = 0 // ... and the total arc subtended by the string
        
        // Calculate the arc subtended by each letter and their total
        for i in 0 ..< l {
            arcs += [chordToArc(characters[i].size(attributes: attributes).width, radius: radius)]
            totalArc += arcs[i]
        }
        
        // Are we writing clockwise (right way up at 12 o'clock, upside down at 6 o'clock)
        // or anti-clockwise (right way up at 6 o'clock)?
        let direction: CGFloat = clockwise ? -1 : 1
        let slantCorrection = clockwise ? -CGFloat(M_PI_2) : CGFloat(M_PI_2)
        
        // The centre of the first character will then be at
        // thetaI = theta - totalArc / 2 + arcs[0] / 2
        // But we add the last term inside the loop
        var thetaI = angle - direction * totalArc / 2
        
        for i in 0 ..< l {
            thetaI += direction * arcs[i] / 2
            // Call centre with each character in turn.
            // Remember to add +/-90ΒΊ to the slantAngle otherwise
            // the characters will "stack" round the arc rather than "text flow"
            centre(text: characters[i], context: context, radius: radius, angle: thetaI, slantAngle: thetaI + slantCorrection)
            // The centre of the next character will then be at
            // thetaI = thetaI + arcs[i] / 2 + arcs[i + 1] / 2
            // but again we leave the last term to the start of the next loop...
            thetaI += direction * arcs[i] / 2
        }
    }
    
    func chordToArc(_ chord: CGFloat, radius: CGFloat) -> CGFloat {
        // *******************************************************
        // Simple geometry
        // *******************************************************
        return 2 * asin(chord / (2 * radius))
    }
    
    /**
     This draws the String str centred at the position
     specified by the polar coordinates (r, theta)
     i.e. the x= r * cos(theta) y= r * sin(theta)
     and rotated by the angle slantAngle
    */
    func centre(text str: String, context: CGContext, radius r:CGFloat, angle theta: CGFloat, slantAngle: CGFloat) {
        // Set the text attributes
        let attributes = [NSForegroundColorAttributeName: self.textColor,
                          NSFontAttributeName: self.font] as [String : Any]
        // Save the context
        context.saveGState()
        // Move the origin to the centre of the text (negating the y-axis manually)
        context.translateBy(x: r * cos(theta), y: -(r * sin(theta)))
        // Rotate the coordinate system
        context.rotate(by: -slantAngle)
        // Calculate the width of the text
        let offset = str.size(attributes: attributes)
        // Move the origin by half the size of the text
        context.translateBy(x: -offset.width / 2, y: -offset.height / 2) // Move the origin to the centre of the text (negating the y-axis manually)
        // Draw the text
        str.draw(at: CGPoint(x: 0, y: 0), withAttributes: attributes)
        // Restore the context
        context.restoreGState()
    }
    
    func getRadiusForLabel() -> CGFloat {
        // Imagine the bounds of this label will have a circle inside it.
        // The circle will be as big as the smallest width or height of this label.
        // But we need to fit the size of the font on the circle so make the circle a little
        // smaller so the text does not get drawn outside the bounds of the circle.
        let smallestWidthOrHeight = min(self.bounds.size.height, self.bounds.size.width)
        let heightOfFont = self.text?.size(attributes: [NSFontAttributeName: self.font]).height ?? 0
        
        // Dividing the smallestWidthOrHeight by 2 gives us the radius for the circle.
        return (smallestWidthOrHeight/2) - heightOfFont + 5
    }
}

Example of Usage on Storyboard

Text on Path Usage

Changes I made

  • I removed parameters that I could get straight from the label now.
  • I'm admittedly not the smartest in Trigonometry and have forgotten a lot at my age so I included all the relevant definitions so I could start to understand @Grimxn brilliancy.
  • The angle and clockwise settings are now properties you can adjust in Attributes Inspector.
  • I create the radius from the size of the label now.
  • Put some of the comments in standard format on functions, you know, so you get that popup that comes up with you OPTION + CLICK functions.

Help Text Example

Problems I have seen

I encourage you to edit the above to improve it.

  • I don't know why but sometimes the label kept rendering over other controls even though it was behind them in the document outline.
Septuagesima answered 12/3, 2017 at 8:57 Comment(5)
Well done! The warning is fixed by changing let attributes = [NSFontAttributeName: self.font] to let attributes: [String : Any] = [NSFontAttributeName: self.font]. The change in the type of attributes dictionaries came about in Swift 3, IIRC... – Copal
Updated. Thank you! – Septuagesima
Great but how can you set a radius that is not depend on the width/height? I want to just be able to set an arc, and change it dynamically, and even to bring it back to be flat ? #44967674 – Corporal
@MarkMoeykens It doesnt work at all, even I set angle to 1 or 2 it put the text in random place. Why? – Doran
@MarkMoeykens what should I do to rotate label -60 degrees left? Let the text starts at 10 o clock, direction clockwise. – Doran
L
14

Always the same implementation but adjusted for Swift 4

import UIKit

@IBDesignable
class CircularLabel: UILabel {
    // *******************************************************
    // DEFINITIONS (Because I'm not brilliant and I'll forget most this tomorrow.)
    // Radius: A straight line from the center to the circumference of a circle.
    // Circumference: The distance around the edge (outer line) the circle.
    // Arc: A part of the circumference of a circle. Like a length or section of the circumference.
    // Theta: A label or name that represents an angle.
    // Subtend: A letter has a width. If you put the letter on the circumference, the letter's width
    //          gives you an arc. So now that you have an arc (a length on the circumference) you can
    //          use that to get an angle. You get an angle when you draw a line from the center of the
    //          circle to each end point of your arc. So "subtend" means to get an angle from an arc.
    // Chord: A line segment connecting two points on a curve. If you have an arc then there is a
    //          start point and an end point. If you draw a straight line from start point to end point
    //          then you have a "chord".
    // sin: (Super simple/incomplete definition) Or "sine" takes an angle in degrees and gives you a number.
    // asin: Or "asine" takes a number and gives you an angle in degrees. Opposite of sine.
    //          More complete definition: http://www.mathsisfun.com/sine-cosine-tangent.html
    // cosine: Also takes an angle in degrees and gives you another number from using the two radiuses (radii).
    // *******************************************************

    @IBInspectable var angle: CGFloat = 1.6
    @IBInspectable var clockwise: Bool = true

    override func draw(_ rect: CGRect) {
        centreArcPerpendicular()
    }
    /**
    This draws the self.text around an arc of radius r,
    with the text centred at polar angle theta
    */
    func centreArcPerpendicular() {
        guard let context = UIGraphicsGetCurrentContext() else { return }
        let string = text ?? ""
        let size   = bounds.size
        context.translateBy(x: size.width / 2, y: size.height / 2)

        let radius = getRadiusForLabel()
        let l = string.count
        let attributes = [NSAttributedStringKey.font : self.font!]

        let characters: [String] = string.map { String($0) } // An array of single character strings, each character in str
        var arcs: [CGFloat] = [] // This will be the arcs subtended by each character
        var totalArc: CGFloat = 0 // ... and the total arc subtended by the string

        // Calculate the arc subtended by each letter and their total
        for i in 0 ..< l {
            arcs += [chordToArc(characters[i].size(withAttributes: attributes).width, radius: radius)]
            totalArc += arcs[i]
        }

        // Are we writing clockwise (right way up at 12 o'clock, upside down at 6 o'clock)
        // or anti-clockwise (right way up at 6 o'clock)?
        let direction: CGFloat = clockwise ? -1 : 1
        let slantCorrection = clockwise ? -CGFloat.pi/2 : CGFloat.pi/2

        // The centre of the first character will then be at
        // thetaI = theta - totalArc / 2 + arcs[0] / 2
        // But we add the last term inside the loop
        var thetaI = angle - direction * totalArc / 2

        for i in 0 ..< l {
            thetaI += direction * arcs[i] / 2
            // Call centre with each character in turn.
            // Remember to add +/-90ΒΊ to the slantAngle otherwise
            // the characters will "stack" round the arc rather than "text flow"
            centre(text: characters[i], context: context, radius: radius, angle: thetaI, slantAngle: thetaI + slantCorrection)
            // The centre of the next character will then be at
            // thetaI = thetaI + arcs[i] / 2 + arcs[i + 1] / 2
            // but again we leave the last term to the start of the next loop...
            thetaI += direction * arcs[i] / 2
        }
    }

    func chordToArc(_ chord: CGFloat, radius: CGFloat) -> CGFloat {
        // *******************************************************
        // Simple geometry
        // *******************************************************
        return 2 * asin(chord / (2 * radius))
    }

    /**
    This draws the String str centred at the position
    specified by the polar coordinates (r, theta)
    i.e. the x= r * cos(theta) y= r * sin(theta)
    and rotated by the angle slantAngle
    */
    func centre(text str: String, context: CGContext, radius r:CGFloat, angle theta: CGFloat, slantAngle: CGFloat) {
        // Set the text attributes
        let attributes : [NSAttributedStringKey : Any] = [
            NSAttributedStringKey.foregroundColor: textColor!,
            NSAttributedStringKey.font: font!
            ]
        // Save the context
        context.saveGState()
        // Move the origin to the centre of the text (negating the y-axis manually)
        context.translateBy(x: r * cos(theta), y: -(r * sin(theta)))
        // Rotate the coordinate system
        context.rotate(by: -slantAngle)
        // Calculate the width of the text
        let offset = str.size(withAttributes: attributes)
        // Move the origin by half the size of the text
        context.translateBy(x: -offset.width / 2, y: -offset.height / 2) // Move the origin to the centre of the text (negating the y-axis manually)
        // Draw the text
        str.draw(at: CGPoint(x: 0, y: 0), withAttributes: attributes)
        // Restore the context
        context.restoreGState()
    }

    func getRadiusForLabel() -> CGFloat {
        // Imagine the bounds of this label will have a circle inside it.
        // The circle will be as big as the smallest width or height of this label.
        // But we need to fit the size of the font on the circle so make the circle a little
        // smaller so the text does not get drawn outside the bounds of the circle.
        let smallestWidthOrHeight = min(bounds.size.height, bounds.size.width)
        let heightOfFont = text?.size(withAttributes: [NSAttributedStringKey.font: self.font]).height ?? 0

        // Dividing the smallestWidthOrHeight by 2 gives us the radius for the circle.
        return (smallestWidthOrHeight/2) - heightOfFont + 5
    }
}
Lightness answered 21/11, 2017 at 17:24 Comment(1)
One tiny addition I made was to add a "isRTL" boolean for right-to-left languages which just negates the direction but not the slantCorrection (so the text is not "upside down") – Myatt
H
2
  • Swift 5
  • Transparent background
  • Kern option for text
  • Correct ratio for view

Full playground code

import UIKit
import PlaygroundSupport

class MyViewController : UIViewController {

    override func loadView() {

        // *******************************************************
        // Playground code to test
        // *******************************************************
        let size = CGSize(width: 256, height: 256)

        UIGraphicsBeginImageContextWithOptions(size, false, 0.0)
        let context = UIGraphicsGetCurrentContext()!
        // *******************************************************************
        // Scale & translate the context to have 0,0
        // at the centre of the screen maths convention
        // Obviously change your origin to suit...
        // *******************************************************************
        context.translateBy (x: size.width / 2, y: size.height / 2)
        context.scaleBy(x: 1, y: -1)

        centreArcPerpendicular(text: "Hello round World",
                               context: context,
                               radius: size.height * 0.44,
                               angle: 0,
                               colour: UIColor.white,
                               font: UIFont.systemFont(ofSize: 16),
                               clockwise: true,
                               kern: 3)
        centreArcPerpendicular(text: "Anticlockwise",
                               context: context,
                               radius: size.height * 0.44,
                               angle: -.pi,
                               colour: UIColor.white,
                               font: UIFont.systemFont(ofSize: 16),
                               clockwise: false,
                               kern: 3)
        centre(text: "Hello center world", context: context, radius: 0, angle: 0 , colour: UIColor.yellow, font: UIFont.systemFont(ofSize: 16), slantAngle: .pi/4, kern: 0)

        let image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
    }

    func centreArcPerpendicular(text str: String, context: CGContext, radius r: CGFloat, angle theta: CGFloat, colour c: UIColor, font: UIFont, clockwise: Bool, kern: CGFloat = 0) {

        // *******************************************************
        // This draws the String str around an arc of radius r,
        // with the text centred at polar angle theta
        // *******************************************************

        func chordToArc(_ chord: CGFloat, radius: CGFloat) -> CGFloat {
            return 2 * asin(chord / (2 * radius))
        }

        let characters: [String] = str.map { String($0) } // An array of single character strings, each character in str
        let l = characters.count
        let attributes = [NSAttributedString.Key.font: font,
                          NSAttributedString.Key.kern: kern] as [NSAttributedString.Key : Any]

        var arcs: [CGFloat] = [] // This will be the arcs subtended by each character
        var totalArc: CGFloat = 0 // ... and the total arc subtended by the string

        // Calculate the arc subtended by each letter and their total
        for i in 0 ..< l {
            arcs += [chordToArc(characters[i].size(withAttributes: attributes).width, radius: r)]
            totalArc += arcs[i]
        }

        // Are we writing clockwise (right way up at 12 o'clock, upside down at 6 o'clock)
        // or anti-clockwise (right way up at 6 o'clock)?
        let direction: CGFloat = clockwise ? -1 : 1
        let slantCorrection: CGFloat = clockwise ? -.pi / 2 : .pi / 2

        // The centre of the first character will then be at
        // thetaI = theta - totalArc / 2 + arcs[0] / 2
        // But we add the last term inside the loop
        var thetaI = theta - direction * totalArc / 2

        for i in 0 ..< l {
            thetaI += direction * arcs[i] / 2
            // Call centerText with each character in turn.
            // Remember to add +/-90ΒΊ to the slantAngle otherwise
            // the characters will "stack" round the arc rather than "text flow"
            centre(text: characters[i], context: context, radius: r, angle: thetaI, colour: c, font: font, slantAngle: thetaI + slantCorrection, kern: kern)
            // The centre of the next character will then be at
            // thetaI = thetaI + arcs[i] / 2 + arcs[i + 1] / 2
            // but again we leave the last term to the start of the next loop...
            thetaI += direction * arcs[i] / 2
        }
    }

    func centre(text str: String, context: CGContext, radius r: CGFloat, angle theta: CGFloat, colour c: UIColor, font: UIFont, slantAngle: CGFloat, kern: CGFloat) {
        // *******************************************************
        // This draws the String str centred at the position
        // specified by the polar coordinates (r, theta)
        // i.e. the x= r * cos(theta) y= r * sin(theta)
        // and rotated by the angle slantAngle
        // *******************************************************

        // Set the text attributes
        let attributes = [NSAttributedString.Key.foregroundColor: c,
                          NSAttributedString.Key.font: font,
                          NSAttributedString.Key.kern: kern] as [NSAttributedString.Key : Any]

        //let attributes = [NSForegroundColorAttributeName: c, NSFontAttributeName: font]
        // Save the context
        context.saveGState()
        // Undo the inversion of the Y-axis (or the text goes backwards!)
        context.scaleBy(x: 1, y: -1)
        // Move the origin to the centre of the text (negating the y-axis manually)
        context.translateBy(x: r * cos(theta), y: -(r * sin(theta)))
        // Rotate the coordinate system
        context.rotate(by: -slantAngle)
        // Calculate the width of the text
        let offset = str.size(withAttributes: attributes)
        // Move the origin by half the size of the text
        context.translateBy (x: -offset.width / 2, y: -offset.height / 2) // Move the origin to the centre of the text (negating the y-axis manually)
        // Draw the text
        str.draw(at: CGPoint(x: 0, y: 0), withAttributes: attributes)
        // Restore the context
        context.restoreGState()
    }
}

// Present the view controller in the Live View window
PlaygroundPage.current.liveView = MyViewController()

enter image description here

Heterochromatic answered 21/1, 2022 at 21:1 Comment(0)
A
1

@IBDesignable For UILabel on Circular Path for Swift 2

Big thanks to both @Grimxn and @mark-moeykens for the absolutely killer work. I've done a small refactor on Mark's work so I could use it in a project that hasn't taken the time to update to Swift 3. Wanted to share, since the previous posts were so helpful.

Swift 2 Code for Custom UILabel

import UIKit

@IBDesignable
class ArcUILabel: UILabel
{
    // *******************************************************
    // DEFINITIONS (Because I'm not brilliant and I'll forget most this tomorrow.)
    // Radius: A straight line from the center to the circumference of a circle.
    // Circumference: The distance around the edge (outer line) the circle.
    // Arc: A part of the circumference of a circle. Like a length or section of the circumference.
    // Theta: A label or name that represents an angle.
    // Subtend: A letter has a width. If you put the letter on the circumference, the letter's width
    //          gives you an arc. So now that you have an arc (a length on the circumference) you can
    //          use that to get an angle. You get an angle when you draw a line from the center of the
    //          circle to each end point of your arc. So "subtend" means to get an angle from an arc.
    // Chord: A line segment connecting two points on a curve. If you have an arc then there is a
    //          start point and an end point. If you draw a straight line from start point to end point
    //          then you have a "chord".
    // sin: (Super simple/incomplete definition) Or "sine" takes an angle in degrees and gives you a number.
    // asin: Or "asine" takes a number and gives you an angle in degrees. Opposite of sine.
    //          More complete definition: http://www.mathsisfun.com/sine-cosine-tangent.html
    // cosine: Also takes an angle in degrees and gives you another number from using the two radiuses (radii).
    // *******************************************************

    @IBInspectable var angle: CGFloat = 1.6
    @IBInspectable var clockwise: Bool = true

    override func drawRect(rect: CGRect)
    {
        centreArcPerpendicular()
    }

    /**
     This draws the self.text around an arc of radius r,
     with the text centred at polar angle theta
     */
    func centreArcPerpendicular() {
        guard let context = UIGraphicsGetCurrentContext() else { return }
        let str = self.text ?? ""
        let size = self.bounds.size
        CGContextTranslateCTM(context, size.width / 2, size.height / 2)
    
        let radius = getRadiusForLabel()
        let l = str.characters.count
        let attributes: [String : AnyObject] = [NSFontAttributeName: self.font]
    
        let characters: [String] = str.characters.map { String($0) } // An array of single character strings, each character in str
        var arcs: [CGFloat] = [] // This will be the arcs subtended by each character
        var totalArc: CGFloat = 0 // ... and the total arc subtended by the string
    
        // Calculate the arc subtended by each letter and their total
        for i in 0 ..< l {
            arcs += [chordToArc(characters[i].sizeWithAttributes(attributes).width, radius: radius)]
            totalArc += arcs[i]
        }
    
        // Are we writing clockwise (right way up at 12 o'clock, upside down at 6 o'clock)
        // or anti-clockwise (right way up at 6 o'clock)?
        let direction: CGFloat = clockwise ? -1 : 1
        let slantCorrection = clockwise ? -CGFloat(M_PI_2) : CGFloat(M_PI_2)
    
        // The centre of the first character will then be at
        // thetaI = theta - totalArc / 2 + arcs[0] / 2
        // But we add the last term inside the loop
        var thetaI = angle - direction * totalArc / 2
    
        for i in 0 ..< l {
            thetaI += direction * arcs[i] / 2
            // Call centre with each character in turn.
            // Remember to add +/-90ΒΊ to the slantAngle otherwise
            // the characters will "stack" round the arc rather than "text flow"
            centre(text: characters[i], context: context, radius: radius, angle: thetaI, slantAngle: thetaI + slantCorrection)
            // The centre of the next character will then be at
            // thetaI = thetaI + arcs[i] / 2 + arcs[i + 1] / 2
            // but again we leave the last term to the start of the next loop...
            thetaI += direction * arcs[i] / 2
        }
    }

    func chordToArc(_ chord: CGFloat, radius: CGFloat) -> CGFloat {
        // *******************************************************
        // Simple geometry
        // *******************************************************
        return 2 * asin(chord / (2 * radius))
    }

    /**
     This draws the String str centred at the position
     specified by the polar coordinates (r, theta)
     i.e. the x= r * cos(theta) y= r * sin(theta)
     and rotated by the angle slantAngle
     */
    func centre(text str: String, context: CGContext, radius r:CGFloat, angle theta: CGFloat, slantAngle: CGFloat) {
        // Set the text attributes
        let attributes = [NSForegroundColorAttributeName: self.textColor,
                          NSFontAttributeName: self.font] as [String : AnyObject]
        // Save the context
        CGContextSaveGState(context)
        // Move the origin to the centre of the text (negating the y-axis manually)
        CGContextTranslateCTM(context, r * cos(theta), -(r * sin(theta)))
        // Rotate the coordinate system
        CGContextRotateCTM(context, -slantAngle)
    
        // Calculate the width of the text
        let offset: CGSize = str.sizeWithAttributes(attributes)
        // Move the origin by half the size of the text
        CGContextTranslateCTM(context, -offset.width / 2, -offset.height / 2)

        // Draw the text
        let txtStr = NSString(string: str)
        txtStr.drawAtPoint(CGPoint(x: 0, y: 0), withAttributes: attributes)
    
        // Restore the context
        CGContextRestoreGState(context)
    }

    func getRadiusForLabel() -> CGFloat {
        // Imagine the bounds of this label will have a circle inside it.
        // The circle will be as big as the smallest width or height of this label.
        // But we need to fit the size of the font on the circle so make the circle a little
        // smaller so the text does not get drawn outside the bounds of the circle.
        let smallestWidthOrHeight = min(self.bounds.size.height, self.bounds.size.width)
        let heightOfFont = self.text?.sizeWithAttributes([NSFontAttributeName: self.font]).height ?? 0

        // Dividing the smallestWidthOrHeight by 2 gives us the radius for the circle.
        return (smallestWidthOrHeight/2) - heightOfFont + 5
    }
}
Apartment answered 4/4, 2017 at 15:29 Comment(0)
C
1

A C#-version based on the code from Grimxm:

private void CenterArcPerpendicular(string text, CGContext context, float radius, double angle, UIColor textColor, UIFont font, bool isClockwise) {
    var characters = text.ToCharArray();

    var arcs = new List<float>() { };
    float totalArc = 0;

    for (var i = 0; i < characters.Length; i++)
    {
        var character = new NSString(new string(new char[] { characters[i] }));
        var charSize = character.StringSize(font);
        var arc = ChordToArc((float)charSize.Width, radius);
        arcs.Add(arc);

        totalArc += arc;
    }

    var direction = isClockwise ? -1 : 1;
    var slantCorrection = (float)(isClockwise ? -(Math.PI / 2) : (Math.PI / 2));
    var thetaI = angle - (direction * (totalArc / 2));

    for (var i = 0; i < characters.Length; i++)
    {
        var character = new NSString(new string(new char[] { characters[i] }));
        thetaI += direction * arcs[i] / 2;
        CenterText(character, context: context, radius: radius, angle: thetaI, textColor: textColor, font: font, slantAngle: thetaI + slantCorrection);
        thetaI += direction * arcs[i] / 2;
    }
}

private float ChordToArc(float chord, float radius) {
    return (float)(2 * Math.Asin(chord / (2 * radius)));
}

private void CenterText(NSString text, CGContext context, float radius, double angle, UIColor textColor, UIFont font, double slantAngle)
{
    var attributes = new UIStringAttributes { Font = font, ForegroundColor = textColor };

    context.SaveState();
    context.ScaleCTM(1, -1);
    var dX = radius * Math.Cos(angle);
    var dY = -(radius * Math.Sin(angle));

    context.TranslateCTM((nfloat)dX, (nfloat)dY);
    context.RotateCTM(-(nfloat)slantAngle);

    var offset = text.StringSize(font);
    context.TranslateCTM(-offset.Width / 2, -offset.Height / 2);
    
    text.DrawString(CGPoint.Empty, attributes);
    context.RestoreState();
}

For use in a Xamarin iOS app.

Con answered 6/7, 2020 at 8:45 Comment(0)
T
1

Update code to Swift 5

 import Foundation
import UIKit
 @IBDesignable       
class UILabelX: UILabel {

@IBInspectable var angle: CGFloat = 1.6
@IBInspectable var clockwise: Bool = true

override func draw(_ rect: CGRect) {
    centreArcPerpendicular()
}

/**
 This draws the self.text around an arc of radius r,
 with the text centred at polar angle theta
 */
func centreArcPerpendicular() {
    guard let context = UIGraphicsGetCurrentContext() else { return }
    let str = self.text ?? ""
    let size = self.bounds.size
    context.translateBy(x: size.width / 2, y: size.height / 2)

    let radius = getRadiusForLabel()
    let l = str.count
//        let attributes: [String : Any] = [NSAttributedString.Key: self.font]
    let attributes : [NSAttributedString.Key : Any] = [.font : self.font]

    let characters: [String] = str.map { String($0) } // An array of single character strings, each character in str
    var arcs: [CGFloat] = [] // This will be the arcs subtended by each character
    var totalArc: CGFloat = 0 // ... and the total arc subtended by the string

    // Calculate the arc subtended by each letter and their total
    for i in 0 ..< l {
 //            arcs = [chordToArc(characters[i].widthOfString(usingFont: self.font), radius: radius)]
        arcs += [chordToArc(characters[i].size(withAttributes: attributes).width, radius: radius)]
        totalArc += arcs[i]
    }

    // Are we writing clockwise (right way up at 12 o'clock, upside down at 6 o'clock)
    // or anti-clockwise (right way up at 6 o'clock)?
    let direction: CGFloat = clockwise ? -1 : 1
    let slantCorrection = clockwise ? -CGFloat(Double.pi/2) : CGFloat(Double.pi/2)

    // The centre of the first character will then be at
    // thetaI = theta - totalArc / 2 + arcs[0] / 2
    // But we add the last term inside the loop
    var thetaI = angle - direction * totalArc / 2

         for i in 0 ..< l {
        thetaI += direction * arcs[i] / 2
        // Call centre with each character in turn.
        // Remember to add +/-90ΒΊ to the slantAngle otherwise
        // the characters will "stack" round the arc rather than "text flow"
        centre(text: characters[i], context: context, radius: radius, angle: thetaI, slantAngle: thetaI + slantCorrection)
        // The centre of the next character will then be at
        // thetaI = thetaI + arcs[i] / 2 + arcs[i + 1] / 2
        // but again we leave the last term to the start of the next loop...
        thetaI += direction * arcs[i] / 2
    }
}

func chordToArc(_ chord: CGFloat, radius: CGFloat) -> CGFloat {
    // *******************************************************
    // Simple geometry
    // *******************************************************
    return 2 * asin(chord / (2 * radius))
}

/**
 This draws the String str centred at the position
 specified by the polar coordinates (r, theta)
 i.e. the x= r * cos(theta) y= r * sin(theta)
 and rotated by the angle slantAngle
*/
func centre(text str: String, context: CGContext, radius r:CGFloat, angle theta: CGFloat, slantAngle: CGFloat) {
    // Set the text attributes
    let attributes = [NSAttributedString.Key.font: self.font!] as [NSAttributedString.Key : Any]
    // Save the context
    context.saveGState()
    // Move the origin to the centre of the text (negating the y-axis manually)
    context.translateBy(x: r * cos(theta), y: -(r * sin(theta)))
    // Rotate the coordinate system
    context.rotate(by: -slantAngle)
    // Calculate the width of the text
    let offset = str.size(withAttributes: attributes)
    // Move the origin by half the size of the text
    context.translateBy(x: -offset.width / 2, y: -offset.height / 2) // Move the origin to the centre of the text (negating the y-axis manually)
    // Draw the text
    str.draw(at: CGPoint(x: 0, y: 0), withAttributes: attributes)
    // Restore the context
    context.restoreGState()
}

func getRadiusForLabel() -> CGFloat {
    // Imagine the bounds of this label will have a circle inside it.
    // The circle will be as big as the smallest width or height of this label.
    // But we need to fit the size of the font on the circle so make the circle a little
    // smaller so the text does not get drawn outside the bounds of the circle.
    let smallestWidthOrHeight = min(self.bounds.size.height, self.bounds.size.width)
    let heightOfFont = self.text?.size(withAttributes: [NSAttributedString.Key.font: self.font]).height ?? 0

    // Dividing the smallestWidthOrHeight by 2 gives us the radius for the circle.
    return (smallestWidthOrHeight/2) - heightOfFont + 5
}
}
Trainbearer answered 4/9, 2021 at 14:22 Comment(0)
T
0

SwiftUI implementation below :)

import Foundation
import SwiftUI
import UIKit


final class MyViewCircular: UIView {
    
    var customText: String = "" {
           didSet {
               setNeedsDisplay()
           }
       }
       
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        self.backgroundColor = .clear
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func draw(_ rect: CGRect) {
        let size = CGSize(width:UIScreen.main.bounds.width / 1.1, height: UIScreen.main.bounds.width / 1.1)
        UIGraphicsBeginImageContextWithOptions(size, false, 0.0)
        let context = UIGraphicsGetCurrentContext()!
        context.translateBy(x: size.width / 2, y: size.height / 2)
        context.scaleBy(x: 1, y: -1)
        
        /*centreArcPerpendicular(text: "Hello round World",
                               context: context,
                               radius: size.height * 0.44,
                               angle: 0,
                               colour: UIColor.white,
                               font: UIFont.systemFont(ofSize: 16),
                               clockwise: true,
                               kern: 3)
         */
        centreArcPerpendicular(text: customText,
                               context: context,
                               radius: size.height * 0.42,
                               angle: -.pi * 0.5,
                               colour: UIColor.white,
                               font: UIFont.systemFont(ofSize: 12),
                               clockwise: false,
                               kern: 3)
       /* centre(text: "Hello center world",
               context: context,
               radius: 0,
               angle: 0,
               colour: UIColor.yellow,
               font: UIFont.systemFont(ofSize: 16),
               slantAngle: .pi/4,
               kern: 0)
        */
        
        let image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        
        if let image = image {
            let imageView = UIImageView(image: image)
            imageView.frame = self.bounds
            imageView.contentMode = .scaleAspectFit
            self.addSubview(imageView)
        }
    }
    
    // The rest of your code remains the same
    func centreArcPerpendicular(text str: String, context: CGContext, radius r: CGFloat, angle theta: CGFloat, colour c: UIColor, font: UIFont, clockwise: Bool, kern: CGFloat = 0) {
        // Function implementation
        
        // *******************************************************
             // This draws the String str around an arc of radius r,
             // with the text centred at polar angle theta
             // *******************************************************

             func chordToArc(_ chord: CGFloat, radius: CGFloat) -> CGFloat {
                 return 2 * asin(chord / (2 * radius))
             }

             let characters: [String] = str.map { String($0) } // An array of single character strings, each character in str
             let l = characters.count
             let attributes = [NSAttributedString.Key.font: font,
                               NSAttributedString.Key.kern: kern] as [NSAttributedString.Key : Any]

             var arcs: [CGFloat] = [] // This will be the arcs subtended by each character
             var totalArc: CGFloat = 0 // ... and the total arc subtended by the string

             // Calculate the arc subtended by each letter and their total
             for i in 0 ..< l {
                 arcs += [chordToArc(characters[i].size(withAttributes: attributes).width, radius: r)]
                 totalArc += arcs[i]
             }

             // Are we writing clockwise (right way up at 12 o'clock, upside down at 6 o'clock)
             // or anti-clockwise (right way up at 6 o'clock)?
             let direction: CGFloat = clockwise ? -1 : 1
             let slantCorrection: CGFloat = clockwise ? -.pi / 2 : .pi / 2

             // The centre of the first character will then be at
             // thetaI = theta - totalArc / 2 + arcs[0] / 2
             // But we add the last term inside the loop
             var thetaI = theta - direction * totalArc / 2

             for i in 0 ..< l {
                 thetaI += direction * arcs[i] / 2
                 // Call centerText with each character in turn.
                 // Remember to add +/-90ΒΊ to the slantAngle otherwise
                 // the characters will "stack" round the arc rather than "text flow"
                 centre(text: characters[i], context: context, radius: r, angle: thetaI, colour: c, font: font, slantAngle: thetaI + slantCorrection, kern: kern)
                 // The centre of the next character will then be at
                 // thetaI = thetaI + arcs[i] / 2 + arcs[i + 1] / 2
                 // but again we leave the last term to the start of the next loop...
                 thetaI += direction * arcs[i] / 2
             }
    }

    func centre(text str: String, context: CGContext, radius r: CGFloat, angle theta: CGFloat, colour c: UIColor, font: UIFont, slantAngle: CGFloat, kern: CGFloat) {
        // Function implementation
        // *******************************************************
               // This draws the String str centred at the position
               // specified by the polar coordinates (r, theta)
               // i.e. the x= r * cos(theta) y= r * sin(theta)
               // and rotated by the angle slantAngle
               // *******************************************************

               // Set the text attributes
               let attributes = [NSAttributedString.Key.foregroundColor: c,
                                 NSAttributedString.Key.font: font,
                                 NSAttributedString.Key.kern: kern] as [NSAttributedString.Key : Any]

               //let attributes = [NSForegroundColorAttributeName: c, NSFontAttributeName: font]
               // Save the context
               context.saveGState()
               // Undo the inversion of the Y-axis (or the text goes backwards!)
               context.scaleBy(x: 1, y: -1)
               // Move the origin to the centre of the text (negating the y-axis manually)
               context.translateBy(x: r * cos(theta), y: -(r * sin(theta)))
               // Rotate the coordinate system
               context.rotate(by: -slantAngle)
               // Calculate the width of the text
               let offset = str.size(withAttributes: attributes)
               // Move the origin by half the size of the text
               context.translateBy (x: -offset.width / 2, y: -offset.height / 2) // Move the origin to the centre of the text (negating the y-axis manually)
               // Draw the text
               str.draw(at: CGPoint(x: 0, y: 0), withAttributes: attributes)
               // Restore the context
               context.restoreGState()
           }
    }


struct MyViewCircularRepresentable: UIViewRepresentable {
    
    var customText: String
      
      func makeUIView(context: Context) -> MyViewCircular {
          let view = MyViewCircular()
          view.customText = customText
          return view
      }

      func updateUIView(_ uiView: MyViewCircular, context: Context) {
          uiView.customText = customText
      }
}




#Preview {
    ZStack {
        
        AppBackgroundView().ignoresSafeArea(.all)
        
        MyViewCircularRepresentable(customText: "turn off to save battery ")
            .frame(width:200, height:200)
            .foregroundColor(.blue)
        
    }
    
}
Towhead answered 2/6, 2024 at 20:44 Comment(0)

© 2022 - 2025 β€” McMap. All rights reserved.