How I Improved CoreText Rendering to 60 FPS

Recently, I started building a video player that uses CoreText to render subtitles. However, when scrolling through the UI, the frame rate dropped to an unacceptable 15 FPS. To fix this, I created a demo that wraps a native text view with CoreText rendering, eventually optimizing the performance to a smooth 60 FPS.

Main Reasons for the FPS Drop

I found two main culprits for the performance drop:

  1. intrinsicContentSize was being called too many times.
  2. CTFrameDraw was being called too many times.

Optimizing intrinsicContentSize

intrinsicContentSize determines the natural size of a view. A ScrollView needs to fetch this size for all of its child views in order to calculate the layout. Under the hood, this property uses CTFramesetterCreateWithAttributedString and CTFramesetterSuggestFrameSizeWithConstraints to calculate the dimensions based on the text and font size.

A simple optimization here is to cache the calculated size, using the font size and text content as the cache key. This prevents redundant, expensive calculations.

Drawing in the Correct Order

In my original code, I was mistakenly calling CTFrameDraw twice—a classic AI-generated mistake! :D

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Code before the fix:
// ❌ Drawing in the wrong order
override func draw(_ dirtyRect: NSRect) {
    let context = NSGraphicsContext.current!.cgContext

    // Mistake 1: Drawing the text first
    CTFrameDraw(frame, context)

    if let selectionRange = self.selectionRange {
        let rects = getSelectionRects(from: frame, range: selectionRange)

        // Draw the background selection
        context.setFillColor(NSColor.selectedTextBackgroundColor.cgColor)
        context.fill(rects)

        // Mistake 2: The background just hid the text, so we have to draw it again!
	    CTFrameDraw(frame, context)

Another way to prevent views from being unnecessarily recreated is to assign unique identifiers to your text views using a combination of their text, font size, and color.

Why is Scrolling Up Slower Than Scrolling Down?

While investigating these performance issues, I noticed something interesting: scrolling downwards was smooth and stable, but scrolling upwards (from bottom to top) caused severe FPS drops.

Why does this happen? As mentioned earlier, a ScrollView needs to calculate the layout of its child views. When scrolling up, the system has to calculate the positions of the views above the current viewport. To do this, it needs to figure out the heights of all those preceding views, which repeatedly triggers CTFramesetterSuggestFrameSizeWithConstraints—a highly CPU-intensive operation. This is exactly why caching text sizes, as discussed earlier, is so crucial for performance.

Core Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85

override func draw(_ dirtyRect: NSRect) {
    NSColor.textBackgroundColor.setFill()
    bounds.fill()

    guard !text.isEmpty else { return }

    let insetRect = bounds.insetBy(dx: 12, dy: 12)
    guard insetRect.width > 1, insetRect.height > 1 else { return }

    let frame = frameForCurrentContent(in: insetRect)

    guard let context = NSGraphicsContext.current?.cgContext else { return }
    context.saveGState()
    context.textMatrix = .identity
    CTFrameDraw(frame, context)
    drawSelection(in: frame, context: context, pathRect: insetRect)
    drawComments(in: frame, context: context, pathRect: insetRect)
    context.restoreGState()
}

override var intrinsicContentSize: NSSize {
    let contentWidth = max(120, (bounds.width > 1 ? bounds.width : 760) - 24)
    let height = suggestedTextHeight(for: contentWidth)
    return NSSize(width: NSView.noIntrinsicMetric, height: max(84, height + 24))
}

private func suggestedTextHeight(for width: CGFloat) -> CGFloat {
    guard !text.isEmpty else { return 0 }

    let key = LayoutCacheKey(
        styleKey: styleKeyForCurrentContent(),
        contentWidth: width,
        markupVersion: markupVersion
    )
    if let cachedHeightKey,
       cachedHeightKey == key,
       let cachedSuggestedHeight {
        return cachedSuggestedHeight
    }

    let attributed = buildAttributedString()
    let framesetter = CTFramesetterCreateWithAttributedString(attributed)
    let target = CGSize(width: width, height: .greatestFiniteMagnitude)
    let size = CTFramesetterSuggestFrameSizeWithConstraints(
        framesetter,
        CFRange(location: 0, length: attributed.length),
        nil,
        target,
        nil
    )
    let measured = ceil(size.height)
    cachedHeightKey = key
    cachedSuggestedHeight = measured
    return measured
}

private func makeFrame(for string: NSAttributedString, in rect: CGRect) -> CTFrame {
    let framesetter = CTFramesetterCreateWithAttributedString(string)
    let path = CGPath(rect: rect, transform: nil)
    return CTFramesetterCreateFrame(framesetter, CFRange(location: 0, length: string.length), path, nil)
}

private func frameForCurrentContent(in rect: CGRect) -> CTFrame {
    let styleKey = styleKeyForCurrentContent()
    let key = LayoutCacheKey(
        styleKey: styleKey,
        contentWidth: rect.width,
        markupVersion: markupVersion
    )

    if let cachedFrameKey,
       cachedFrameKey == key,
       let cachedFrame {
        return cachedFrame
    }

    let attributed = buildAttributedString()
    let frame = makeFrame(for: attributed, in: rect)

    cachedFrameKey = key
    cachedFrame = frame

    return frame
}