Find Touched characters in UILabel with attributedString without using TextStorage - swift

I've been investigating ways to find hashtags/mentions/... in UILabel.
All Libs out there are just doing fine, but when it comes to RTL languages like Persian and texts getting too long in UITableView the scrolling becomes lagging and Consumes CPU up to 50% each time this row is visited. I figured out that all these libs are using textStorage somehow, and by removing it and using a cache method the lag is all gone and list scrolls smoothly, but the problem is that without textStorage its not possible (I can't find a way) to find which character is touched by the user. so if I solve this, my problem with RTL languages is solved, so here's the Question: we've got a UIlabel and a CGPoint how to find characters touched?
currently using this modified method by ContextLabel Library:
fileprivate func linkResult(at location: CGPoint) -> LinkResult? {
var fractionOfDistance: CGFloat = 0.0
let characterIndex = layoutManager.characterIndex(for: location, in: textContainer, fractionOfDistanceBetweenInsertionPoints: &fractionOfDistance)
if characterIndex <= textStorage?.length {
if let linkResults = contextLabelData?.linkResults {
for linkResult in linkResults {
let rangeLocation = linkResult.range.location
let rangeLength = linkResult.range.length
let rect = self.boundingRect(forCharacterRange: linkResult.range)
if(rect?.contains(location))!
{
return linkResult
}
}
}
}
return nil
}
which boundingRect is
func boundingRect(forCharacterRange range: NSRange) -> CGRect? {
guard let attributedText = attributedText else { return nil }
let textStorage = NSTextStorage(attributedString: attributedText)
let layoutManager = NSLayoutManager()
textStorage.addLayoutManager(layoutManager)
let textContainer = NSTextContainer(size:CGSize(width:self.bounds.size.width, height: CGFloat.greatestFiniteMagnitude))
textContainer.lineFragmentPadding = 0.0
layoutManager.addTextContainer(textContainer)
var glyphRange = NSRange()
// Convert the range for glyphs.
layoutManager.characterRange(forGlyphRange: range, actualGlyphRange: &glyphRange)
return layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
}
and linkResults are those hashtags, mentions, url... that are previously found and now its going to search through them , this always returns nil when textStorage is removed
UPDATE: its working now with current methods for English Text no matter how long but not for RTL yet

Related

Swift - Attributed Text features using Storyboard all reset when setting font size programmatically and run simulator

On the storyboard I created a text view. Inserted two paragraphs of text content inside textview. Selected custom attributes on the storyboard and made some words bold. When I run the simulator, everything is ok. But when I set the font size programmatically with respect to the "view.frame.height", the bold words which I set on the storyboard resets to regular words.
Code: "abcTextView.font = abcTextView.font?.withSize(self.view.frame.height * 0.021)"
I couldn't get past this issue. How can I solve this?
The problem is that you're working with an AttributedString. Take a look at Manmal's excellent answer here if you want more context, and an explanation of how the code works:
NSAttributedString, change the font overall BUT keep all other attributes?
Here's an easy application of the extension he provides, to put it in the context of your problem:
class ViewController: UIViewController {
#IBOutlet weak var myTextView: UITextView!
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
let newString = NSMutableAttributedString(attributedString: myTextView.attributedText)
newString.setFontFace(font: UIFont.systemFont(ofSize: self.view.frame.height * 0.033))
myTextView.attributedText = newString
}
}
extension NSMutableAttributedString {
func setFontFace(font: UIFont, color: UIColor? = nil) {
beginEditing()
self.enumerateAttribute(
.font,
in: NSRange(location: 0, length: self.length)
) { (value, range, stop) in
if let f = value as? UIFont,
let newFontDescriptor = f.fontDescriptor
.withFamily(font.familyName)
.withSymbolicTraits(f.fontDescriptor.symbolicTraits) {
let newFont = UIFont(
descriptor: newFontDescriptor,
size: font.pointSize
)
removeAttribute(.font, range: range)
addAttribute(.font, value: newFont, range: range)
if let color = color {
removeAttribute(
.foregroundColor,
range: range
)
addAttribute(
.foregroundColor,
value: color,
range: range
)
}
}
}
endEditing()
}
}

Fontsize of attributedString doesn't change

I have an attributedString and want to change only it's fontsize. To do that, I use another method that I found on StackOverflow. For most cases, this is working, but somehow it doesn't change the whole attributedString in one case.
Method to change the size:
/**
*A struct with static methods that can be useful for your GUI
*/
struct GuiUtils {
static func setAttributedStringToSize(attributedString: NSAttributedString, size: CGFloat) -> NSMutableAttributedString {
let mus = NSMutableAttributedString(attributedString: attributedString)
mus.enumerateAttribute(.font, in: NSRange(location: 0, length: mus.string.count)) { (value, range, stop) in
if let oldFont = value as? UIFont {
let newFont = oldFont.withSize(size)
mus.addAttribute(.font, value: newFont, range: range)
}
}
return mus
}
}
Working:
label.attributedText = GuiUtils.setAttributedStringToSize(attributedString: attributedString, size: fontSize)
Not working:
mutableAttributedString.replaceCharacters(in: gapRange, with: filledGap)
label.attributedText = GuiUtils.setAttributedStringToSize(attributedString: mutableAttributedString.replaceCharacters, size: fontSize)
Somehow, the replaced text does not change its size.
Excuse me, but do you sure that your filledGap attributed string has font attribute? Because if it doesn't – this part will not be handled by the enumerateAttribute block.
In this case your fix will be just to set any font to the whole filledGap string, to be sure that it's part will be handled by the enumerateAttribute block.

NSAttributedString, change the font overall BUT keep all other attributes?

Say I have an NSMutableAttributedString .
The string has a varied mix of formatting throughout:
Here is an example:
This string is hell to change in iOS, it really sucks.
However, the font per se is not the font you want.
I want to:
for each and every character, change that character to a specific font (say, Avenir)
BUT,
for each and every character, keep the mix of other attributions (bold, italic, colors, etc etc) which was previously in place on that character.
How the hell do you do this?
Note:
if you trivially add an attribute "Avenir" over the whole range: it simply deletes all the other attribute ranges, you lose all formatting. Unfortunately, attributes are not, in fact "additive".
Since rmaddy's answer did not work for me (f.fontDescriptor.withFace(font.fontName) does not keep traits like bold), here is an updated Swift 4 version that also includes color updating:
extension NSMutableAttributedString {
func setFontFace(font: UIFont, color: UIColor? = nil) {
beginEditing()
self.enumerateAttribute(
.font,
in: NSRange(location: 0, length: self.length)
) { (value, range, stop) in
if let f = value as? UIFont,
let newFontDescriptor = f.fontDescriptor
.withFamily(font.familyName)
.withSymbolicTraits(f.fontDescriptor.symbolicTraits) {
let newFont = UIFont(
descriptor: newFontDescriptor,
size: font.pointSize
)
removeAttribute(.font, range: range)
addAttribute(.font, value: newFont, range: range)
if let color = color {
removeAttribute(
.foregroundColor,
range: range
)
addAttribute(
.foregroundColor,
value: color,
range: range
)
}
}
}
endEditing()
}
}
Or, if your mix-of-attributes does not include font,
then you don't need to remove old font:
let myFont: UIFont = .systemFont(ofSize: UIFont.systemFontSize);
myAttributedText.addAttributes(
[NSAttributedString.Key.font: myFont],
range: NSRange(location: 0, length: myAttributedText.string.count));
Notes
The problem with f.fontDescriptor.withFace(font.fontName) is that it removes symbolic traits like italic, bold or compressed, since it will for some reason override those with default traits of that font face. Why this is so totally eludes me, it might even be an oversight on Apple's part; or it's "not a bug, but a feature", because we get the new font's traits for free.
So what we have to do is create a font descriptor that has the symbolic traits from the original font's font descriptor: .withSymbolicTraits(f.fontDescriptor.symbolicTraits). Props to rmaddy for the initial code on which I iterated.
I've already shipped this in a production app where we parse a HTML string via NSAttributedString.DocumentType.html and then change the font and color via the extension above. No problems so far.
Here is a much simpler implementation that keeps all attributes in place, including all font attributes except it allows you to change the font face.
Note that this only makes use of the font face (name) of the passed in font. The size is kept from the existing font. If you want to also change all of the existing font sizes to the new size, change f.pointSize to font.pointSize.
extension NSMutableAttributedString {
func replaceFont(with font: UIFont) {
beginEditing()
self.enumerateAttribute(.font, in: NSRange(location: 0, length: self.length)) { (value, range, stop) in
if let f = value as? UIFont {
let ufd = f.fontDescriptor.withFamily(font.familyName).withSymbolicTraits(f.fontDescriptor.symbolicTraits)!
let newFont = UIFont(descriptor: ufd, size: f.pointSize)
removeAttribute(.font, range: range)
addAttribute(.font, value: newFont, range: range)
}
}
endEditing()
}
}
And to use it:
let someMutableAttributedString = ... // some attributed string with some font face you want to change
someMutableAttributedString.replaceFont(with: UIFont.systemFont(ofSize: 12))
my two cents for OSX/AppKit>
extension NSAttributedString {
// replacing font to all:
func setFont(_ font: NSFont, range: NSRange? = nil)-> NSAttributedString {
let mas = NSMutableAttributedString(attributedString: self)
let range = range ?? NSMakeRange(0, self.length)
mas.addAttributes([.font: font], range: range)
return NSAttributedString(attributedString: mas)
}
// keeping font, but change size:
func setFont(size: CGFloat, range: NSRange? = nil)-> NSAttributedString {
let mas = NSMutableAttributedString(attributedString: self)
let range = range ?? NSMakeRange(0, self.length)
mas.enumerateAttribute(.font, in: range) { value, range, stop in
if let font = value as? NSFont {
let name = font.fontName
let newFont = NSFont(name: name, size: size)
mas.addAttributes([.font: newFont!], range: range)
}
}
return NSAttributedString(attributedString: mas)
}
Important -
rmaddy has invented an entirely new technique for this annoying problem in iOS.
The answer by manmal is the final perfected version.
Purely for the historical record here is roughly how you'd go about doing it the old days...
// carefully convert to "our" font - "re-doing" any other formatting.
// change each section BY HAND. total PITA.
func fixFontsInAttributedStringForUseInApp() {
cachedAttributedString?.beginEditing()
let rangeAll = NSRange(location: 0, length: cachedAttributedString!.length)
var boldRanges: [NSRange] = []
var italicRanges: [NSRange] = []
var boldANDItalicRanges: [NSRange] = [] // WTF right ?!
cachedAttributedString?.enumerateAttribute(
NSFontAttributeName,
in: rangeAll,
options: .longestEffectiveRangeNotRequired)
{ value, range, stop in
if let font = value as? UIFont {
let bb: Bool = font.fontDescriptor.symbolicTraits.contains(.traitBold)
let ii: Bool = font.fontDescriptor.symbolicTraits.contains(.traitItalic)
// you have to carefully handle the "both" case.........
if bb && ii {
boldANDItalicRanges.append(range)
}
if bb && !ii {
boldRanges.append(range)
}
if ii && !bb {
italicRanges.append(range)
}
}
}
cachedAttributedString!.setAttributes([NSFontAttributeName: font_f], range: rangeAll)
for r in boldANDItalicRanges {
cachedAttributedString!.addAttribute(NSFontAttributeName, value: font_fBOTH, range: r)
}
for r in boldRanges {
cachedAttributedString!.addAttribute(NSFontAttributeName, value: font_fb, range: r)
}
for r in italicRanges {
cachedAttributedString!.addAttribute(NSFontAttributeName, value: font_fi, range: r)
}
cachedAttributedString?.endEditing()
}
.
Footnote. Just for clarity on a related point. This sort of thing inevitably starts as a HTML string. Here's a note on how to convert a string that is html to an NSattributedString .... you will end up with nice attribute ranges (italic, bold etc) BUT the fonts will be fonts you don't want.
fileprivate extension String {
func htmlAttributedString() -> NSAttributedString? {
guard let data = self.data(using: String.Encoding.utf16, allowLossyConversion: false) else { return nil }
guard let html = try? NSMutableAttributedString(
data: data,
options: [NSDocumentTypeDocumentAttribute: NSHTMLTextDocumentType],
documentAttributes: nil) else { return nil }
return html
}
}
.
Even that part of the job is non-trivial, it takes some time to process. In practice you have to background it to avoid flicker.
Obj-C version of #manmal's answer
#implementation NSMutableAttributedString (Additions)
- (void)setFontFaceWithFont:(UIFont *)font color:(UIColor *)color {
[self beginEditing];
[self enumerateAttribute:NSFontAttributeName
inRange:NSMakeRange(0, self.length)
options:0
usingBlock:^(id _Nullable value, NSRange range, BOOL * _Nonnull stop) {
UIFont *oldFont = (UIFont *)value;
UIFontDescriptor *newFontDescriptor = [[oldFont.fontDescriptor fontDescriptorWithFamily:font.familyName] fontDescriptorWithSymbolicTraits:oldFont.fontDescriptor.symbolicTraits];
UIFont *newFont = [UIFont fontWithDescriptor:newFontDescriptor size:font.pointSize];
if (newFont) {
[self removeAttribute:NSFontAttributeName range:range];
[self addAttribute:NSFontAttributeName value:newFont range:range];
}
if (color) {
[self removeAttribute:NSForegroundColorAttributeName range:range];
[self addAttribute:NSForegroundColorAttributeName value:newFont range:range];
}
}];
[self endEditing];
}
#end
There is a tiny bug in the accepted answer causing the original font size to get lost.
To fix this simply replace
font.pointSize
with
f.pointSize
This ensures that e.g. H1 and H2 headings have the correct size.
Swift 5
This properly scales the font size, as other answers overwrite the font size, which may differ, like with sub, sup attribute
For iOS replace NSFont->UIFont and NSColor->UIColor
extension NSMutableAttributedString {
func setFont(_ font: NSFont, textColor: NSColor? = nil) {
guard let fontFamilyName = font.familyName else {
return
}
beginEditing()
enumerateAttribute(NSAttributedString.Key.font, in: NSMakeRange(0, length), options: []) { (value, range, stop) in
if let oldFont = value as? NSFont {
let descriptor = oldFont
.fontDescriptor
.withFamily(fontFamilyName)
.withSymbolicTraits(oldFont.fontDescriptor.symbolicTraits)
// Default font is always Helvetica 12
// See: https://developer.apple.com/documentation/foundation/nsattributedstring?language=objc
let size = font.pointSize * (oldFont.pointSize / 12)
let newFont = NSFont(descriptor: descriptor, size: size) ?? oldFont
addAttribute(NSAttributedString.Key.font, value: newFont, range: range)
}
}
if let textColor = textColor {
addAttributes([NSAttributedString.Key.foregroundColor:textColor], range: NSRange(location: 0, length: length))
}
endEditing()
}
}
extension NSAttributedString {
func settingFont(_ font: NSFont, textColor: NSColor? = nil) -> NSAttributedString {
let ms = NSMutableAttributedString(attributedString: self)
ms.setFont(font, textColor: textColor)
return ms
}
}
Would it be valid to let a UITextField do the work?
Like this, given attributedString and newfont:
let textField = UITextField()
textField.attributedText = attributedString
textField.font = newFont
let resultAttributedString = textField.attributedText
Sorry, I was wrong, it keeps the "Character Attributes" like NSForegroundColorAttributeName, e.g. the colour, but not the UIFontDescriptorSymbolicTraits, which describe bold, italic, condensed, etc.
Those belong to the font and not the "Character Attributes". So if you change the font, you are changing the traits as well. Sorry, but my proposed solution does not work. The target font needs to have all traits available as the original font for this to work.

How to get the selected string from a NSTextView in Swift?

How to do to get the selected string from a NSTextView in Swift?
// create a range of selected text
let range = mainTextField.selectedRange()
// this works but I need a plain string not an attributed string
let str = mainTextField.textStorage?.attributedSubstring(from: range)
Maybe I have to add an intermediate step where I get the full string and then apply the range on it?
What about
let str = mainTextField.text.substring(with: range)
Edit:
This should work now:
let range = mainTextField.selectedRange() // Returns NSRange :-/ (instead of Range)
let str = mainTextField.string as NSString? // So we cast String? to NSString?
let substr = str?.substring(with: range) // To be able to use the range in substring(with:)
The code snippet in the Swift 5. That's not very hard but repeated
let string = textView.attributedString().string
let selectedRange = textView.selectedRange()
let startIndex = string.index(string.startIndex, offsetBy: selectedRange.lowerBound)
let endIndex = string.index(string.startIndex, offsetBy: selectedRange.upperBound)
let substring = textView.attributedString().string[startIndex..<endIndex]
let selectedString = String(substring)
In case anyone is having issues with the selectedRanges() not returning the proper range for an NSTextView, make sure that you have made your NSTextView selectable. I don't know if it's not selectable by default but I had to enable it;
textView.isSelectable = true
I was trying this in Swift 5 using #user25917 code sample above and I couldn't get the ranges no matter what. I was getting a visual confirmation the text was selected through highlighting which threw me off. This was driving me mad for a few hours.
This may be helpfull for you :
let textView = NSTextView(frame: NSMakeRect(0, 0, 100, 100))
let attributes = [NSForegroundColorAttributeName: NSColor.redColor(),
NSBackgroundColorAttributeName: NSColor.blackColor()]
let attrStr = NSMutableAttributedString(string: "my string", attributes: attributes)
let area = NSMakeRange(0, attrStr.length)
if let font = NSFont(name: "Helvetica Neue Light", size: 16) {
attrStr.addAttribute(NSFontAttributeName, value: font, range: area)
textView.textStorage?.appendAttributedString(attrStr)
}

Find rect/position of words in NSTextField

I have a NSTextField with a sentence in it. I want to find a rect (ideally) or position for each word in the sentence to do things in those positions (outside of the NSTextField).
Doing this in a NSTextView/UITextView seems achievable with NSLayoutManager.boundingRectForGlyphRange, but without the NSLayoutManager that NSTextView (and UITextView) have it seems a bit more challenging.
What is the best way for me to find the position of a given word in a NSTextField?
It requires one barely-documented bit of magic and another completely undocumented bit. Here's Objective-C code. Don't have it handy in Swift, sorry.
NSRect textBounds = [textField.cell titleRectForBounds:textField.bounds];
NSTextContainer* textContainer = [[NSTextContainer alloc] init];
NSLayoutManager* layoutManager = [[NSLayoutManager alloc] init];
NSTextStorage* textStorage = [[NSTextStorage alloc] init];
[layoutManager addTextContainer:textContainer];
[textStorage addLayoutManager:layoutManager];
textContainer.lineFragmentPadding = 2;
layoutManager.typesetterBehavior = NSTypesetterBehavior_10_2_WithCompatibility;
textContainer.containerSize = textBounds.size;
[textStorage beginEditing];
textStorage.attributedString = textField.attributedStringValue;
[textStorage endEditing];
NSUInteger count;
NSRectArray rects = [layoutManager rectArrayForCharacterRange:matchRange
withinSelectedCharacterRange:matchRange
inTextContainer:textContainer
rectCount:&count];
for (NSUInteger i = 0; i < count; i++)
{
NSRect rect = NSOffsetRect(rects[i], textBounds.origin.x, textBounds.origin.y);
rect = [textField convertRect:rect toView:self];
// do something with rect
}
The typesetterBehavior is documented here. The lineFragmentPadding was determined empirically.
Depending on exactly what you're planning to do with the rectangles, you may wish to pass { NSNotFound, 0 } as the selected character range.
For efficiency, you generally want to keep the text objects around instead of instantiating them every time. You just set the text container's containerSize and the text storage's attributedString to the appropriate values each time.
According the Ken Thomases answer. I made a adaptation for Swift 4.2
guard let textFieldCell = textField.cell,
let textFieldCellBounds = textFieldCell.controlView?.bounds else{
return
}
let textBounds = textFieldCell.titleRect(forBounds: textFieldCellBounds)
let textContainer = NSTextContainer()
let layoutManager = NSLayoutManager()
let textStorage = NSTextStorage()
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
layoutManager.typesetterBehavior = NSLayoutManager.TypesetterBehavior.behavior_10_2_WithCompatibility
textContainer.containerSize = textBounds.size
textStorage.beginEditing()
textStorage.setAttributedString(textFieldCell.attributedStringValue)
textStorage.endEditing()
let rangeCharacters = (textFieldCell.stringValue as NSString).range(of: "string")
var count = 0
let rects: NSRectArray = layoutManager.rectArray(forCharacterRange: rangeCharacters,
withinSelectedCharacterRange: rangeCharacters,
in: textContainer,
rectCount: &count)!
for i in 0...count {
var rect = NSOffsetRect(rects[i], textBounds.origin.x, textBounds.origin.y)
rect = textField.convert(rect, to: self.view)
// do something with rect
}
All Credits to Ken Thomases