Scroll down to, **and a little bit past**, a desired spot in a UITextView - ios

What I have:
Working code that scrolls to and highlights the next instance of a search term in a UITextView that contains a moderately lengthy document.
What I want:
Instead of scrolling to the desired term so that it is at the very bottom of the TextView, I'd like to scroll slightly PAST the term so that it is better visible in the view.
Where I'm stuck:
I'm weak on how Swift generally & Swift 4 in particular work with Range, and NSRange, so while I suspect that the answer will include capturing some amount of the document beyond the search term itself (say, the lesser of the next 200 characters or the next 5 line breaks), I'm not clear how to accomplish that. Or if there is a better approach to scroll the view a bit more.
Code #1 -- does the highlighting and finds "this" instance of the search term:
func attributedTextHighlighting(instanceNbr: Int, searchTerm: String, inRawHaystackText: String) -> NSMutableAttributedString {
let desiredFont = UIFont(name: "Menlo-Regular", size: self.currentDisplayFontSize)
let fontAttribute : [NSAttributedStringKey : Any] = [NSAttributedStringKey.font: desiredFont as Any]
let attributed = NSMutableAttributedString(string: inRawHaystackText, attributes: fontAttribute)
if !searchTerm.isEmpty {
do {
let foo = NSRegularExpression.escapedPattern(for: searchTerm)
let regex = try NSRegularExpression(pattern: foo, options: .caseInsensitive)
var countingMatches = 0
for match in regex.matches(in: inRawHaystackText, range: NSRange(location: 0, length:
inRawHaystackText.utf16.count)) as [NSTextCheckingResult] {
if countingMatches == instanceNbr {
// highlight this term green
attributed.addAttribute(NSAttributedStringKey.backgroundColor, value: UIColor.green, range: match.range)
theGreenMatchingRange = match.range
} else {
// highlight this term yellow
attributed.addAttribute(NSAttributedStringKey.backgroundColor, value: UIColor.yellow, range: match.range)
}
// and either way, increment the countingMatches
countingMatches += 1
}
} catch {
fatalError("Bad RegEx! \(error)")
}
} else {
print("ofSearchTerm.isEmpty = true")
}
return attributed
}
This code finds the search term in the source text & generates attributed text that highlights the term green (for the current "find") or yellow (for all others). This code works OK.
Code #2 -- scrolls to the next instance of the search term
func scrollToNextMatchButtonTapped() {
print("scrollToNextMatchButtonTapped")
currentlyHighlightedInstance += 1
if currentlyHighlightedInstance == numberOfInstances {
currentlyHighlightedInstance = 0 // since numberOfInstances is 1-based but currentlyHighlightedInstance is 0-based
}
reloadAttribTextWithHighlighting() // calls the above code, so the green highlight moves to the next found instance
textDisplay.scrollRangeToVisible(theGreenMatchingRange) // would really like to scroll to here plus a few lines (not past the end of the document, obviously)
The last line there works (theGreenMatchingRange is the global variable that's set in Code #1 above to be the range of "this" instance of the search term), but it scrolls so that the green-highlighted term is at the very bottom of the window.
What do I need to add or do differently so that the green term isn't at the very bottom?

Related

How to set a style for a specific word inside UITextView?

I have a UITextView in which I am trying to style a particular word. The problem I am facing is that on setting a style for the word, it's also applying the style to all the other occurrences of the word. I just want one particular instance of the word say first or third to have the custom style.
Consider the text present inside UITextView.
Sunset is the time of day when our sky meets the outer space solar winds.
There are blue, pink, and purple swirls, spinning and twisting, like clouds of balloons caught in
a whirlwind. The sun moves slowly to hide behind the line of horizon, while the
moon races to take its place in prominence atop the night sky. People slow to a crawl,
entranced, fully forgetting the deeds that must still be done. There is a coolness, a
calmness, when the sun does set.
If I set the style to sun then both the occurrences of the word is getting the style applied.
Here is the code
let normalAttr = [NSAttributedString.Key.font: UIFont(name: "Oswald", size: 19.0), NSAttributedString.Key.paragraphStyle : style]
let customAttr = [NSAttributedString.Key.font: UIFont(name: "Oswald", size: 19.0), NSAttributedString.Key.foregroundColor: UIColor.red]
let words = textView.text.components(separatedBy: " ")
let newText = NSMutableAttributedString()
for word in words {
if (word == selectedWord) {
newText.append(NSMutableAttributedString(string: word + " " , attributes: selectedAttributes as [NSAttributedString.Key : Any]))
} else {
newText.append(NSMutableAttributedString(string:word + " ", attributes: normalAttributes as [NSAttributedString.Key : Any]))
}
}
textView.attributedText = newText
I just want to apply the style to one word any help on how could I do that?
How are you choosing which instance to replace?
The simplest way to do this would be to just maintain your own counter:
var counter = 0
for word in words {
if (word == selectedWord) {
counter += 1
// myTarget being the first or third or whatever
let attributesToUse = (counter == myTarget) ? selectedAttributes : normalAttributes
newText.append(NSMutableAttributedString(string: word + " " , attributes: attributesToUse as [NSAttributedString.Key : Any]))
} else {
newText.append(NSMutableAttributedString(string:word + " ", attributes: normalAttributes as [NSAttributedString.Key : Any]))
}
}
But you can certainly get cleaner by using NSAttributedStrings and looking for the range of your text..
let myText = NSMutableAttributedString(string: textView.text, attributes: normalAttributes)
// this will only turn up the FIRST occurrence
if let range = myText.range(of: selectedWord) {
let rangeOfSelected = NSRange(range, in: myText)
myText.setAttributes(selectedAttributes, range: rangeOfSelected)
}
If you want to use arbitrary occurrence you can prob write an extension that creates an array of all the ranges and then pick the one that matters, this is a good reference for that: https://medium.com/#weijentu/find-and-return-the-ranges-of-all-the-occurrences-of-a-given-string-in-swift-2a2015907a0e
Def could be overkill though, you can also modify the methods in those article to instead to take in an int (occuranceNumber) and use a counter like above to return only the range of the nth occurrence, and then do the same thing with attributed strings.

How to split NSAttributedString across equal bounds views with correct word wrap

I have been grappling with this since a while. There are APIs that give us bounds size for given attributes of NSAttributedString.
But there is no direct way to get string range that would fit within given bounds.
My requirement is to fit very long string across a few paged views (PDF is not an option, neither is scrolling). Hence I have to figure out string size for each view (same bounds).
Upon research I found that CTFramesetterSuggestFrameSizeWithConstraints and its friends in Core Text maybe of help. I tried the approach described here, but the resulting ranges have one ugly problem:
It ignores word breaks (a different problem unrelated to Core Text, but I would really like to see if there was some solution to that as well).
Basically I want paging of text across number of UITextView objects, but not getting the right attributed string splits.
NOTE:
My NSAttributedString attributes are as follows:
let attributes: [NSAttributedString.Key : Any] = [.foregroundColor : textColor, .font : font, .paragraphStyle : titleParagraphStyle]
(titleParagraphStyle has lineBreakMode set to byWordWrapping)
extension UITextView
{
func getStringSplits (fullString: String, attributes: [NSAttributedString.Key:Any]) -> [String]
{
let attributeString = NSAttributedString(string: fullString, attributes: attributes)
let frameSetterRef = CTFramesetterCreateWithAttributedString(attributeString as CFAttributedString)
var initFitRange:CFRange = CFRangeMake(0, 0)
var finalRange:CFRange = CFRangeMake(0, fullString.count)
var ranges: [Int] = []
repeat
{
CTFramesetterSuggestFrameSizeWithConstraints(frameSetterRef, initFitRange, attributes as CFDictionary, CGSize(width: bounds.size.width, height: bounds.size.height), &finalRange)
initFitRange.location += finalRange.length
ranges.append(finalRange.length)
}
while (finalRange.location < attributeString.string.count)
var stringSplits: [String] = []
var startIndex: String.Index = fullString.startIndex
for n in ranges
{
let endIndex = fullString.index(startIndex, offsetBy: n, limitedBy: fullString.endIndex) ?? fullString.endIndex
let theSubString = fullString[startIndex..<endIndex]
stringSplits.append(String(theSubString))
startIndex = endIndex
}
return stringSplits
}
}
My requirement is to fit very long string across a few paged views (PDF is not an option, neither is scrolling). Hence I have to figure out string size for each view (same bounds).
No, you don't have to figure that out. The text kit stack will do it for you.
In fact, UITextView will flow long text from one text view to another automatically. It's just a matter of configuring the text kit stack — one layout manager with multiple text containers.

Swift 3 : How do you wrap content in a tableviewcell with multiple labels?

I've been trying to figure this out for a while. I have set constraints for each label with background color set. I set each label's line break to word wrap, but that still doesn't work. What I'm looking for is a label wrap like word wrap whether or not that exists. Thanks.
Here is a slightly different approach. You can customize the appearance of each tagged word with an attributed string. This has some limitations but depending on your requirements it could be a good fit for you. The below code is an example pointing you in the correct direction, however you still might need to write additional code for correctly wrapping the spaces or recognizing touch events.
let tags = ["Outdoors", "Working", "Learning"].map { " \($0) " }
let text = tags.joined(separator: " ")
let ranges = tags.compactMap { text.range(of: $0) }
let attributedText = NSMutableAttributedString(string: text)
for range in ranges {
attributedText.addAttributes([.backgroundColor: UIColor.green], range: NSRange(range, in: text))
}
textView.attributedText = attributedText

Read/see More at the end of the label

I am trying to create a read more button at the end of my label. I want it to display 3 lines by default. I am coding in swift not objective c. Only when the user clicks the read more part of the label, should the label expand. It should look and work exactly like it does on instagram except on Instagram, it is in a tableview cell. My label and read more button will be in a scrollview. I have managed to get the expanding and contracting part working by adjusting the number of lines property of the label.
if descriptionLabel.numberOfLines == 0{
descriptionLabel.numberOfLines = 3
}else {
descriptionLabel.numberOfLines = 0
}
descriptionLabel.lineBreakMode = NSLineBreakMode.byWordWrapping
I am having problems with putting a "...more" at the end of the label and cutting the text off at the right place. I have looked at other people's responses to similar questions but nothing seems to work properly.
I can put a button over the last line of text so making the see more part of the label clickable also isn't the problem. The problem I am having is truncating the text at the right place and placing the see more text at the right place so that it displays.
I also want the read more button to only appear when it is necessary. I don't want to it appear when there are only 1-3 lines of text. This is also something I am having issues with.
I can't use this https://github.com/apploft/ExpandableLabel because it does not support scrollviews just tableviews.
the swift solution here didn't work: Add "...Read More" to the end of UILabel. It crashed the app.
Finally, the read more button should be in line with the last line of text and at the end of it. It would be an added benefit it this worked in a tableview cell as well!
I found ReadMoreTextView in Github, which is based on UITextView. The key method in this library is the following:
private func characterIndexBeforeTrim(range rangeThatFits: NSRange) -> Int {
if let text = attributedReadMoreText {
let readMoreBoundingRect = attributedReadMoreText(text: text, boundingRectThatFits: textContainer.size)
let lastCharacterRect = layoutManager.boundingRectForCharacterRange(range: NSMakeRange(NSMaxRange(rangeThatFits)-1, 1), inTextContainer: textContainer)
var point = lastCharacterRect.origin
point.x = textContainer.size.width - ceil(readMoreBoundingRect.size.width)
let glyphIndex = layoutManager.glyphIndex(for: point, in: textContainer, fractionOfDistanceThroughGlyph: nil)
let characterIndex = layoutManager.characterIndexForGlyph(at: glyphIndex)
return characterIndex - 1
} else {
return NSMaxRange(rangeThatFits) - readMoreText!.length
}
}
To display text like "xxxx...Read More", the library
Get how many characters could be display in the UITextView: Use NSLayoutManager.characterRange(forGlyphRange:, actualGlyphRange:)
Get the position of the last visible character and the width of "...Read More": Use NSLayoutManager.boundingRect(forGlyphRange glyphRange: NSRange, in container: NSTextContainer)
Get the character index before trimming: Use NSLayoutManager.characterIndexForGlyph(at glyphIndex: Int)
Replace text which should be trimmed with "...Read More": UITextStorage.replaceCharacters(in range: NSRange, with attrString: NSAttributedString)
Please check :
func addSeeMore(str: String, maxLength: Int) -> NSAttributedString {
var attributedString = NSAttributedString()
let index: String.Index = str.characters.index(str.startIndex, offsetBy: maxLength)
let editedText = String(str.prefix(upTo: index)) + "... See More"
attributedString = NSAttributedString(string: editedText)
return attributedString
}
You can use like :
let str = "Lorem Ipsum is simply dummy text of the printing and typesetting industry."
descriptionLabel.attributedText = addSeeMore(str: str, maxLength: 20)
// Output : Lorem Ipsum is simpl... See More

Get color changed words of attributed string

I have a UITextView that allows to select words of the text by tapping it. If it is tapped the word is highlighted in color by changing the NSForegroundColor attribute.
Tapping it again deselects it by changing the color back to the textcolor.
Now I would need to know all selected words in the UITextView.
The first idea was to remove everything that is a special character and split the text at space. Then check if the color attribute is equals to the selected/highlighted color of each seperate word.
But attributed string doesn't allow to split at a character or remove components. Neither does NSAttributedString.
Second idea was to save the ranges of the highlighted parts in an array and iterate over it to get the highlighted parts. But this seems a bit too complicated for me, especially as I need the correct order of the words as they appear which is not guaranteed with an array, add/remove on each tap
(For example let's say the text is: "This is a test"
Tap this -> index 0
Tap test -> index 1
Tap this -> test becomes index 0
Tap this -> this becomes index 1
then the order is not good anymore.
I already figured out how to get the color of an attributed string. That's not the problem.
How can I iterate over the attributed string and figure out the words that have changed color or what is the best way to solve this problem?
Thank you!
Greetings
You can iterate over the attributed string looking for the color attribute.
The follow code demonstrates how:
// This generates a test attributed string.
// You actually want the attributedText property of your text view
let str = NSMutableAttributedString(string: "This is a test of the following code")
str.addAttributes([NSForegroundColorAttributeName:UIColor.red], range: NSMakeRange(0, 4))
str.addAttributes([NSForegroundColorAttributeName:UIColor.red], range: NSMakeRange(8, 1))
str.addAttributes([NSForegroundColorAttributeName:UIColor.red], range: NSMakeRange(15, 2))
print(str)
The above prints:
This{
NSColor = "UIExtendedSRGBColorSpace 1 0 0 1";
} is {
}a{
NSColor = "UIExtendedSRGBColorSpace 1 0 0 1";
} test {
}of{
NSColor = "UIExtendedSRGBColorSpace 1 0 0 1";
} the following code{
}
This code processes the attributed string. Any range of text formatted with a foreground color will be put into the words array.
var words = [String]()
str.enumerateAttribute(NSForegroundColorAttributeName, in: NSMakeRange(0, str.length), options: []) { (value, range, stop) in
if value != nil {
let word = str.attributedSubstring(from: range).string
words.append(word)
}
}
print(words)
This prints:
["This", "a", "of"]
I can suggest you to create some kind of storage for selected ranges, then based on this range you can customize look of this words, not the other way. It will allow you to access selected words every time without checking attributes of whole text.
While I agree with Piotr that you should store the Ranges, to answer your question:
attributedString.enumerateAttributes(in: NSMakeRange(0, attributedString.length), options: []) { attributes, range, _ in
if let color = attributes[NSForegroundColorAttributeName] as? UIColor,
color == YOUR_HIGHLIGHT_COLOR {
let nString = attributedString.string as NSString
let word = nString.substring(with: range)
// Do what you want with the word
}
}

Resources