iosswiftcgaffinetransformcatextlayer

Position CATextLayers along Arc


I am trying to build a custom control which might look something like this Inkscape mockup:

enter image description here

I was doing things with drawRect() but I need to animate some of the elements, so I decided to switch to a composition of CALayers. I had been drawing the text using CGContextRef functions, but having switched to CATextLayer for each character, I cannot seem to get them transformed correctly.

My general approach was to create a CATextLayer for each character. I think I can use each layer's preferredFrameSize() to get the eventual size of the character. And then I thought I would adjust their position and rotation in my control's layoutSubviews() method.

"What have I tried?" I feel like I've just been sitting on a sit-n-spin, blindfolded, throwing darts. You name it, I've tried it.

I can determine the angle of rotation for each character with something like:

let baseline = (self.ringBox.width * 3 / 8) // radius out to the baseline arc
let circumference = baseline * Tau
var advance = wordWidth.half.negated // wordWidth was summed from the width of each character earlier
let angle = 0.75.rotations
for each in self.autoCharacters {
    let charSize = each.bounds.size
    advance += charSize.width.half
    let charRotation = (advance / circumference - 0.75).rotations + angle
    ...
}

I've noticed that layoutSubviews() seems to be called twice. Why? I've noticed that the second time through, the preferredFrameSize() seems to be thinner. Why? Was it because I was setting the affineTransform and it had cumulative effects. I was trying to initially set the position of the layer to the center of the parent box, as well as bounds to be preferredFrameSize, hoping that would center it in the center. And then apply transforms. Despite multiple attempts, I get weird placement, as well as weird clipping.

So I'm just looking for a simple/straightforward recipe that will position a CATextLayer at a given radius/angle from the center of a view.


Solution

  • In the end, I wrote a separate app with sliders for all of:

    As well as some debug info that showed the preferredFrameSize() of the layer. Armed with that, able to manipulate things and see the effects, I was able to actually get it down pretty simple.

    After creating a single CATextLayer for each character in a given word (typed as [CATextLayer]), I used the following method to position the characters of the word:

    // pass 1 to:
    // - baseline each characters rotation
    // - size it and center it
    // - accumulate the length of the whole word
    var wordWidth:CGFloat = 0.0
    for letter in word {
        // baseline the transform first, because preferredFrameSize() is affected by any latent rotation in the current transform
        letter.setAffineTransform(CGAffineTransformIdentity)
        // the preferredFrameSize will now be the un rotated size of the single character
        let size = letter.preferredFrameSize()
        // move the letter to the center of the view
        letter.frame = CGRect(origin: center - size.half, size: size)
        // accumulate the length of the word as the length of characters
        wordWidth += size.width
    }
    let Tau = CGFloat(M_PI * 2)
    // the length of the circle we want to be relative to
    let circumference = radius * Tau
    // back up the "advance" to half of the word width
    var advance = wordWidth.half.negated
    for letter in word {
        // retrieve the character dimensions
        let charSize = letter.bounds.size
        // advance half of the character, so we're at its center
        advance += charSize.width.half
        // determine the angle to rotate off of the nominal (which is 270 degrees)
        let charRotation = (advance / circumference * Tau - (Tau * 3 / 4)) + angle
        // start with an identity transform now
        var transform = CGAffineTransformIdentity
        // rotate it. this will rotate about the center of the character
        transform = transform.rotated(charRotation)
        // translate outwards to the ring
        transform = transform.translated(CGPoint(x: 0, y: radius.negated))
        // apply the transform
        letter.setAffineTransform(transform)
        // move the advance the other half of this character
        advance += charSize.width.half
    }