Get the NSRange of last line inside one line - swift

How can I get the NSRange of last line inside one line? (If cursor is at the end of it)
In the image example, I want to get the line range of the text in red box.
My code now get the range of whole line (green box):
var currentLineRange: NSRange {
let nsText = self.text as NSString
let currentLineRange = nsText.lineRange(for: self.selectedRange)
return currentLineRange
}

Notice that the range you want is a function of the width with which you have laid out the text, and the font of the text, among many other things. You cannot tell this from a String alone, as a String is just some Characters, and does not have those information. The lineRange method looks for new line characters in the string, which there evidently aren't, in your Lorem Ipsum paragraph, between "of" and "Lorem", hence that is not counted as a "line".
Assuming this is displayed in a UITextView (as that is what you have tagged the question with), you can use its layoutManager to get the range you want.
let selectedRange = textview.selectedRange
let glyphRange = textview.layoutManager.glyphRange(forCharacterRange: selectedRange, actualCharacterRange: nil)
// if the selection starts after the last glyph, glyphRange.lowerBound is not a valid glyph index
// so we need to use the last glyph in that case
let glyphIndex = glyphRange.lowerBound == textview.layoutManager.numberOfGlyphs ?
glyphRange.lowerBound - 1 : glyphRange.lowerBound
var effectiveGlyphRange = NSRange(location: 0, length: 0)
// this gets the *glyph* range of the line
textview.layoutManager.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: &effectiveGlyphRange)
let effectiveCharRange = textview.layoutManager.characterRange(forGlyphRange: effectiveGlyphRange, actualGlyphRange: nil)
This code will get the range of the line (aka line fragment) containing the start of the selected range.
If you want to get a range of multiple lines when multiple lines are selected (similar to how lineRange would have behaved), it should be trivial to modify the code to also get the range of the line containing the end of the selected range, then combine the two ranges.

Related

Get the range of a paragraph NSAttributedString

I would like to be able to get the range(s) of the paragraph(s) overlapping with the textView.selectedRange.
My goal is to change the alignment of the whole paragraph where the selection is.
I have tried the following but the ranges provided by enumerate attribute seem to all be of length 1, making my intersection check useless.
Is there a way to get a list of continuous ranges with the same paragraph style?
var alignment: NSTextAlignment
let contentRange = NSRange(location: 0, length: editor.contentLength)
editor.attributedText.enumerateAttribute(.paragraphStyle, in: contentRange, options: .longestEffectiveRangeNotRequired) { (paragraphStyle, range, _) in
//Check if attribute range contains cursor
if NSIntersectionRange(range, editor.selectedRange).length > 0 {
print(range)
if let paragraphStyle = paragraphStyle as? NSParagraphStyle {
let newStyle: NSMutableParagraphStyle = paragraphStyle.mutableParagraphStyle
newStyle.alignment = alignment
editor.addAttribute(.paragraphStyle, value: newStyle, at: range)
}
}
}
Many thanks
Edit
Thanks to #Larme, having no options does indeed get a continuous range:
editor.attributedText.enumerateAttribute(.paragraphStyle, in: contentRange, options: []) { (paragraphStyle, range, _) in
However, this will combine consecutive paragraphs with the same paragraph style in the same range.
i.e.
The user creates three paragraphs aligned to the left
The user wants to change the middle one to align right
They should be able to
a) select any portion of the paragraph
b) have the cursor at any position in the paragraph (selection length 0) to achieve the same result (affecting the whole paragraph)
The current check using enumarateAttribute will return a range grouping all three paragraphs together (as they all have the same paragraphStyle) and apply the new alignment to all of them.
The enumerateAttribute doesn't really get the range of a single paragraph it will return a consecutive range for all consecutive paragraphs with the same paragraphStyle
Is there another way to get the range of the paragraph corresponding to the selectedRange?
A bit late to the party, but hopefully this helps.
The way I approached this problem was by saving the selected range, pulling an NSString from my NSAttributedString, then calling NSString's paragraphRange. Once you have the paragraph range, then you can enumerate however you'd like!
//0: SAVE RANGE
let rangeOfNote = yourTextView.selectedRange
//1: CONVERT NSATTRIBUTEDSTRING TO NSSTRING
let composeText = yourTextView.attributedText.string as NSString
//2: CALL NSSTRING'S PARAGRAPH RANGE (for: NSRange)
let paragraphRange = composeText.paragraphRange(for: rangeOfNote)
//3: CHECK CORRECT
print(composeText.substring(with: paragraphRange), paragraphRange)
//4: Use range in enumerate attributes.
yourTextView.attributedText.enumerateAttributes(in: paragraphRange, ...)

Accessing NSTextView metrics

I have an NSTextVIew in which I am only showing mono-spaced characters from the standard alphabet. So no numbers, special characters, emojis, etc. Each character equals one glyph. On top of the text I need to draw some shapes, and I am looking for a way to access some metrics from the text system:
the distance from one character to the next one
the distance from one line to the next one
See the picture for what I mean.
There don't seem to be any properties that I can use directly, or at least I haven't found them, so I am now use the text view's layoutManager to obtain these values:
For the first one I obtain the enclosing rects for two adjacent characters via the boundingRect(forGlyphRange glyphRange: NSRange, in container: NSTextContainer) -> NSRect method of the layoutmanager, and subtract the origin.x for both rects.
For the second one, I could use the same function, but then I need to know the range for the first character on the second line. Or iterate over all the characters and once the origin.y of the enclosing rect changes, I have the first character on the second line and I can calculate the distance between two lines.
EDIT : here's possible code using the layoutManager:
typealias TextMetrics = (distanceBetweenCharacters: CGFloat, distanceBetweenLines: CGFloat)
var metrics: TextMetrics = self.textMetrics() // need to update when text changes
func textMetrics() -> TextMetrics {
guard let lm = self.layoutManager,
let tc = self.textContainer
else { return (0,0)
}
var distanceBetweenCharacters: CGFloat = 0.0
var distanceBetweenLines: CGFloat = 0.0
if string.count > 2 {
let firstRect = lm.boundingRect(forGlyphRange: NSRange(location: 0, length: 1), in: tc)
let secondRect = lm.boundingRect(forGlyphRange: NSRange(location: 1, length: 1), in: tc)
distanceBetweenCharacters = secondRect.maxX - firstRect.maxX
for (index, _) in string.enumerated() {
let rect = lm.boundingRect(forGlyphRange: NSRange(location: index, length: 1), in: tc)
if rect.maxY > firstRect.maxY { // reached next line
distanceBetweenLines = rect.maxY - firstRect.maxY
break
}
}
}
return (distanceBetweenCharacters, distanceBetweenLines)
}
I also looked at getting these from the defaultParagraphStyle, but if I access that, it is nil.
Is there maybe another, easier way to obtain these values?
After some more searching and trial and error, I found that distanceBetweenLines could be calculated from the font metrics and the lineHeightMultiple, which is a property from NSParagraphStyle, but it could also be defined outside the paragraph style, which is what I do.
So in the end this works:
let distanceBetweenLines = layoutManager.defaultLineHeight(for: myTextFont) * lineHeightMultiple
For distanceBetweenCharacters, I have not found another solution.
EDIT
Based on the suggestion from #Willeke in the comment below, I now calculate distanceBetweenCharacters as follows:
let distanceBetweenCharacters = myTextFont.advancement(forCGGlyph: layoutManager.cgGlyph(at: 0)).width + myKerning

Detecting Cursor position in UITextView that contains emojis returns the wrong position in swift 4

I'm using this code for detecting the cursor's position in a UITextView :
if let selectedRange = textView.selectedTextRange {
let cursorPosition = textView.offset(from: textView.beginningOfDocument, to: selectedRange.start)
print("\(cursorPosition)")
}
I put this under textViewDidChange func for detecting cursor position each time the text change.
It is working fine but when I putting emojis the textView.text.count is different with the cursor position. from swift 4 each emoji counted as one character but it seems that for cursor position it is different.
so How can I get the exact cursor position that matches the count of characters in a text ?
Long story short: When using Swift with String and NSRange use this extension for Range conversion
extension String {
/// Fixes the problem with `NSRange` to `Range` conversion
var range: NSRange {
let fromIndex = unicodeScalars.index(unicodeScalars.startIndex, offsetBy: 0)
let toIndex = unicodeScalars.index(fromIndex, offsetBy: count)
return NSRange(fromIndex..<toIndex, in: self)
}
}
Let's take a deeper look:
let myStr = "Wéll helló ⚙️"
myStr.count // 12
myStr.unicodeScalars.count // 13
myStr.utf8.count // 19
myStr.utf16.count // 13
In Swift 4 string is a collection of characters (composite character like ö and emoji will count as one character). UTF-8 and UTF-16 views are the collections of UTF-8 and UTF-16 code units respectively.
Your problem is, that textView.text.count counts collection elements (emoji as well as composite character will count as one element) and NSRange counts indexes of UTF-16 code units. The difference is illustrated in the snipped above.
More here:
Strings And Characters

Swift splitting UILabel between two lines

Here's a picture of a UILabel getting split between two lines:
I'm okay with it getting split, but it's getting split awkwardly. Is there any way to distribute the text more evenly between the two lines? I.e. to have three words (no pun intended) on each line in this case. The string is coming from user input, so I need a solution that works for any string (character limit is 40). Also, I'm doing this programatically. Thanks!
Add a linebreak \n to the text where you want the split to happen.
EDIT
Here is a solution that splits the string in roughly half, based on spaces:
var str = "Hello, label, here is some variable text"
let length = str.characters.count // 40
var splitRange = str.range(of: " ", options: String.CompareOptions.literal, range: str.index(str.startIndex, offsetBy: length / 2)..<str.endIndex, locale: nil) // finds first space after halfway mark
var firstLine = str.substring(to: splitRange!.lowerBound) // "Hello, label, here is"
var secondLine = str.substring(from: splitRange!.upperBound) // "some variable text"

NSString.rangeOfString returns unusual result with non-latin characters

I need to get the range of two words in a string, for example:
ยัฟิแก ไฟหก
(this is literally me typing PYABCD WASD) - it's a non-sensical test since I don't speak Thai.
//Find all the ranges of each word
var words: [String] = []
var ranges: [NSRange] = []
//Convert to nsstring first because otherwise you get stuck with Ranges and Strings.
let nstext = backgroundTextField.stringValue as NSString //contains "ยัฟิแก ไฟหก"
words = nstext.componentsSeparatedByString(" ")
var nstextLessWordsWeHaveRangesFor = nstext //if you have two identical words this prevents just getting the first word's range
for word in words
{
let range:NSRange = nstextLessWordsWeHaveRangesFor.rangeOfString(word)
Swift.print(range)
ranges.append(range)
//create a string the same length as word
var fillerString:String = ""
for i in 0..<word.characters.count{
//for var i=0;i<word.characters.count;i += 1{
Swift.print("i: \(i)")
fillerString = fillerString.stringByAppendingString(" ")
}
//remove duplicate words / letters so that we get correct range each time.
if range.length <= nstextLessWordsWeHaveRangesFor.length
{
nstextLessWordsWeHaveRangesFor = nstextLessWordsWeHaveRangesFor.stringByReplacingCharactersInRange(range, withString: fillerString)
}
}
outputs:
(0,6)
(5,4)
Those ranges are overlapping.
This causes problems down the road where I'm trying to use NSLayoutManager.enumerateEnclosingRectsForGlyphRange since the ranges are inconsistent.
How can I get the correct range (or in this specific case, non-overlapping ranges)?
Swift String characters describe "extended grapheme clusters", and NSString
uses UTF-16 code points, therefore the length of a string differs
depending on which representation you use.
For example, the first character "ยั" is actually the combination
of "ย" (U+0E22) with the diacritical mark " ั" (U+0E31).
That counts as one String character, but as two NSString characters.
As a consequence, indices change when you replace the word with
spaces.
The simplest solution is to stick to one, either String or NSString
(if possible). Since you are working with NSString, changing
for i in 0..<word.characters.count {
to
for i in 0..<range.length {
should solve the problem. The creation of the filler string
can be simplified to
//create a string the same length as word
let fillerString = String(count: range.length, repeatedValue: Character(" "))
Removing nstextLessWordsWeHaveRangesFor solves the issue (at the bottom starting with range.length <= nstextLessWordsWeHaveRangesFor.length). The modification of that variable is changing the range and giving unexpected output. Here is the result when the duplicate word removal is removed:
var words: [String] = []
let nstext = "ยัฟิแก ไฟหก" as NSString
words = nstext.componentsSeparatedByString(" ")
for word in words {
let range = nstext.rangeOfString(word)
print(range)
}
Output is: (0,6) and (7,4)