I'm trying to build a UITextView in iOS8 that recognizes taps on specific words, specifically words preceded by the "#" and "# symbols"
I first tried the following method in a subclass of UITextView:
var point = tapGesture.locationInView(self)
var position = closestPositionToPoint(point)
let range = tokenizer.rangeEnclosingPosition(position, withGranularity: .Word, inDirection: 1)
let word = textInRange(range)
println(word!)
However, clicking on a word in a text view prints the word, but will leave out the "#" and "#", I believe this to be from the .Word granularity not recognizing special characters. I came up with a work around that uses attributed text to identify the prefixed special character.
var point = tapGesture.locationInView(self)
var position = closestPositionToPoint(point)
let range = tokenizer.rangeEnclosingPosition(position, withGranularity: .Word, inDirection: 1)
if range != nil {
let location = offsetFromPosition(beginningOfDocument, toPosition: range!.start)
let length = offsetFromPosition(range!.start, toPosition: range!.end)
let attrRange = NSMakeRange(location, length)
let attrText = attributedText.attributedSubstringFromRange(attrRange)
let word = attributedText.attributedSubstringFromRange(attrRange)
let isHashtag: AnyObject? = word.attribute("Hashtag", atIndex: 0, longestEffectiveRange: nil, inRange: NSMakeRange(0, word.length))
let isAtMention: AnyObject? = word.attribute("Mention", atIndex: 0, longestEffectiveRange: nil, inRange: NSMakeRange(0, word.length))
if isHashtag != nil {
println("#\(word.string)")
} else if isAtMention != nil {
println("#\(word.string)")
}
}
And this works pretty well, but tapping on the special character will not print out the word. Does anyone have possible solution to this problem? Is there a different way to identify tapped words without using rangeEnclosingPosition?
Related
How can you check if you string has an ellipsis in swift? I have noticed some odd behavior when using the swift string functions and learned that ellipsis are to blame. When a user enters ... as part of the string in a UITextField and then I try to locate a string after that the character count is always off by 2. This is because the string functions are treating ... as 3 characters when locating the index of the string I am searching for, but the character count functions are treating it as 1 character. The solution is pretty simple... when I have an elliptical in the string then adjust the "found" index of the string by 2. The issue is I don't know how to search "does this string have an ellipsis" because this didn't find it:
if heading.contains("...") {
print ("found the ...!")
}
I suspect there is a special way to search for an ellipsis but haven't been able to find out what it is. This is my "find the last space after substringing out the first 30 characters" function that works for strings that don't have an ellipsis:
func breakOutHeadingFromString(fullString: String, charBreakPoint: Int) -> (heading: String, remainingText: String, breakChar: String) {
var heading = fullString
var remainingText = ""
var breakChar = ""
// If the Full string has less characters than break point then just return full blurb as heading and blank out 2
if fullString.count > charBreakPoint {
// Get N characters out of total char count (hardcoded to 30 can this be dynamic?)
var prefix = String(fullString.prefix(charBreakPoint))
// Find the last space in heading so you can continue message there
let lastSpace = prefix.lastIndex(of: " ") ?? prefix.endIndex
var breakPoint = lastSpace
breakChar = "|"
// If there is a /n clip there
if let newLine = prefix.firstIndex(of: "\n") {
prefix = String(prefix[..<newLine])
breakPoint = newLine
breakChar = "\n"
}
// Use the Break Point to split the message in 2
let breakPointInt: Int = fullString.distance(from: fullString.startIndex, to: breakPoint)
// if the string has a eliptical ... then the break point is off by 2 because it 1 char in but 3 in
heading = String(fullString.prefix(breakPointInt))
remainingText = String(fullString.suffix(fullString.count - breakPointInt - 1))
}
return (heading,remainingText,breakChar)
}
The ellipsis is 1 unicode character, not 3 so it is counted as 1 character and below is what I think is happening in your situation.
This did not find it
if heading.contains("...") {
print ("found the ...!")
}
Because these are 3 periods (3 characters) and different from the ellipsis character (1 character)
Try highlighting with a mouse what you are comparing (...) with the actual ellipsis character (…)
In the first instance, you can highlight each of the dots individually using your mouse and in the second scenario you will not be able to select each individual dot.
Here is some test:
var ellipsisString = "This is … a"
var threeDotString = "This is ... a"
print("Ellipsis character count: \(ellipsisString.count)")
print("Three dot character count: \(threeDotString.count)")
// The output:
Ellipsis character count: 11
Three dot character count: 13
As you can see, with the proper ellipsis character, it counts it as only 1 character
Now using your contains function with the ellipsis string:
var ellipsisString = "This is … a"
print(ellipsisString.contains("…"))
print(ellipsisString.contains("..."))
// The output
true
false
You can see contains("…") succeeds with the real ellipsis character but contains("...") fails with the three dots you used.
Finally, let's say I wanted to add the string nice after the ellipsis character in the ellipsis string This is … a - your strategy will not work of adding 2 to the index if a proper ellipsis character was used
Here is what I do to achieve this:
var ellipsisString = "This is … a"
// Find the index of the ellipsis character
if let ellipsisIndex = ellipsisString.firstIndex(of: "…")
{
// Convert the String index to a numeric value
let numericIndex: Int
= ellipsisString.distance(from: ellipsisString.startIndex,
to: ellipsisIndex)
// Ellipsis located at the 8th index which is right
print(numericIndex)
// Set the index where we want to add the new text
// The index after the ellipsis character so offset by 1
let insertIndex = ellipsisString.index(ellipsisIndex,
offsetBy: 1)
// Insert the text " nice" into the string after the
// ellipsis character
ellipsisString.insert(contentsOf: " nice",
at: insertIndex)
}
// Print the output after the addition
print(ellipsisString)
// Output
This is … nice a
This gives the desired output you wish which is finding the position of the ellipsis character accurately and then using that position to do what you want, in my case, adding a text after the ellipsis character.
Hope this clears some things up for you
Update With Example
Here is a small update to detect an ellipsis or a period added by the user in a UITextField in real time. The ellipsis will be highlighted in yellow
1. Set Up with a UITextField
class EllipsisViewController: UIViewController
{
let textField = UITextField()
override func viewDidLoad()
{
super.viewDidLoad()
// Basic set up
view.backgroundColor = .white
title = "Ellipses text"
configureTextField()
}
private func configureTextField()
{
// UITextField with auto layout
textField.layer.borderWidth = 2.0
textField.delegate = self
textField.layer.borderColor = UIColor.blue.cgColor
textField.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(textField)
textField.leadingAnchor
.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor,
constant: 16)
.isActive = true
textField.topAnchor
.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
.isActive = true
textField.trailingAnchor
.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor,
constant: -16)
.isActive = true
textField.heightAnchor
.constraint(equalToConstant: 70)
.isActive = true
}
}
Detect ellipsis in a string using UITextFieldDelegate and searching character by character
extension EllipsisViewController: UITextFieldDelegate
{
func textFieldDidChangeSelection(_ textField: UITextField)
{
highlightEllipsisAndPeriods()
}
private func highlightEllipsisAndPeriods()
{
if let text = textField.text
{
let attributedText = NSMutableAttributedString(string: text)
let ellipsisCharacter: String.Element = "…"
for (index, character) in text.enumerated()
{
let attributedKey = NSAttributedString.Key.backgroundColor
// Highlight ellipsis in yellow
if character == ellipsisCharacter
{
attributedText.addAttribute(attributedKey,
value: UIColor.yellow,
range: NSRange(location: index,
length: 1))
}
// Highlight periods in green
if character == "."
{
attributedText.addAttribute(attributedKey,
value: UIColor.green,
range: NSRange(location: index,
length: 1))
}
}
textField.attributedText = attributedText
}
}
}
This gives you the following result
If you have a single pattern you want to find like an ellipsis, you have the option to use NSRegularExpression as well
That would give the same result as above, here is the function:
extension EllipsisViewController: UITextFieldDelegate
{
func textFieldDidChangeSelection(_ textField: UITextField)
{
//highlightEllipsisAndPeriods()
highlightEllipsisUsingRegex()
}
private func highlightEllipsisUsingRegex()
{
if let text = textField.text
{
let attributedText = NSMutableAttributedString(string: text)
let ellipsisString = "…"
do
{
let regex = try NSRegularExpression(pattern: ellipsisString,
options: .caseInsensitive)
// Loop through all the ellipsis found and highlight
// in yellow
for match in regex.matches(in: text,
options: NSRegularExpression.MatchingOptions(),
range: NSRange(location: 0,
length: text.count))
as [NSTextCheckingResult]
{
attributedText.addAttribute(NSAttributedString.Key.backgroundColor,
value: UIColor.yellow,
range: match.range)
}
textField.attributedText = attributedText
}
catch
{
// handle errors
print("error")
}
}
}
}
This is the result using the Regex method:
I have an extension which identify and return the word before the cursor:
extension UITextView {
var currentWord : String? {
let beginning = beginningOfDocument
if let start = position(from: beginning, offset: selectedRange.location),
let end = position(from: start, offset: selectedRange.length) {
let textRange = tokenizer.rangeEnclosingPosition(end, with: .word, inDirection: 1)
if let textRange = textRange {
return text(in: textRange)
}
}
return nil
}
I am using UItextGranularity.word and works fine.
However my issue is this:
If at the beginning of the word i have an # it won't be returned.
so if i have #jon the currentword will be jon.
Is there a way to include the # so to have the complete word with the special Character?
Thank you
It doesn't seem like you can do it with UITextInputTokenizer. You can try this solution:
var currentWord: String? {
let regex = try! NSRegularExpression(pattern: "\\S+$")
let textRange = NSRange(location: 0, length: selectedRange.location)
if let range = regex.firstMatch(in: text, range: textRange)?.range {
return String(text[Range(range, in: text)!])
}
return nil
}
I am trying to add color to 2 words in a string. This is the code I am using:
var HighScore:Int = 0
var CurrentScore:Int = 0
let stringOne = "You have managed to score \(CurrentScore). Current record is \(self.HighScore). Play again and give it another try!"
let stringTwo = "\(CurrentScore)"
let stringThree = "\(HighScore)"
let range1 = (stringOne as NSString).range(of: stringTwo)
let range2 = (stringOne as NSString).range(of: stringThree)
let attributedText = NSMutableAttributedString.init(string: stringOne)
attributedText.addAttribute(NSForegroundColorAttributeName, value: UIColor.init(netHex: 0x00b4ff) , range: range1)
attributedText.addAttribute(NSForegroundColorAttributeName, value: UIColor.init(netHex: 0x00b4ff) , range: range2)
gameOverDescriptionLabel.attributedText = attributedText
The problem I have is that if CurrentScore and HighScore is the same(ex: 2 & 2) the color on the range2 still stays white, but if they are not equal(2 & 1 or 1 & 2) both gets the color I have choosen.
Any suggestions?
Add this to the top or bottom of your .swift file:
extension NSMutableAttributedString {
func bold(_ text:String) -> NSMutableAttributedString {
let attrs:[String:AnyObject] = [NSForegroundColorAttributeName: UIColor.init(netHex: 0x00b4ff)]
let boldString = NSMutableAttributedString(string:"\(text)", attributes:attrs)
self.append(boldString)
return self
}
func normal(_ text:String)->NSMutableAttributedString {
let normal = NSAttributedString(string: text)
self.append(normal)
return self
}
}
To code below is the usage, you can edit it how you'd like, but I have made it so you can easy just copy&paste it to your project:
let formattedString = NSMutableAttributedString()
formattedString
.normal("You have managed to score ")
.bold("\(CurrentScore)")
.normal(". Current record is ")
.bold("\(HighScore)")
.normal(". Play again and give it another try!")
gameOverDescriptionLabel.attributedText = formattedString
If the current score and high score are the same string, searching for the latter finds the former (because the search starts at the beginning).
There are lots of other, better ways to do this.
Perform the second search in a range starting after the result of the first search (range(of:) has a range: parameter, but you are not using it)
Instead of looking for the range of the high score, search for the surrounding boilerplate ("You have managed to score" and so on) and figure out where the numbers must be.
Use NSScanner or regular expressions to find the numerical expressions embedded in the string.
My favorite: mark each of the numbers with an "invisible" attribute and search for that attribute so that you can find the numbers reliably (example here).
A solution without searching for the range would be to create 2 separate NSMutableAttributedString for current score and high score, and then append everything together.
let currentScoreString = NSMutableAttributedString(...)
let highscoreString = NSMutableAttributedString(...)
let finalString = NSMutableAttributedString(string: "You have managed to score ").append(currentScoreString)....
//MARK: forgroundColor
open var foregroundColor: UIColor? {
didSet {
textAttributes[NSForegroundColorAttributeName] = foregroundColor
self.attributedText = NSAttributedString(string: self.text, attributes: textAttributes)
}
}
//MARK: backgroundColor
open var textBackgroundColor: UIColor? {
didSet {
textAttributes[NSBackgroundColorAttributeName] = textBackgroundColor
self.attributedText = NSAttributedString(string: self.text, attributes: textAttributes)
}
}
I want to have multiple capture groups that can be optional and I want to access the strings they correspond to.
Something that looks/works like this:
let text1 = "something with foo and bar"
let text2 = "something with just bar"
let regex = NSRegularExpression(pattern: "(foo)? (bar)")
for (first?, second) in regex.matches(in:text1) {
print(first) // foo
print(second) // bar
}
for (first?, second) in regex.matches(in:text2) {
print(first) // nil
print(second) // bar
}
Retrieving captured subtext with NSRegularExpression is not so easy.
First of all, the result of matches(in:range:) is [NSTextCheckingResult], and each NSTextCheckingResult does not match to tuple like (first?, second).
If you want to retrieve captured subtext, you need to get the range from the NSTextCheckingResult with rangeAt(_:) method. rangeAt(0) represents the range matching the whole pattern, rangeAt(1) for the first capture, rangeAt(2) for the second, and so on.
And rangeAt(_:) returns an NSRange, not Swift Range. The content (location and length) is based on the UTF-16 representation of NSString.
And this is the most important part for your purpose, rangeAt(_:) returns NSRange(location: NSNotFound, length: 0) for each missing capture.
So, you may need to write something like this:
let text1 = "something with foo and bar"
let text2 = "something with just bar"
let regex = try! NSRegularExpression(pattern: "(?:(foo).*)?(bar)") //please find a better example...
for match in regex.matches(in: text1, range: NSRange(0..<text1.utf16.count)) {
let firstRange = match.rangeAt(1)
let secondRange = match.rangeAt(2)
let first = firstRange.location != NSNotFound ? (text1 as NSString).substring(with: firstRange) : nil
let second = (text1 as NSString).substring(with: secondRange)
print(first) // Optioonal("foo")
print(second) // bar
}
for match in regex.matches(in: text2, range: NSRange(0..<text2.utf16.count)) {
let firstRange = match.rangeAt(1)
let secondRange = match.rangeAt(2)
let first = firstRange.location != NSNotFound ? (text2 as NSString).substring(with: firstRange) : nil
let second = (text2 as NSString).substring(with: secondRange)
print(first) // nil
print(second) // bar
}
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.