Oliver Pearmain
Oliver Pearmain

Reputation: 20600

TextKit / NSLayoutManager's `boundingRect(forGlyphRange:, in:)` does not match UITextView positioning for certain fonts

I have a requirement to animate/transform the glyph's of a UITextView (or subclass). In order to achieve this I am attempting to use TextKit (i.e. NSTextContainer / NSLayoutManager / NSTextStorage) to provide me with the frame of a given glyph and then manually position each glyph in the view as a CATextLayer (needs to be CoreAnimation for smooth/performant animations).

This loosely works however FOR CERTAIN FONTS the frame supplied by TextKit's boundingRect(forGlyphRange:, in:) function does not exactly match the frame of the glyph were it being rendered in a vanilla/default UITextView. The font "Courier" for example has a significant y offset.

I have produced a contrived and simplified playground to demonstrate the problem. Code below.

Can someone help me work out how I can position the red TextKit positioned CALayer glyph's so they sit perfectly on-top of their black UITextView glyph equivalents?

import UIKit
import PlaygroundSupport

fileprivate class CustomTextView: UITextView {

    private var glyphTextLayers: [CALayer] = []

    override func layoutSubviews() {
        super.layoutSubviews()
        calculateTextLayers()
    }

    private func removeGlyphTextLayers() {
        glyphTextLayers.forEach { $0.removeFromSuperlayer() }
        glyphTextLayers = []
    }

    private func calculateTextLayers() {
        removeGlyphTextLayers()

        var index = 0

        while index <= textStorage.string.count {
            let glyphRange = NSMakeRange(index, 1)
            let characterRange = layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil)

            guard characterRange.length > 0 else {
                break
            }

            let glyphRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)

            let attributedStringForGlyph = textStorage.attributedSubstring(from: characterRange)
                .replacingForegroundColor(with: .red) // For demo purposes only

            let textLayer = CATextLayer()
            textLayer.contentsScale = UIScreen.main.scale
            textLayer.alignmentMode = .center
            textLayer.frame = glyphRect
            textLayer.string = attributedStringForGlyph

            // TODO transform the glyphs
            // textLayer.transform = ...

            layer.addSublayer(textLayer)
            glyphTextLayers.append(textLayer)

            index += characterRange.length
        }
    }
}

extension NSAttributedString {
    func replacingForegroundColor(with: UIColor) -> NSAttributedString {
        let mutableAttributedString =  NSMutableAttributedString(attributedString: self)
        let range = NSMakeRange(0, mutableAttributedString.length)
        mutableAttributedString.removeAttribute(NSAttributedString.Key.foregroundColor, range: range)
        mutableAttributedString.addAttributes([
            NSAttributedString.Key.foregroundColor: UIColor.red as Any
        ], range: range)
        return mutableAttributedString
    }
}

class PlaygroundViewController : UIViewController {

    override func loadView() {
        let view = UIView()
        view.backgroundColor = .white
        self.view = view

        addCustomText(withFontNamed: "Helvetica", atY: 50)
        addCustomText(withFontNamed: "Courier", atY: 150)
        addCustomText(withFontNamed: "Futura", atY: 250)
        addCustomText(withFontNamed: "Optima", atY: 350)
    }

    private func addCustomText(withFontNamed fontName: String, atY y: CGFloat) {
        let fontSize = 48.0
        let font = UIFont(name: fontName, size: fontSize)!
        let text = "\(fontName) \(fontSize)"

        let paragraphStyle = NSMutableParagraphStyle()
        paragraphStyle.alignment = .center

        let attributedText = NSAttributedString(string: text, attributes: [
            NSAttributedString.Key.font: font as Any,
            NSAttributedString.Key.foregroundColor: UIColor.black as Any,
            NSAttributedString.Key.paragraphStyle: paragraphStyle as Any
        ])

        let customTextView = CustomTextView()
        customTextView.backgroundColor = .lightGray
        customTextView.attributedText = attributedText
        customTextView.textContainerInset = .zero
        customTextView.textContainer.lineFragmentPadding = 0

        customTextView.translatesAutoresizingMaskIntoConstraints = false
        self.view.addSubview(customTextView)
        NSLayoutConstraint.activate([
            customTextView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 6),
            customTextView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -6),
            customTextView.centerYAnchor.constraint(equalTo: view.topAnchor, constant: y),
            customTextView.heightAnchor.constraint(equalToConstant: font.lineHeight)
        ])
    }
}

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

Upvotes: 0

Views: 242

Answers (0)

Related Questions