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")
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)
}
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)
}
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.