resolving hashtags in textview - swift

I'm setting the text on a textview and then calling this method of the UITextView extension in order to create links out of the words that are hashtags and mentions (Swift 3)
extension UITextView {
func resolveHashTags(font: UIFont = UIFont.systemFont(ofSize: 17.0)){
if let text = self.text {
let words:[String] = text.components(separatedBy: " ")
let attrs = [ NSFontAttributeName : font ]
let attrString = NSMutableAttributedString(string: text, attributes:attrs)
for word in words {
if (word.characters.count > 1 && ((word.hasPrefix("#") && word[1] != "#") || (word.hasPrefix("#") && word[1] != "#"))) {
let matchRange = text.range(of: word)
let newWord = String(word.characters.dropFirst())
if let matchRange = matchRange {
attrString.addAttribute(NSLinkAttributeName, value: "\(word.hasPrefix("#") ? "hashtag:" : "mention:")\(newWord)", range: text.NSRangeFromRange(range: matchRange))
}
}
}
self.attributedText = attrString
}
}
}
My issue is very simple. I have no way to create a link for something like this "helloworld#hello" simply because my word does not have a prefix of "#"
Another scenario I can't figure out is when the user puts multi hashtags together for example "hello world, how are you? #success#moments#connect" as this would all be considered 1 hashtag with the current logic when it should be 3 different links.
How do i correct? thank you

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
}
}
func textViewDidChange(_ textView: UITextView) {
textView.attributedText = resolveHashTags(text: textView.text)
textView.linkTextAttributes = [NSForegroundColorAttributeName : UIColor.red]
}
I hope this is what you are looking for. Tell me if it worked out for you.

If you're willing to use a 3rd party library, you could try using Mustard (disclaimer: I'm the author).
You could match hashtag tokens using this tokenizer:
struct HashtagTokenizer: TokenizerType, DefaultTokenizerType {
// start of token is identified by '#'
func tokenCanStart(with scalar: UnicodeScalar) -> Bool {
return scalar == UnicodeScalar(35) // ('35' is scalar value for the # character)
}
// all remaining characters must be letters
public func tokenCanTake(_ scalar: UnicodeScalar) -> Bool {
return CharacterSet.letters.contains(scalar)
}
}
Which can then be used to match the hashtags in the text:
let hashtags = "hello world, how are you? #success#moments#connect".tokens(matchedWith: HashtagTokenizer())
// hashtags.count -> 3
// hashtags[0].text -> "#success"
// hashtags[0].range -> 26..<34
// hashtags[1].text -> "#moments"
// hashtags[1].range -> 34..<42
// hashtags[2].text -> "#connect"
// hashtags[2].range -> 42..<50
The array returned is an array of tokens, where each one contains a text property of the matched text, and a range property of the range of that matched text in the original string that you can use to create a link on the text view.

Related

How to extract Hashtags from text using SwiftUI?

Is there any way to find Hashtags from a text with SwiftUI?
This is how my try looks like:
calling the function like this : Text(convert(msg.findMentionText().joined(separator: " "), string: msg)).padding(.top, 8)
.
But it does not work at all.
My goal something like this:
extension String {
func findMentionText() -> [String] {
var arr_hasStrings:[String] = []
let regex = try? NSRegularExpression(pattern: "(#[a-zA-Z0-9_\\p{Arabic}\\p{N}]*)", options: [])
if let matches = regex?.matches(in: self, options:[], range:NSMakeRange(0, self.count)) {
for match in matches {
arr_hasStrings.append(NSString(string: self).substring(with: NSRange(location:match.range.location, length: match.range.length )))
}
}
return arr_hasStrings
}
}
func convert(_ hashElements:[String], string: String) -> NSAttributedString {
let hasAttribute = [NSAttributedString.Key.foregroundColor: UIColor.orange]
let normalAttribute = [NSAttributedString.Key.foregroundColor: UIColor.black]
let mainAttributedString = NSMutableAttributedString(string: string, attributes: normalAttribute)
let txtViewReviewText = string as NSString
hashElements.forEach { if string.contains($0) {
mainAttributedString.addAttributes(hasAttribute, range: txtViewReviewText.range(of: $0))
}
}
return mainAttributedString
}
You need to initailize Text() with a String, but instead you are attempting to initialize it with an Array of String.
You could either just display the first one if the array is not empty:
msg.findMentionText().first.map { Text($0) }
Or you could join the elements array into a single String:
Text(msg.findMentionText().joined(separator: " "))

How to get context of a sentence

So i'm working a an app that can patch words that are broken.
Lets take:
mny people say there is a error in this sentence
With swift here we can us UITextChecker and get a wonderful result of what the word mny could actually be... However, i actually get a couple of choices, one of which is many and among the other you have money so obviously money wouldn't fit in very well in this sentence. Are there any way to check if the sentence itself is logical?
Consider that this still needs to be improved. I updated this swift 3 solution to Swift 5. Worth to mention that it was originally inspired by this python tutorial
Create a new iOS project, add there a text file named bigtext.txt which will contain this text. This will be our "learning" dictionary.
Then in ViewController:
import UIKit
import NaturalLanguage
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let inputString = "mny people say there is a error in this sentence"
var newString = inputString
// Read a text file and "study" the model
guard let path = Bundle.main.path(forResource: "bigtext", ofType: "txt") else {
print("Path not available")
return
}
let checker = SpellChecker(contentsOfFile: path)
// better to use this to iterate between words in a sentence
let tokenizer = NLTokenizer(unit: .word)
tokenizer.string = inputString
tokenizer.enumerateTokens(in: inputString.startIndex..<inputString.endIndex) { tokenRange, _ in
let word = String(inputString[tokenRange])
let checked = checker?.correct(word: word)
let candidates = checker?.candidates(word: word)
if word == checked {
print("\(word) unchanged")
} else {
if let checked = checked {
newString.replaceSubrange(tokenRange, with: checked)
}
print("Correct:\t\(word) -> \(String(describing: checked))")
print("Candidates:\t\(word) -> \(String(describing: candidates))")
}
return true
}
print("Result: \(newString)")
}
}
func edits(word: String) -> Set<String> {
if word.isEmpty { return [] }
let splits = word.indices.map {
(word[word.startIndex..<$0], word[$0..<word.endIndex])
}
let deletes = splits.map { $0.0 + String($0.1.dropFirst()) }
let transposes: [String] = splits.map { left, right in
if let fst = right.first {
let drop1 = String(right.dropFirst())
if let snd = drop1.first {
let drop2 = String(drop1.dropFirst())
return "\(left)\(snd)\(fst)\(drop2)"
}
}
return ""
}.filter { !$0.isEmpty }
let alphabet = "abcdefghijklmnopqrstuvwxyz"
let replaces = splits.flatMap { left, right in
alphabet.map { "\(left)\($0)\(String(right.dropFirst()))" }
}
let inserts = splits.flatMap { left, right in
alphabet.map { "\(left)\($0)\(right)" }
}
let setString = [String(deletes.first!)] + transposes + replaces + inserts
return Set(setString)
}
struct SpellChecker {
var knownWords: [String:Int] = [:]
mutating func train(word: String) {
if let idx = knownWords[word] {
knownWords[word] = idx + 1
}
else {
knownWords[word] = 1
}
}
init?(contentsOfFile file: String) {
do {
let text = try String(contentsOfFile: file, encoding: .utf8).lowercased()
let words = text.unicodeScalars.split(whereSeparator: { !("a"..."z").contains($0) }).map { String($0) }
for word in words { self.train(word: word) }
}
catch {
return nil
}
}
func knownEdits2(word: String) -> Set<String>? {
var known_edits: Set<String> = []
for edit in edits(word: word) {
if let k = known(words: edits(word: edit)) {
known_edits.formUnion(k)
}
}
return known_edits.isEmpty ? nil : known_edits
}
func known<S: Sequence>(words: S) -> Set<String>? where S.Iterator.Element == String {
let s = Set(words.filter { self.knownWords.index(forKey: $0) != nil })
return s.isEmpty ? nil : s
}
func candidates(word: String) -> Set<String> {
guard let result = known(words: [word]) ?? known(words: edits(word: word)) ?? knownEdits2(word: word) else {
return Set<String>()
}
return result
}
func correct(word: String) -> String {
return candidates(word: word).reduce(word) {
(knownWords[$0] ?? 1) < (knownWords[$1] ?? 1) ? $1 : $0
}
}
}
Will output you:
Correct: mny -> Optional("may")
Candidates: mny -> Optional(Set(["any", "ny", "may", "many"]))
people unchanged
say unchanged
there unchanged
is unchanged
a unchanged
error unchanged
in unchanged
this unchanged
sentence unchanged
Result: may people say there is a error in this sentence
Please, consider that we took first correction candidate.
Need first to clarify ourselves the word order and understand the sentence context.

How I can change text color for every 5 first words in label?

I get different text from API and I want change text color for every 5 first word. I try use range and attributes string, but I do something wrong and this not good work for me. How can i do it?
this is my code:
private func setMessageText(text: String) {
let components = text.components(separatedBy: .whitespacesAndNewlines)
let words = components.filter { !$0.isEmpty }
if words.count >= 5 {
let attribute = NSMutableAttributedString.init(string: text)
var index = 0
for word in words where index < 5 {
let range = (text as NSString).range(of: word, options: .caseInsensitive)
attribute.addAttribute(NSAttributedString.Key.foregroundColor, value: Colors.TitleColor, range: range)
attribute.addAttribute(NSAttributedString.Key.font, value: Fonts.robotoBold14, range: range)
index += 1
}
label.attributedText = attribute
} else {
label.text = text
}
}
enter image description here
It's more efficient to get the index of the end of the 5th word and add color and font once for the entire range.
And you are strongly discouraged from bridging String to NSString to get a subrange from a string. Don't do that. Use native Swift Range<String.Index>, there is a convenience API to convert Range<String.Index> to NSRange reliably.
private func setMessageText(text: String) {
let components = text.components(separatedBy: .whitespacesAndNewlines)
let words = components.filter { !$0.isEmpty }
if words.count >= 5 {
let endOf5thWordIndex = text.range(of: words[4])!.upperBound
let nsRange = NSRange(text.startIndex..<endOf5thWordIndex, in: text)
let attributedString = NSMutableAttributedString(string: text)
attributedString.addAttributes([.foregroundColor : Colors.TitleColor, .font : Fonts.robotoBold14], range: nsRange)
label.attributedText = attributedString
} else {
label.text = text
}
}
An alternative – more sophisticated – way is to use the dedicated API enumerateSubstrings(in:options: with option byWords
func setMessageText(text: String) {
var wordIndex = 0
var attributedString : NSMutableAttributedString?
text.enumerateSubstrings(in: text.startIndex..., options: .byWords) { (substring, substringRange, enclosingRange, stop) in
if wordIndex == 4 {
let endIndex = substringRange.upperBound
let nsRange = NSRange(text.startIndex..<endIndex, in: text)
attributedString = NSMutableAttributedString(string: text)
attributedString!.addAttributes([.foregroundColor : Colors.TitleColor, .font : Fonts.robotoBold14], range: nsRange)
stop = true
}
wordIndex += 1
}
if let attributedText = attributedString {
label.attributedText = attributedText
} else {
label.text = text
}
}

swift extract hashtag strings from text

How do I extract hashtag strings from a text in Swift? I've seen some answers but they seem too complicated for what I need and I don't really understand how RegEx works?
E.g.
Text: "This is #something with a lot of #random #hashtags #123yay."
What I want: "something", "random", "hashtags", "123yay".
Thanks!
here is the helper method to convert your string into hash detection string
this extension find the # words from sting also including arabic words.
extension String {
func findMentionText() -> [String] {
var arr_hasStrings:[String] = []
let regex = try? NSRegularExpression(pattern: "(#[a-zA-Z0-9_\\p{Arabic}\\p{N}]*)", options: [])
if let matches = regex?.matches(in: self, options:[], range:NSMakeRange(0, self.count)) {
for match in matches {
arr_hasStrings.append(NSString(string: self).substring(with: NSRange(location:match.range.location, length: match.range.length )))
}
}
return arr_hasStrings
}
}
And below method converts your string into Reach colorful hash string.
func convert(_ hashElements:[String], string: String) -> NSAttributedString {
let hasAttribute = [NSAttributedStringKey.foregroundColor: UIColor.orange]
let normalAttribute = [NSAttributedStringKey.foregroundColor: UIColor.black]
let mainAttributedString = NSMutableAttributedString(string: string, attributes: normalAttribute)
let txtViewReviewText = string as NSString
hashElements.forEach { if string.contains($0) {
mainAttributedString.addAttributes(hasAttribute, range: txtViewReviewText.range(of: $0))
}
}
return mainAttributedString
}
i.e
let text = "#Jaydeep #Viral you have to come for party"
let hashString = convert(text.findMentionText(), string: text)
Output:
extension String
{
func hashtags() -> [String]
{
if let regex = try? NSRegularExpression(pattern: "#[a-z0-9]+", options: .caseInsensitive)
{
let string = self as NSString
return regex.matches(in: self, options: [], range: NSRange(location: 0, length: string.length)).map {
string.substring(with: $0.range).replacingOccurrences(of: "#", with: "").lowercased()
}
}
return []
}
}
then, to get the hashtags array
yourstring.hashtags()
Here is the source
let str = "This is #something with a lot of #random #hashtags #123yay."
let words = str.components(separatedBy: " ")
var hashTags = [String]()
for word in words{
if word.hasPrefix("#"){
let hashtag = word.dropFirst()
hashTags.append(String(hashtag))
}
}
print("Hashtags :: ", hashTags)
First things first, this works best in a TextView. So set one up inside of your view however you want, but make sure that your ViewController has a UITextViewDelegate & the textView is delegated to that view controller.
I’m also doing this with some prefilled information, but the same concept applies with pulling data from your database and what not.
This is how we set up our ViewController:
class ViewController: UIViewController, UITextViewDelegate {
var string = "Hello, my name is #Jared & #Jared and I like to move it."
#IBOutlet weak var textView: UITextView!
override func viewDidLoad() {
super.viewDidLoad()
textView.text = string
textView.delegate = self
}
The overall task we’re trying to accomplish in this part is just to split up all the words in our textView. It’s simpler than you might think:
First, let’s create our extension:
Now add this to your ViewController:
extension UITextView {
func resolveTags(){
let nsText:NSString = self.text as NSString
let words:[String] = nsText.components(separatedBy: " ")
let attrs = [
NSAttributedStringKey.font : UIFont.init(name: "HelveticaNeue", size: 13),
NSAttributedStringKey.foregroundColor : UIColor.black
]
let attrString = NSMutableAttributedString(string: nsText as String, attributes:attrs)
for word in words {
if word.hasPrefix("#") {
let matchRange:NSRange = nsText.range(of: word as String)
var stringifiedWord:String = word as String
stringifiedWord = String(stringifiedWord.dropFirst())
attrString.addAttribute(NSAttributedStringKey.link, value: "hash:\(stringifiedWord)", range: matchRange)
attrString.addAttribute(NSAttributedStringKey.foregroundColor, value: UIColor.blue , range: matchRange)
}
}
self.attributedText = attrString
}
}
Let’s use this thing!
It all comes down to this. We have this function working, now how do we use it?
Easy.
Inside of your viewDidLoad function, or wherever you set your textView text, just call:
textView.resolveTags()
Result:
Courtesy of: Jared Davidson On Twitter
You can also use third party Activelabel . this is simple to use and also support Hashtags (#), Mentions (#), URLs (http://) and custom regex patterns
https://github.com/optonaut/ActiveLabel.swift
I just changed #JayDeep 's answer to more swifty style.
extension String {
var tags: [String] {
let regex = try? NSRegularExpression(pattern: "(#[a-zA-Z0-9_\\p{Arabic}\\p{N}]*)", options: [])
let nsRange: NSRange = .init(location: 0, length: self.count)
guard let matches = regex?.matches(in: self, options: NSRegularExpression.MatchingOptions(), range: nsRange)
else { return [] }
return matches
.map { match in
let startIndex = self.index(self.startIndex, offsetBy: match.range.location)
let endIndex = self.index(startIndex, offsetBy: match.range.length)
let range = startIndex ..< endIndex
return String(self[range])
}
}
}
My clean solution: We will return PrefixesDetected to the view. And the view will format it as he wants. (So we will execute yourString.resolvePrefixes()) in the viewModel and we will be able to test it.
struct PrefixesDetected {
let text: String
let prefix: String?
}
extension String {
func resolvePrefixes(_ prefixes: [String] = ["#", "#"]) -> [PrefixesDetected] {
let words = self.components(separatedBy: " ")
return words.map { word -> PrefixesDetected in
PrefixesDetected(text: word,
prefix: word.hasPrefix(prefixes: prefixes))
}
}
func hasPrefix(prefixes: [String]) -> String? {
for prefix in prefixes {
if hasPrefix(prefix) {
return prefix
}
}
return nil
}
}
Then in the view we can format it as for example: (In this case we want both in the same color but in this way you can give them different behaviors)
Here I do with reduce but this is just to show an example, you can format it as you want! :)
titleDetectedPrefixes.reduce(NSAttributedString(), { result, prefixDectedWord in
let wordColor: UIColor = prefixDectedWord.prefix != nil ? .highlightTextMain : .mainText
let attributedWord = NSAttributedString(string: prefixDectedWord.text)
{ Add desired attributes }
})
for those who are using swiftUI you can achieve it by using the "+" operator
so the final solution will look like this
static func tagHighlighter(description : String , previousText : Text = Text("") , tag : String = "#") -> Text {
var t : Text = Text("")
let words : [String] = description.components(separatedBy: " ")
for word in words {
if !word.isEmpty {
let tag = word[word.startIndex]
if tag == "#" {
t = t + Text("\(word) ").foregroundColor(Color("tag_color"))
} else if tag == "#" {
t = t + Text("\(word) ").foregroundColor(Color("tag_color"))
} else {
t = t + Text("\(word) ")
}
}
}
return t
}
This is how I'm doing it
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.lowercased())
}
}
return words
}
Copy paste this extension to your class:
extension UITextView{
func insertTextWithHashtags(text textString: String){
let nsTextString: NSString = textString as NSString
let simpleTextAttributes: [NSAttributedString.Key : Any] = [NSAttributedString.Key.foregroundColor : UIColor(named: "Black Color")!, NSAttributedString.Key.font : UIFont(name: "Inter-Regular", size: 16.0)!]
let attributedString = NSMutableAttributedString(string: textString, attributes: simpleTextAttributes)
var word = ""
for text in textString+" "{ //+" " is for loop to run one extra time to complete hashtag
if text == "#" || text == "\n" || text == " "{
if word.hasPrefix("#"){
let range = nsTextString.range(of: word)
let link = [NSAttributedString.Key.link : word]
attributedString.addAttributes(link, range: range)
if text == "#"{
word = "#"
}else{
word = ""
}
}else{
if text == "#"{
word = "#"
}
}
}else{
if word.hasPrefix("#"){
word.append(text)
}
}
}
//For for applying attributes to hashtag
let linkAttributes: [NSAttributedString.Key : Any] = [NSAttributedString.Key.foregroundColor : UIColor(named: "Primary Color")!]
self.linkTextAttributes = linkAttributes
self.attributedText = attributedString
}
}
and then call it like this:
postTextView.insertTextWithHashtags(text: "#Hello#Hey #Space")

Is there joinWithSeparator for attributed strings

Array of strings can be joined together with specific separator using joinWithSeparator method.
let st = [ "apple", "pie", "potato" ]
st.joinWithSeparator(", ")
As a result we will have "apple, pie, potato".
What if I have attributed strings inside my array? Is there easy way to combine them into one big attributed string?
Swift 5:
import Foundation
extension Sequence where Iterator.Element == NSAttributedString {
func joined(with separator: NSAttributedString) -> NSAttributedString {
return self.reduce(NSMutableAttributedString()) {
(r, e) in
if r.length > 0 {
r.append(separator)
}
r.append(e)
return r
}
}
func joined(with separator: String = "") -> NSAttributedString {
return self.joined(with: NSAttributedString(string: separator))
}
}
Swift 4:
import Foundation
extension SequenceType where Generator.Element: NSAttributedString {
func joinWithSeparator(separator: NSAttributedString) -> NSAttributedString {
var isFirst = true
return self.reduce(NSMutableAttributedString()) {
(r, e) in
if isFirst {
isFirst = false
} else {
r.appendAttributedString(separator)
}
r.appendAttributedString(e)
return r
}
}
func joinWithSeparator(separator: String) -> NSAttributedString {
return joinWithSeparator(NSAttributedString(string: separator))
}
}
Updated answer for Swift 4, together with some basic documentation, using Sequence:
extension Sequence where Iterator.Element: NSAttributedString {
/// Returns a new attributed string by concatenating the elements of the sequence, adding the given separator between each element.
/// - parameters:
/// - separator: A string to insert between each of the elements in this sequence. The default separator is an empty string.
func joined(separator: NSAttributedString = NSAttributedString(string: "")) -> NSAttributedString {
var isFirst = true
return self.reduce(NSMutableAttributedString()) {
(r, e) in
if isFirst {
isFirst = false
} else {
r.append(separator)
}
r.append(e)
return r
}
}
/// Returns a new attributed string by concatenating the elements of the sequence, adding the given separator between each element.
/// - parameters:
/// - separator: A string to insert between each of the elements in this sequence. The default separator is an empty string.
func joined(separator: String = "") -> NSAttributedString {
return joined(separator: NSAttributedString(string: separator))
}
}
For Swift 3.0, Sequence type wasn't supported the I switch to Array. I also change the method name to use the swift 3.0 style
extension Array where Element: NSAttributedString {
func joined(separator: NSAttributedString) -> NSAttributedString {
var isFirst = true
return self.reduce(NSMutableAttributedString()) {
(r, e) in
if isFirst {
isFirst = false
} else {
r.append(separator)
}
r.append(e)
return r
}
}
func joined(separator: String) -> NSAttributedString {
return joined(separator: NSAttributedString(string: separator))
}
}
Took this solution from SwifterSwift, it's similar to the other ones:
public extension Array where Element: NSAttributedString {
func joined(separator: NSAttributedString) -> NSAttributedString {
guard let firstElement = first else { return NSMutableAttributedString(string: "") }
return dropFirst().reduce(into: NSMutableAttributedString(attributedString: firstElement)) { result, element in
result.append(separator)
result.append(element)
}
}
func joined(separator: String) -> NSAttributedString {
guard let firstElement = first else { return NSMutableAttributedString(string: "") }
let attributedStringSeparator = NSAttributedString(string: separator)
return dropFirst().reduce(into: NSMutableAttributedString(attributedString: firstElement)) { result, element in
result.append(attributedStringSeparator)
result.append(element)
}
}
}