CoreText Swift Academy - part 3

In Part 2, I discussed selected font metrics. Now let's see how to layout text line or lines.

Text Layout

Text layout is non-trivial task. CoreText provides handful of useful functions to layout text in a rectangle frame, or along a custom path.

The most common usecase is to layout text inside a rectangle (frame). Like in a Label component. For that, first I need to create framesetter instance. Framesetter is reponsible for generating text frames. This is where the layout logic is implemented. Next, I get the frame (CTFrame) for my text.
I use CGPath(rect: bounds, transform: nil) as a frame path for a rectangle, however it can be any shape really:

let framesetter = CTFramesetterCreateWithAttributedString(attributedString)

let textFrame = CTFramesetterCreateFrame(framesetter, CFRange(), CGPath(rect: bounds, transform: nil), nil)

my drawing function, now looks like that:

override func draw(_ dirtyRect: NSRect) {
  super.draw(dirtyRect)

  // create Attributed String
  let myfont = CTFontCreateWithName("Papyrus" as CFString, 24, nil)
  let attributedString = NSAttributedString(string: text, attributes: [NSAttributedString.Key.font: myfont])

  // calculate text frame
  let framesetter = CTFramesetterCreateWithAttributedString(attributedString)
  let textFrame = CTFramesetterCreateFrame(framesetter, CFRange(), CGPath(rect: bounds, transform: nil), nil)

  // draw text frame
  if let context = NSGraphicsContext.current?.cgContext {
    CTFrameDraw(textFrame, context)
  }
}

as a result, I've got a text that is layout to fit in the view bounds.

Label(text: "Hello World lorem ipsum dolor sit")

Screenshot-2020-07-05-at-19.04.44

See how line breaks between "ipsim" and "dolor", and I did nothing to break it manually. This is the result of CTFramesetter and default CTTypesetter) combined to deliver the magic.

I can use any path to create a text frame. Here's an example with a frame (path) as a circle:

let path = CGPath(ellipseIn: bounds, transform: nil)

// calculate text frame
let framesetter = CTFramesetterCreateWithAttributedString(attributedString)
let textFrame = CTFramesetterCreateFrame(framesetter, CFRange(), path, nil)

// draw text frame
if let context = NSGraphicsContext.current?.cgContext {
  context.addPath(path)
  context.drawPath(using: .stroke)
  CTFrameDraw(textFrame, context)
}

Screenshot-2020-07-05-at-20.14.42

Typesetter

Typesetting is the composition of text by means of arranging types. A typesetter performs line layout. Line layout includes word wrapping, hyphenation, and line breaking in either vertical or horizontal rectangles.

CTTypesetter instance can be used with or without CTFramesetter.

When I have a typesetter instance, I can use it to do the math and create CTLine instances with a custom line breaking. For the purpose of this experience, let me use it to break the line right after "Hello World"

I want to use CTTypesetterSuggestLineBreak(typesetter, startIndex, width). width parameter is a suggested break position (not character index)

let breakIndex = CTTypesetterSuggestLineBreak(typesetter, 0, 140)

the value 140 I use here is a constant I pre-calculated, that is somewhere in the middle of the word "World", so the typesetter will say to break the line after this world. I should do the proper math here and calculate offset based on font metrics (check Part 1 for more details).

This is manual process of layout. After I calculate break index, I need to create lines (CTLine) for text. Finally I draw line by line:

let typesetter = CTTypesetterCreateWithAttributedString(attributedString)
// get line break index
let breakIndex = CTTypesetterSuggestLineBreak(typesetter, 0, 140)
// get lines using breakIndex
let line1 = CTTypesetterCreateLine(typesetter, CFRange(location: 0, length: breakIndex))
let line2 = CTTypesetterCreateLine(typesetter, CFRange(location: breakIndex, length: attributedString.length - breakIndex))

// draw lines
if let context = NSGraphicsContext.current?.cgContext {
  context.textPosition = .init(x: 0, y: 150)
  CTLineDraw(line1, context)
  context.textPosition = .init(x: 0, y: 100)
  CTLineDraw(line2, context)
}

Screenshot-2020-07-05-at-20.08.21

further recomended reading: https://robnapier.net/laying-out-text-with-coretext


Node: this is part of series (3 of many). check here tomorrow for next episode.