Find index of Nth instance of substring in string in Swift - ios

My Swift app involves searching through text in a UITextView. The user can search for a certain substring within that text view, then jump to any instance of that string in the text view (say, the third instance). I need to find out the integer value of which character they are on.
For example:
Example 1: The user searches for "hello" and the text view reads "hey hi hello, hey hi hello", then the user presses down arrow to view second instance. I need to know the integer value of the first h in the second hello (i.e. which # character that h in hello is within the text view). The integer value should be 22.
Example 2: The user searches for "abc" while the text view reads "abcd" and they are looking for the first instance of abc, so the integer value should be 1 (which is the integer value of that a since it's the first character of the instance they're searching for).
How can I get the index of the character the user is searching for?

Xcode 11 • Swift 5 or later
let sentence = "hey hi hello, hey hi hello"
let query = "hello"
var searchRange = sentence.startIndex..<sentence.endIndex
var indices: [String.Index] = []
while let range = sentence.range(of: query, options: .caseInsensitive, range: searchRange) {
searchRange = range.upperBound..<searchRange.upperBound
indices.append(range.lowerBound)
}
print(indices) // "[7, 21]\n"

Another approach is NSRegularExpression which is designed to easily iterate through matches in an string. And if you use the .ignoreMetacharacters option, it will not apply any sophisticated wildcard/regex logic, but will just look for the string in question. So consider:
let string = "hey hi hello, hey hi hello" // string to search within
let searchString = "hello" // string to search for
let matchToFind = 2 // grab the second occurrence
let regex = try! NSRegularExpression(pattern: searchString, options: [.caseInsensitive, .ignoreMetacharacters])
You could use enumerateMatches:
var count = 0
let range = NSRange(string.startIndex ..< string.endIndex, in: string)
regex.enumerateMatches(in: string, range: range) { result, _, stop in
count += 1
if count == matchToFind {
print(result!.range.location)
stop.pointee = true
}
}
Or you can just find all of them with matches(in:range:) and then grab the n'th one:
let matches = regex.matches(in: string, range: range)
if matches.count >= matchToFind {
print(matches[matchToFind - 1].range.location)
}
Obviously, if you were so inclined, you could omit the .ignoreMetacharacters option and allow the user to perform regex searches, too (e.g. wildcards, whole word searches, start of word, etc.).
For Swift 2, see previous revision of this answer.

Related

Swift: Get an index of beginning and ending character of a word in a String

A string:
"jim#domain.com, bill#domain.com, chad#domain.com, tom#domain.com"
Through gesture recognizer, I am able to get the character the user tapped on (happy to provide code, but don't see the relevance at this point).
Let's say the User tapped on o in "chad#domain.com" and the character index is 39
Given 39 the index of o, I would like to get the string start index of c where "chad#domain.com" begins, and an end index for m from "com" where "chad#domain.com" ends.
In another words, given an index of a character in a String, I need to get the index on the left and right right before we encounter a space in a String on the left and a comma on the right.
Tried, but this only provides the last word in the String:
if let range = text.range(of: " ", options: .backwards) {
let suffix = String(text.suffix(from: range.upperBound))
print(suffix) // tom#domain.com
}
I am not sure where to go from here?
You can call range(of:) on two slices of the given string:
text[..<index] is the text preceding the given character position,
and text[index...] is the text starting at the given position.
Example:
let text = "jim#domain.com, bill#domain.com, chad#domain.com, tom#domain.com"
let index = text.index(text.startIndex, offsetBy: 39)
// Search the space before the given position:
let start = text[..<index].range(of: " ", options: .backwards)?.upperBound ?? text.startIndex
// Search the comma after the given position:
let end = text[index...].range(of: ",")?.lowerBound ?? text.endIndex
print(text[start..<end]) // chad#domain.com
Both range(of:) calls return nil if no space (or comma) has
been found. In that case the nil-coalescing operator ?? is used
to get the start (or end) index instead.
(Note that this works because Substrings share a common index
with their originating string.)
An alternative approach is to use a "data detector",
so that the URL detection does not depend on certain separators.
Example (compare How to detect a URL in a String using NSDataDetector):
let text = "jim#domain.com, bill#domain.com, chad#domain.com, tom#domain.com"
let index = text.index(text.startIndex, offsetBy: 39)
let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
let matches = detector.matches(in: text, range: NSRange(location: 0, length: text.utf16.count))
for match in matches {
if let range = Range(match.range, in: text), range.contains(index) {
print(text[range])
}
}
Different approach:
You have the string and the Int index
let string = "jim#domain.com, bill#domain.com, chad#domain.com, tom#domain.com"
let characterIndex = 39
Get the String.Index from the Int
let stringIndex = string.index(string.startIndex, offsetBy: characterIndex)
Convert the string into an array of addresses
let addresses = string.components(separatedBy: ", ")
Map the addresses to their ranges (Range<String.Index>) in the string
let ranges = addresses.map{string.range(of: $0)!}
Get the (Int) index of the range which contains stringIndex
if let index = ranges.index(where: {$0.contains(stringIndex)}) {
Get the corresponding address
let address = addresses[index] }
One approach could be to split the original string on the “,” and then using simple math to find in what element of the array the given position (39) exist and from there get the right string or indexes for the previous space and next comma depending on what your end goal is.

Swift - Whitespace count in a string [duplicate]

This question already has answers here:
Find number of spaces in a string in Swift
(3 answers)
Closed 5 years ago.
How do you get the count of the empty space within text?
It would be more helpful to me if explained with an example.
You can either use componentsSeparatedBy or filter function like
let array = string.components(separatedBy:" ")
let spaceCount = array.count - 1
or
let spaceCount = string.filter{$0 == " "}.count
If you want to consider other whitespace characters (not only space) use regular expression:
let string = "How to get count of the empty space in text,Like how we get character count like wise i need empty space count in a text, It would be more helpful if explained with an example."
let regex = try! NSRegularExpression(pattern: "\\s")
let numberOfWhitespaceCharacters = regex.numberOfMatches(in: string, range: NSRange(location: 0, length: string.utf16.count))
Regular expression \\s considers tab, cr, lf and space
Easiest way is to do something like this:
let emptySpacesCount = yourString.characters.filter { $0 == " " }.count
What this does is it takes characters from your string, filter out everything that is not space and then counts number of remaining elements.
You can try this example;
let string = "Whitespace count in a string swift"
let spaceCount = string.characters.filter{$0 == " "}.count

How to get range of specific substring even if a duplicate

I want to detect the words that begin with a #, and return their specific ranges. Initially I tried using the following code:
for word in words {
if word.hasPrefix("#") {
let matchRange = theSentence.range(of: word)
//Do stuff with this word
}
}
This works fine, except if you have a duplicate hashtag it will return the range of the first occurrence of the hashtag. This is because of the nature of the range(_:) function.
Say I have the following string:
"The range of #hashtag should be different to this #hashtag"
This will return (13, 8) for both hashtags, when really it should return (13, 8) as well as (50, 8). How can this be fixed? Please note that emojis should be able to be detected in the hashtag too.
EDIT
If you want to know how to do this with emojis to, go here
Create regex for that and use it with the NSRegularExpression and find the matches range.
var str = "The range of #hashtag should be different to this #hashtag"
let regex = try NSRegularExpression(pattern: "(#[A-Za-z0-9]*)", options: [])
let matches = regex.matchesInString(str, options:[], range:NSMakeRange(0, str.characters.count))
for match in matches {
print("match = \(match.range)")
}
Why don't you separate your word in chunks where each chunk starts with #. Then you can know how many times your word with # appears in sentence.
Edit: I think that regex answer is the best way for this but this is an other approach for same solution.
var hastagWords = [""]
for word in words {
if word.hasPrefix("#") {
// Collect all words which begin with # in an array
hastagWords.append(word)
}
}
// Create a copy of original word since we will change it
var mutatedWord = word.copy() as! String
for hashtagWord in hastagWords {
let range = mutatedWord.range(of: hashtagWord)
if let aRange = range {
// If range is OK then remove the word from original word and go to an other range
mutatedWord = mutatedWord.replacingCharacters(in: aRange, with: "")
}
}

Return range with first and last character in string

I have a string: "Hey #username that's funny". For a given string, how can I search the string to return all ranges of string with first character # and last character to get the username?
I suppose I can get all indexes of # and for each, get the substringToIndex of the next space character, but wondering if there's an easier way.
If your username can contain only letters and numbers, you can use regular expression for that:
let s = "Hey #username123 that's funny"
if let r = s.rangeOfString("#\\w+", options: NSStringCompareOptions.RegularExpressionSearch) {
let name = s.substringWithRange(r) // #username123"
}
#Vladimir's answer is correct, but if you're trying to find multiple occurrences of "username", this should also work:
let s = "Hey #username123 that's funny"
let ranges: [NSRange]
do {
// Create the regular expression.
let regex = try NSRegularExpression(pattern: "#\\w+", options: [])
// Use the regular expression to get an array of NSTextCheckingResult.
// Use map to extract the range from each result.
ranges = regex.matchesInString(s, options: [], range: NSMakeRange(0, s.characters.count)).map {$0.range}
}
catch {
// There was a problem creating the regular expression
ranges = []
}
for range in ranges {
print((s as NSString).substringWithRange(range))
}

Using character delimiters to find and highlight text in Swift

I previously developed an android app that served as a reference guide to users. It used a sqlite database to store the information. The database stores UTF-8 text without formatting (i.e. bold or underlined)
To highlight what sections of text required formatting I enclosed them using delimiter tokens specifically $$ as this does not appear in the database as information. Before displaying the text to the user I wrote a method to find these delimiters and add formatting to the text contained within them and delete the delimiters. so $$foo$$ became foo.
My java code for this is as follows:
private static CharSequence boldUnderlineText(CharSequence text, String token) {
int tokenLen = token.length();
int start = text.toString().indexOf(token) + tokenLen;
int end = text.toString().indexOf(token, start);
while (start > -1 && end > -1)
{
SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder(text);
//add the formatting required
spannableStringBuilder.setSpan(new UnderlineSpan(), start, end, 0);
spannableStringBuilder.setSpan(new StyleSpan(Typeface.BOLD), start, end, 0);
// Delete the tokens before and after the span
spannableStringBuilder.delete(end, end + tokenLen);
spannableStringBuilder.delete(start - tokenLen, start);
text = spannableStringBuilder;
start = text.toString().indexOf(token, end - tokenLen - tokenLen) + tokenLen;
end = text.toString().indexOf(token, start);
}
return text;
}
I have recreated my app in Swift for iOS and it is complete apart from showing the correct formatting. It appears that Swift treats strings differently from other languages.
So far I have tried using both NSString and String types for my original unformatted paragraph and get manage to get the range, start and end index of the first delimiter:
func applyFormatting2(noFormatString: NSString, delimiter: String){
let paragraphLength: Int = noFormatString.length //length of paragraph
let tokenLength: Int = delimiter.characters.count //length of token
let rangeOfToken = noFormatString.rangeOfString(formatToken) //range of the first delimiter
let startOfToken = rangeOfToken.toRange()?.startIndex //start index of first delimiter
let endOfToken = rangeOfToken.toRange()?.endIndex //end index of first delimiter
var startOfFormatting = endOfToken //where to start the edit (end index of first delimiter)
}
OR
func applyFormatting(noFormatString: String, token: String){
let paragraphLength: Int = noFormatString.characters.count
let tokenLength: Int = token.characters.count //length of the $$ Token (2)
let rangeOfToken = noFormatString.rangeOfString(formatToken) //The range of the first instance of $$ in the no format string
let startOfToken = rangeOfToken?.startIndex //the starting index of the found range for the found instance of $$
let endOfToken = rangeOfToken?.endIndex //the starting index of the found range for the found instance of $$
var startOfFormatting = endOfToken
}
I appreciate this code is verbose and has pointless variables but it helps me think though my code when I'm working out a problem.
I am currently struggling to workout how to find the second/closing delimiter. I want to search through the string from a specific index as I did in Java using the line
int end = text.toString().indexOf(token, start);
however I cannot work out how to do this using ranges.
Can anyone help me out with either how to correctly identify where the closing delimiter is or how to complete the code block to format all the required text?
Thanks
Aldo
How about using NSRegularExpression?
public extension NSMutableAttributedString {
func addAttributes(attrs: [String : AnyObject], delimiter: String) throws {
let escaped = NSRegularExpression.escapedPatternForString(delimiter)
let regex = try NSRegularExpression(pattern:"\(escaped)(.*?)\(escaped)", options: [])
var offset = 0
regex.enumerateMatchesInString(string, options: [], range: NSRange(location: 0, length: string.characters.count)) { (result, flags, stop) -> Void in
guard let result = result else {
return
}
let range = NSRange(location: result.range.location + offset, length: result.range.length)
self.addAttributes(attrs, range: range)
let replacement = regex.replacementStringForResult(result, inString: self.string, offset: offset, template: "$1")
self.replaceCharactersInRange(range, withString: replacement)
offset -= (2 * delimiter.characters.count)
}
}
}
Here is how you call it.
let string = NSMutableAttributedString(string:"Here is some $$bold$$ text that should be $$emphasized$$")
let attributes = [NSFontAttributeName: UIFont.boldSystemFontOfSize(15)]
try! string.addAttributes(attributes, delimiter: "$$")
The iOS way of doing this is with NS[Mutable]AttributedStrings. You set dictionaries of attributes on text ranges. These attributes include font weights, sizes, colors, line spacing, etc.

Resources