My goal is to be able to tap on words accurately.... I'm having a small nightmare with TextKit not providing frames for fragments in the way I'd expect, it's clear I'm not setting it up correctly but I can't find the parameter that is causing it.
The NSTextLayoutFragment
it returns is just for the first single line in its entirety, I'd like the frames of each word / character / word fragment / whatever it can do to achieve the goal.
class Label: UILabel {
private var textStorage: NSTextContentStorage? {
guard let attributedText else { return nil }
let textStorage = NSTextContentStorage()
let textContainer = NSTextContainer()
let textLayoutManager = NSTextLayoutManager()
// textContainer.size = bounds.size // neither works, nor does 0.0
textContainer.size = CGSize(width: bounds.size.width,
height: .greatestFiniteMagnitude)
textContainer.lineFragmentPadding = 0.0
textContainer.lineBreakMode = lineBreakMode
// textContainer.widthTracksTextView = false // makes no difference
textLayoutManager.textContainer = textContainer
textStorage.addTextLayoutManager(textLayoutManager)
textStorage.attributedString = attributedText
// textStorage.textStorage?.append(attributedText)
return textStorage
}
public var characterMap: [CGRect] {
setNeedsLayout()
layoutIfNeeded()
guard let textStorage,
let layoutManager = textStorage.textLayoutManagers.first,
let textContainer = layoutManager.textContainer else { return [] }
layoutManager.enumerateSubstrings(from: textStorage.documentRange.location,
options: [.byWords]) { string, range, textRange, pointer in
print("String:", string, range, textRange)
}
var rects = [CGRect]()
layoutManager.enumerateTextLayoutFragments(from: layoutManager.documentRange.endLocation,
options: [.reverse, .ensuresLayout]) { layoutFragment in
rects.append(layoutFragment.layoutFragmentFrame)
return true
}
print("TextFrame:", textContainer.size)
print(rects)
return rects
}
Test string:
"Lorem ipsum dolor ⚡️ sit amet, www.google.com consectetur 🌧😭 adipiscing elit"
layoutManager.enumerateSubstrings
works correctly, I get a list of words like this...
String: Optional("Lorem") 0...5 Optional(NSCountableTextRange: {0, 6})
String: Optional("ipsum") 6...11 Optional(NSCountableTextRange: {6, 6})
String: Optional("dolor") 12...17 Optional(NSCountableTextRange: {12, 9})
String: Optional("sit") 21...24 Optional(NSCountableTextRange: {21, 4})
String: Optional("amet") 25...29 Optional(NSCountableTextRange: {25, 6})
String: Optional("www.google.com") 31...45 Optional(NSCountableTextRange: {31, 15})
String: Optional("consectetur") 46...57 Optional(NSCountableTextRange: {46, 17})
String: Optional("adipiscing") 63...73 Optional(NSCountableTextRange: {63, 11})
String: Optional("elit") 74...78 nil
textContainer.size
is also correct
TextFrame: (361.0, 133.66666666666666)
but layoutManager.enumerateTextLayoutFragments
returns only this
[(0.0, 0.0, 357.0078125, 33.4140625)]
What am I missing to get the layoutManager
to fragment the way I'd like it? I've tried TextKit 1 and I had the same problem, the scope is limited to the first line.
Ok I've found the reason for the single line... it was the attributedText
that overrode the lineBreakMode
with its paragraphStyle
private var textStorage: NSTextStorage? {
guard let attributedText,
let lineBreakMode = (attributes[.paragraphStyle] as? NSMutableParagraphStyle)?.lineBreakMode
else { return nil }
let layoutManager = NSLayoutManager()
let textStorage = NSTextStorage(attributedString: attributedText)
let textContainer = NSTextContainer(size: CGSize(width: bounds.size.width,
height: CGFloat.greatestFiniteMagnitude))
textContainer.lineFragmentPadding = 0
textContainer.maximumNumberOfLines = numberOfLines
textContainer.lineBreakMode = lineBreakMode
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
return textStorage
}
private var textContentStorage: NSTextContentStorage? {
guard let attributedText,
let lineBreakMode = (attributes[.paragraphStyle] as? NSMutableParagraphStyle)?.lineBreakMode
else { return nil }
let textStorage = NSTextContentStorage()
let textLayoutManager = NSTextLayoutManager()
let textContainer = NSTextContainer(size: CGSize(width: bounds.size.width,
height: CGFloat.greatestFiniteMagnitude))
textStorage.attributedString = attributedText
textContainer.lineFragmentPadding = 0.0
textContainer.lineBreakMode = lineBreakMode // numberOfLines == 1 ? .byTruncatingTail : .byWordWrapping
textContainer.maximumNumberOfLines = numberOfLines
textLayoutManager.textContainer = textContainer
textStorage.addTextLayoutManager(textLayoutManager)
textLayoutManager.ensureLayout(for: textStorage.documentRange)
textLayoutManager.textViewportLayoutController.layoutViewport()
return textStorage
}
I used a mixture of TextKit 1 and TextKit 2 to get the word rects... behold!
public var wordMap: [CGRect] {
// setNeedsLayout() // up to you if you want to assume layout or not
// layoutIfNeeded()
guard let textStorage,
let layoutManager = textStorage.layoutManagers.first,
let textContainer = layoutManager.textContainers.first,
let textContentStorage,
let textLayoutManager = textContentStorage.textLayoutManagers.first else { return [] }
var ranges = [NSRange]()
textLayoutManager.enumerateSubstrings(from: textContentStorage.documentRange.location,
options: [.byWords]) { string, range, textRange, pointer in
let start = textLayoutManager.offset(from: textContentStorage.documentRange.location,
to: range.location)
let length = textLayoutManager.offset(from: range.location,
to: range.endLocation)
ranges.append(NSRange(location: start, length: length))
}
return ranges.map { layoutManager.boundingRect(forGlyphRange: $0, in: textContainer) }
}