I have a table of items and each item has a label. I also have a search bar that is used to filter the items in the table based on whether mySearchBar.text is a substring of myLabel.text.
That is all working fine, but I'd like to bold the portions of label text that match the search string.
The end product would be something similar to Google Maps search.
Swift 4 : XCode 9.x
private func filterAndModifyTextAttributes(searchStringCharacters: String, completeStringWithAttributedText: String) -> NSMutableAttributedString {
let attributedString: NSMutableAttributedString = NSMutableAttributedString(string: completeStringWithAttributedText)
let pattern = searchStringCharacters.lowercased()
let range: NSRange = NSMakeRange(0, completeStringWithAttributedText.characters.count)
var regex = NSRegularExpression()
do {
regex = try NSRegularExpression(pattern: pattern, options: NSRegularExpression.Options())
regex.enumerateMatches(in: completeStringWithAttributedText.lowercased(), options: NSRegularExpression.MatchingOptions(), range: range) {
(textCheckingResult, matchingFlags, stop) in
let subRange = textCheckingResult?.range
let attributes : [NSAttributedStringKey : Any] = [.font : UIFont.boldSystemFont(ofSize: 17),.foregroundColor: UIColor.red ]
attributedString.addAttributes(attributes, range: subRange!)
}
}catch{
print(error.localizedDescription)
}
return attributedString
}
How to use :
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell(style: .subtitle , reuseIdentifier: "Cell")
cell.textLabel?.attributedText = self.filterAndModifyTextAttributes(searchStringCharacters: self.textFromSearchBar, completeStringWithAttributedText: searchResultString)
return cell
}
Here's a sample of what I ended up implementing:
#IBOutlet weak var mySearchBar: UISearchBar!
#IBOutlet weak var myLabel: UILabel!
...
func makeMatchingPartBold(searchText: String) {
// check label text & search text
guard
let labelText = myLabel.text,
let searchText = mySearchBar.text
else {
return
}
// bold attribute
let boldAttr = [NSFontAttributeName: UIFont.boldSystemFont(ofSize: myLabel.font.pointSize)]
// check if label text contains search text
if let matchRange: Range = labelText.lowercased().range(of: searchText.lowercased()) {
// get range start/length because NSMutableAttributedString.setAttributes() needs NSRange not Range<String.Index>
let matchRangeStart: Int = labelText.distance(from: labelText.startIndex, to: matchRange.lowerBound)
let matchRangeEnd: Int = labelText.distance(from: labelText.startIndex, to: matchRange.upperBound)
let matchRangeLength: Int = matchRangeEnd - matchRangeStart
// create mutable attributed string & bold matching part
let newLabelText = NSMutableAttributedString(string: labelText)
newLabelText.setAttributes(boldAttr, range: NSMakeRange(matchRangeStart, matchRangeLength))
// set label attributed text
myLabel.attributedText = newNameText
}
}
Related
I looked around SO and couldn't find this exact problem, despite there being a few questions with similar titles.
All I want to do is have some matching text on UILabel be drawn in BOLD. I'm using it when I'm searching for objects, it should 'bolden' the search term. To this aim, I wrote the following code:
extension String {
func boldenOccurrences(of searchTerm: String?, baseFont: UIFont, textColor: UIColor) -> NSAttributedString {
let defaultAttributes: [String : Any] = [NSForegroundColorAttributeName : textColor,
NSFontAttributeName: baseFont]
let result = NSMutableAttributedString(string: self, attributes: defaultAttributes)
guard let searchTerm = searchTerm else {
return result
}
guard searchTerm.characters.count > 0 else {
return result
}
// Ranges. Crash course:
//let testString = "Holy Smokes!"
//let range = testString.startIndex ..< testString.endIndex
//let substring = testString.substring(with: range) // is the same as testString
var searchRange = self.startIndex ..< self.endIndex //whole string
var foundRange: Range<String.Index>!
let boldFont = UIFont(descriptor: baseFont.fontDescriptor.withSymbolicTraits(.traitBold)!, size: baseFont.pointSize)
repeat {
foundRange = self.range(of: searchTerm, options: .caseInsensitive , range: searchRange)
if let found = foundRange {
// now we have to do some stupid stuff to make Range compatible with NSRange
let rangeStartIndex = found.lowerBound
let rangeEndIndex = found.upperBound
let start = self.distance(from: self.startIndex, to: rangeStartIndex)
let length = self.distance(from: rangeStartIndex, to: rangeEndIndex)
log.info("Bolden Text: \(searchTerm) in \(self), range: \(start), \(length)")
let nsRange = NSMakeRange(start, length)
result.setAttributes([NSForegroundColorAttributeName : textColor,
NSFontAttributeName: boldFont], range: nsRange)
searchRange = found.upperBound ..< self.endIndex
}
} while foundRange != nil
return result
}
}
Everything "looks" fine. The log statement spits out what I expect and it's all good. However, when drawn on the UILabel, sometimes an entire string is set to bold, and I don't understand how that could be happening. Nothing in the code suggests this should be happening.
I set the result of this above method in a typical UITableCell configuration method (i.e. tableView(cellForRowAt indexPath:.... ) )
cell.titleLabel.attributedText = artist.displayName.emptyIfNil.boldenOccurrences(of: source.currentSearchTerm, baseFont: cell.titleLabel.font, textColor: cell.titleLabel.textColor)
Your primary issue is the cell reuse, maybe when a cell is reused keep your font bold as font, and that is why you have this issue, you can solve this in your cell prepareForReuse() method you can add
override func prepareForReuse() {
super.prepareForReuse()
//to fix ipad Error
self.titleLabel.font = UIFont(name: "YourBaseFont", size: yourFontSize)
}
This is a follow up to this question How can I change style of some words in my UITextView one by one in Swift?
Thanks to #Josh's help I was able to write a piece of code that highlights each word that begins with # - and do it one by one. My final code for that was:
func highlight (to index: Int) {
let regex = try? NSRegularExpression(pattern: "#(\\w+)", options: [])
let matches = regex!.matches(in: hashtagExplanationTextView.text, options: [], range: NSMakeRange(0, (hashtagExplanationTextView.text.characters.count)))
let titleDict: NSDictionary = [NSForegroundColorAttributeName: orangeColor]
let titleDict2: NSDictionary = [NSForegroundColorAttributeName: UIColor.red]
let storedAttributedString = NSMutableAttributedString(string: hashtagExplanationTextView.text!, attributes: titleDict as! [String : AnyObject])
let attributedString = NSMutableAttributedString(attributedString: storedAttributedString)
guard index < matches.count else {
return
}
for i in 0..<index{
let matchRange = matches[i].rangeAt(0)
attributedString.addAttributes(titleDict2 as! [String : AnyObject], range: matchRange)
}
hashtagExplanationTextView.attributedText = attributedString
if #available(iOS 10.0, *) {
let _ = Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { _ in
self.highlight(to: index + 1)
}
} else {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.highlight(to: index + 1)
}
}
}
This works fine, but I would like to change the logic so that it does not highlight the # words, but highlights (one by one) words from preselected array of those words.
So I have this array var myArray:[String] = ["those","words","are","highlighted"] and how can I put it instead of regex match in my code?
I believe you are using regex to get an array of NSRange. Here, you need a slightly different datastructure like [String : [NSRange]]. Then you can use rangeOfString function to detect the NSRange where the word is located. You can follow the example given below for that:
let wordMatchArray:[String] = ["those", "words", "are", "highlighted"]
let labelText:NSString = NSString(string: "those words, those ldsnvldnvsdnds, are, highlighted,words are highlighted")
let textLength:Int = labelText.length
var dictionaryForEachWord:[String : [NSRange]] = [:]
for eachWord:String in wordMatchArray {
var prevRange:NSRange = NSMakeRange(0, 0)
var rangeArray:[NSRange] = []
while ((prevRange.location + prevRange.length) < textLength) {
let start:Int = (prevRange.location + prevRange.length)
let rangeEach:NSRange = labelText.range(of: eachWord, options: NSString.CompareOptions.literal, range: NSMakeRange(start, textLength-start))
if rangeEach.length == 0 {
break
}
rangeArray.append(rangeEach)
prevRange = rangeEach
}
dictionaryForEachWord[eachWord] = rangeArray
}
Now that you have an array of NSRange i.e, [NSRange] for each word stored in a dictionary, you can highlight each word accordingly in your UITextView.
Feel free to comment if you have any doubts regarding the implementation :)
For this new requirement you don't need a regex, you can just iterate over your array of words and use rangeOfString to find out if that string exists and set the attributes for the located range.
To match the original functionality, after you find a matching range you need to search again, starting from the end of that range, to see if there is another match later in your source text.
The proposed solutions so far suggest that you go through each word and then search them in the text view. This works, but you are traversing the text way too many times.
What I would suggest is to enumerate all the words in the text and see if they match any of the words to highlight:
class ViewController: UIViewController {
#IBOutlet var textView: UITextView!
override func viewDidLoad() {
super.viewDidLoad()
textView.delegate = self
highlight()
}
func highlight() {
guard let attributedText = textView.attributedText else {
return
}
let wordsToHighlight = ["those", "words", "are", "highlighted"]
let text = NSMutableAttributedString(attributedString: attributedText)
let textRange = NSRange(location: 0, length: text.length)
text.removeAttribute(NSForegroundColorAttributeName, range: textRange)
(text.string as NSString).enumerateSubstrings(in: textRange, options: [.byWords]) { [weak textView] (word, range, _, _) in
guard let word = word else { return }
if wordsToHighlight.contains(word) {
textView?.textStorage.setAttributes([NSForegroundColorAttributeName: UIColor.red], range: range)
} else {
textView?.textStorage.removeAttribute(NSForegroundColorAttributeName, range: range)
}
}
textView.typingAttributes.removeValue(forKey: NSForegroundColorAttributeName)
}
}
extension ViewController: UITextViewDelegate {
func textViewDidChange(_ textView: UITextView) {
highlight()
}
}
This should be fine for small texts. For long texts, going through everything on each change can really hurt performance. In that case, I'd recommend using a custom NSTextStorage subclass. There you would have better control over what range of text have changed and apply the highlight only to that section.
I have a textView in my tableViewCell. In the Interfacebuilder I set a link, mail and address detection for the textview. So all the links, mail and addresses are highlighted. The textview is selectable, too.
I'm working with an autorefresh, so the content of the the tableViewCell will reload all 30 seconds. Everytime when this is happen, the highlighting disappears for < 1 second and the the highlighting comes back.
Sometimes this happens to at the initialize load of the tableViewCell.
There seems to be a bunch of bugs in iOS 7... but I use minimum 8.4.
So does anybody know this bug oder has some help? Thank you
Try to use the attribute method to customize your string in a method similar to this:
let myAttribute = [ NSForegroundColorAttributeName: UIColor.blueColor() ]
let myAttrString = NSAttributedString(string: stringWithLinks, attributes: myAttribute)
So you have your customized string.
Good job
#Carlo
This example doesn't work for me.
Important: In the Interfacebuilder the textview has to be selectable = true/on
What worked for me is my own parser to get all links/mails (whatever you want):
func searchForLinksAndMailsInString(remark: String) -> NSMutableAttributedString {
let attributedString = NSMutableAttributedString(string: remark)
//set default color for non links and mails
attributedString.addAttribute(NSForegroundColorAttributeName, value: UIColor.whiteColor(), range: NSRange(location:0,length:remark.characters.count))
//search for valid websites
let matchesForWebsite = Model.sharedInstance().parseForRegexInText(RegexFilter.regexFindAllValidWebsites, text: remark)
//make links clickable
for match in matchesForWebsite {
attributedString.setAsLink(match, linkURL: match)
}
//same as websites just for mails
let matchesForMails = Model.sharedInstance().parseForRegexInText(RegexFilter.regexFindAllValidMails, text: remark)
for match in matchesForMails {
attributedString.setAsLMail(match, mail: match)
}
return attributedString
}
Extension to make the results clickable
extension NSMutableAttributedString {
//not my work see [here][1]
public func setAsLink(textToFind:String, linkURL:String) -> Bool {
let foundRange = self.mutableString.rangeOfString(textToFind)
if foundRange.location != NSNotFound {
if let linkURLToURL = NSURL(string: linkURL) {
self.addAttribute(NSLinkAttributeName, value: linkURLToURL, range: foundRange)
}
return true
}
return false
}
public func setAsLMail(textToFind:String, mail:String) -> Bool {
let foundRange = self.mutableString.rangeOfString(textToFind)
if foundRange.location != NSNotFound {
if let mailToURL = NSURL(string: "mailto:\(mail)") {
self.addAttribute(NSLinkAttributeName, value: mailToURL, range: foundRange)
}
return true
}
return false
}
}
How to call:
let attributedString = searchForLinksAndMailsInString(remark)
subtitleTextView.attributedText = attributedString
My regexs (they work, no guarantee for elegance):
class RegexFilter {
static let regexFindAllValidMails = "[A-Z0-9a-z._%+-]+#[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"
static let regexFindAllValidWebsites = "(((http(s)?://)(www.)?)|(www.))([a-z][a-z0-9]*).([a-z]{2,3})"
}
I found this question: How to make UITextView detect hashtags? and I copied the accepted answer into my code. I also set up a delegate to my textview and then put this code:
func textViewDidChange(textView: UITextView) {
textView.resolveHashTags()
}
By doing that I want to check the user's input on the textview and each time user types a #hashtag - I will automatically highlight it.
But there is a weird problem: it never highlights words that starts with the same letters.
It looks like this:
what might be the problem here?
func resolveHashTags(text : String) -> NSAttributedString{
var length : Int = 0
let text:String = text
let words:[String] = text.separate(withChar: " ")
let hashtagWords = words.flatMap({$0.separate(withChar: "#")})
let attrs = [NSFontAttributeName : UIFont.systemFont(ofSize: 17.0)]
let attrString = NSMutableAttributedString(string: text, attributes:attrs)
for word in hashtagWords {
if word.hasPrefix("#") {
let matchRange:NSRange = NSMakeRange(length, word.characters.count)
let stringifiedWord:String = word
attrString.addAttribute(NSLinkAttributeName, value: "hash:\(stringifiedWord)", range: matchRange)
}
length += word.characters.count
}
return attrString
}
To separate words I used a string Extension
extension String {
public func separate(withChar char : String) -> [String]{
var word : String = ""
var words : [String] = [String]()
for chararacter in self.characters {
if String(chararacter) == char && word != "" {
words.append(word)
word = char
}else {
word += String(chararacter)
}
}
words.append(word)
return words
}
}
I hope this is what you are looking for. Tell me if it worked out for you.
Edit :
func textViewDidChange(_ textView: UITextView) {
textView.attributedText = resolveHashTags(text: textView.text)
textView.linkTextAttributes = [NSForegroundColorAttributeName : UIColor.red]
}
Edit 2: Updated for swift 3.
Little late to the party
private func getHashTags(from caption: String) -> [String] {
var words: [String] = []
let texts = caption.components(separatedBy: " ")
for text in texts.filter({ $0.hasPrefix("#") }) {
if text.count > 1 {
let subString = String(text.suffix(text.count - 1))
words.append(subString)
}
}
return words
}
Here is Swift 5 solution as String extension:
extension String
{
func withHashTags(color: UIColor) -> NSMutableAttributedString
{
let words = self.components(separatedBy: " ")
let attributedString = NSMutableAttributedString(string: self)
for word in words
{
if word.hasPrefix("#")
{
let range = (self as NSString).range(of: word)
attributedString.addAttribute(.foregroundColor, value: color, range: range)
}
}
return attributedString
}
}
Pass param color to set specific color for hashtags
I'm trying to color all occurrences (not just the first) of a textview string however when I get to the looping I get a cannot invoke rangeOfString argument.
I checked the rangeOfString:options:range documentation on Swift 2 and it looked pretty similar. I'm not really sure what I'm doing wrong.
import UIKit
class ViewController: UIViewController {
#IBOutlet weak var textView: UITextView!
func textView(textView: UITextView){
if textView == self.textView {
let nsString = textView.text as NSString
let stringLength = textView.text.characters.count
var range = NSRange(location: 0, length: textView.text.characters.count)
let searchString = textView.text
let text = NSMutableAttributedString(string: textView.text)
text.addAttribute(NSForegroundColorAttributeName, value: UIColor.redColor(), range: NSMakeRange(0, stringLength))
textView.attributedText = text
while(range.location != NSNotFound) {
range = (textView.text as NSString).rangeOfString(searchString, options: NSStringCompareOptions, range: range)
text.addAttribute(NSForegroundColorAttributeName, value: UIColor.redColor(), range: nsString.rangeOfString("hello"))
}
}
}
}
It looks like you try to highlight user input from search bar. Here how I do so:
func highlightedText(text: NSString, inText: NSString, var withColor: UIColor?) -> NSMutableAttributedString {
var attributedString = NSMutableAttributedString(string:inText as String)
let range = (inText.lowercaseString as NSString).rangeOfString(text.lowercaseString as String)
if withColor == nil {
if let color = globalTint() {
withColor = color
} else {
withColor = UIColor.redColor()
}
}
attributedString.addAttribute(NSForegroundColorAttributeName, value: withColor! , range: range)
return attributedString
}
...
textView.attributedText = highlightedText("string", inText: titleText, color: nil)