I am trying to develop my own UIView
that leverages TextKit2 to take control over the text rendering to apply custom drawing.
The problem I am facing so far is that the UIView is always bigger than the actual bounds that the text needs.
I know that you can use layoutFragmentFrame
or renderingSurfaceBounds
inside a subclass of NSTextLayoutFragment
to get the exact bounds for the text – but I was not able resize the view accordingly.
This screenshot illustrates the problem:
My UIView
implementation looks like this:
import UIKit
final class TextKit2UIView: UIView, NSTextLayoutManagerDelegate {
let contentStorage = NSTextContentStorage()
let layoutManager = NSTextLayoutManager()
let textContainer = NSTextContainer(size: .zero)
override init(frame: CGRect) {
super.init(frame: frame)
contentStorage.addTextLayoutManager(layoutManager)
layoutManager.textContainer = textContainer
layoutManager.delegate = self
backgroundColor = .brown.withAlphaComponent(0.2)
textContainer.lineFragmentPadding = 0
textContainer.lineBreakMode = .byTruncatingTail
contentStorage.attributedString = NSAttributedString(
string: "Integer bibendum luctus metus, vel mattis diam consequat non. Phasellus magna augue, faucibus et ullamcorper sit amet, vehicula quis nunc. Phasellus nisl arcu, tempus id metus ac, tempus mollis nisi. Duis in ultrices lectus.",
attributes: [.font: UIFont.systemFont(ofSize: 18)]
)
}
required init?(coder: NSCoder) { nil }
// using my own custom NSTextLayoutFragment
func textLayoutManager(_ textLayoutManager: NSTextLayoutManager,
textLayoutFragmentFor location: NSTextLocation,
in textElement: NSTextElement) -> NSTextLayoutFragment {
CustomLayoutFragment(textElement: textElement, range: textElement.elementRange)
}
override func layoutSubviews() {
super.layoutSubviews()
textContainer.size = bounds.size
layoutManager.ensureLayout(for: contentStorage.documentRange)
setNeedsDisplay()
}
override func draw(_ rect: CGRect) {
guard let ctx = UIGraphicsGetCurrentContext() else { return }
layoutManager.enumerateTextLayoutFragments(from: nil, options: []) { fragment in
fragment.draw(at: fragment.layoutFragmentFrame.origin, in: ctx)
return true
}
}
}
And my NSTextLayoutFragment
subclass looks like this (for now it just renders the green rectangle behind the text, but it will have much more custom drawing later on):
class CustomLayoutFragment: NSTextLayoutFragment {
override func draw(at renderingOrigin: CGPoint, in ctx: CGContext) {
let completeRect = self.layoutFragmentFrame
let shapeRect = Rectangle().path(in: completeRect).cgPath
ctx.saveGState()
ctx.addPath(shapeRect)
ctx.setFillColor(UIColor.green.withAlphaComponent(0.2).cgColor)
ctx.fillPath()
ctx.restoreGState()
super.draw(at: renderingOrigin, in: ctx)
}
}
Could someone please explain how to achieve the desired behavior? Specifically, how can I ensure that the uiView’s frame matches the text’s frame, eliminating the visible brown background that the screenshot shows?
Please note that I used SwiftUI to show the UIView on screen and that I explicitly set the width and height of that view to some specific values to showcase the problem. The uiView (and therefore the brown rectangle) has the width and height provided by the SwiftUI's frame modifier.
Turns out I needed to implement sizeThatFit() like this:
func sizeThatFits(_ proposal: ProposedViewSize, uiView: TextKit2UIView, context: Context) -> CGSize? {
let dimensions = proposal.replacingUnspecifiedDimensions(
by: .init(
width: 0,
height: CGFloat.greatestFiniteMagnitude
)
)
let calculatedSize = calculateTextViewHeight(
containerSize: dimensions,
attributedString: uiView.contentStorage.attributedString
)
return .init(
width: ceil(calculatedSize.width),
height: ceil(calculatedSize.height)
)
}
private func calculateTextViewHeight(containerSize: CGSize,
attributedString: NSAttributedString?) -> CGSize {
let boundingRect = attributedString?.boundingRect(
with: .init(width: containerSize.width, height: containerSize.height),
options: [.usesLineFragmentOrigin, .usesFontLeading],
context: nil
)
return CGSize(width: boundingRect?.width ?? .infinity, height: boundingRect?.height ?? .infinity)
}
I got this from this answer here: How to get dynamic height for UITextView in SwiftUI