Reputation: 20600
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