Decorating string fragments using delimiters

A quick throwback to Objective-C and Budapest

Bruno Muniz
Swift2Go

--

Back in my days at Supercharge (❤️) I had the chance to work with Gergő Németh on a really cool project and I remember a specific task we received to decorate fragments of a string with different attributes — and this is what we’ll look into in this article.

Photo by Ervin Lukacs on Unsplash

Considering the string “The prettiest city in Europe is Budapest”, let’s suppose we would like that the word “Europe” were displayed on a bold font and with blue color, while the word “Budapest” were displayed also on a bold font but with green color.

Isn’t it what NSAttributedString should do? More or less…

NSString *string = @"The prettiest city in Europe is Budapest";

// Create a UIFont object.
UIFont *font = [UIFont fontWithName:@"Arial-Bold" size:14.0];

// Create a UIColor object for blue.
UIColor *color = [UIColor blueColor];

// Create a dictionary of attributes.
NSDictionary *attrsDictionary = @{
NSFontAttributeName: font,
NSForegroundColorAttributeName: color
};

// Create an attributed string with the attributes.
NSAttributedString *attrString = [[NSAttributedString alloc] initWithString:string attributes:attrsDictionary];

The first problem we quickly notice is that NSAttributedString by itself doesn’t allow us to specify on what words we want to apply each attributes, instead it simply applies all the attributes to the entire string. Thankfully, it can easily be solved by using its subclass NSMutableAttributedString.

NSString *string = @"The prettiest city in Europe is Budapest";

// Create UIFont object.
UIFont *boldFont = [UIFont fontWithName:@"Arial-BoldMT" size:14.0];

// Create UIColor objects.
UIColor *blueColor = [UIColor blueColor];
UIColor *greenColor = [UIColor greenColor];

// Create dictionaries of attributes for each word.
NSDictionary *europeAttrs = @{
NSFontAttributeName: boldFont,
NSForegroundColorAttributeName: blueColor
};
NSDictionary *budapestAttrs = @{
NSFontAttributeName: boldFont,
NSForegroundColorAttributeName: greenColor
};

// Create a mutable attributed string.
NSMutableAttributedString *attrString = [[NSMutableAttributedString alloc] initWithString:string];

// Create NSRange objects
NSRange europeRange = NSMakeRange(22, 6);
NSRange budapestRange = NSMakeRange(32, 8);

// Apply attributes to ranges
[attrString setAttributes:europeAttrs range:europeRange];
[attrString setAttributes:budapestAttrs range:budapestRange];

At this point we got where we wanted, but can we make our life easier when trying to identify the ranges in which we want to apply the attributes?

// Find the range of the words and apply the attributes.
NSRange europeRange = [string rangeOfString:@"Europe"];
if (europeRange.location != NSNotFound) {
[attrString setAttributes:europeAttrs range:europeRange];
}

NSRange budapestRange = [string rangeOfString:@"Budapest"];
if (budapestRange.location != NSNotFound) {
[attrString setAttributes:budapestAttrs range:budapestRange];
}

Using rangeOfString: is a great way to simplify the task of finding the ranges we are interested, but Gergő came up with a solution that was quite ingenious. He introduced me to an open-source code he wrote himself that searches for consecutive occurrences of characters in a string (which defines a range) and then apply the specified attributes to those ranges.

@implementation NSMutableAttributedString (R87Formatter)

/**
Sets the given attributes between occurrences of specified characters
within the string.

@param attributes The dictionary of attributes to be set.
@param characters The string containing characters which act as markers
between which the attributes will be applied.
*/
- (void)r87_setAttributes:(NSDictionary *)attributes betweenCharacters:(NSString *)characters {
NSArray *ranges = [self r87_findRangesWithCharaters:characters];

// Iterate over each range found.
[ranges enumerateObjectsUsingBlock: ^(id obj, NSUInteger idx, BOOL *stop) {
// Check if the current object is of the type NSValue.
if ([obj isKindOfClass:[NSValue class]]) {
NSValue *thisValue = (NSValue *)obj;
NSRange range = thisValue.rangeValue; // Extract the range from the NSValue.

// Set the attributes for the string within the extracted range.
[self setAttributes:attributes range:range];
}
}];
}

/**
Searches for consecutive occurrences of characters in a given string
and returns an array of ranges where these characters appear.

@param charactersToFind The string containing characters to search for.
@return An array of NSValue objects, each containing an NSRange,
where the specified characters appear in sequence.
*/
- (NSArray *)r87_findRangesWithCharaters:(NSString *)charactersToFind {

// Create a mutable array to hold the resulting ranges.
NSMutableArray *resultArray = [[NSMutableArray alloc] init];

// Boolean flag to track whether we're inside a range of target characters.
BOOL insideTheRange = NO;

// Variable to hold the starting location of a range.
NSUInteger startingRangeLocation = 0;

// Loop while there are occurrences of the target characters in the mutable string.
while ([self.mutableString rangeOfString:charactersToFind].location != NSNotFound) {
// Find the range of the target characters.
NSRange charactersLocation = [self.mutableString rangeOfString:charactersToFind];

// If we're not inside a range, this marks the beginning of a new range.
if (!insideTheRange) {
startingRangeLocation = charactersLocation.location;
insideTheRange = YES;

// Remove the characters that were found to continue the search.
[self.mutableString deleteCharactersInRange:charactersLocation];
} else {
// If we are inside a range, this marks the end of the current range.
// Create a range from the start location to the current location.
NSRange range = NSMakeRange(startingRangeLocation, charactersLocation.location - startingRangeLocation);
insideTheRange = NO;

// Add the found range to the result array.
[resultArray addObject:[NSValue valueWithRange:range]];

// Remove the characters that were found to continue the search.
[self.mutableString deleteCharactersInRange:charactersLocation];
}
}

// Return a non-mutable copy of the result array.
return [resultArray copy];
}

@end

All we had to do now was to specify delimiters and what attributes we would like to apply to the ranges delimited by those symbols — then his code would scavenge the ranges and apply the attributes.

NSString *string = @"The prettiest city in *Europe* is $Budapest$";

UIFont *boldFont = [UIFont fontWithName:@"Arial-BoldMT" size:14.0];

UIColor *blueColor = [UIColor blueColor];
UIColor *greenColor = [UIColor greenColor];

NSDictionary *europeAttrs = @{
NSFontAttributeName: boldFont,
NSForegroundColorAttributeName: blueColor
};
NSDictionary *budapestAttrs = @{
NSFontAttributeName: boldFont,
NSForegroundColorAttributeName: greenColor
};

NSMutableAttributedString *attrString = [[NSMutableAttributedString alloc] initWithString:string];

[attrString r87_setAttributes:europeAttrs betweenCharacters:@"*"];
[attrString r87_setAttributes:budapestAttrs betweenCharacters:@"$"];

Fast-forward to 2023

There’s a couple of things we could do to bring that solution up to today’s standards, but the most obvious one is to re-write it using Swift:

extension NSMutableAttributedString {

/// Finds ranges between pairs of specified characters.
/// - Parameter charactersToFind: The characters marking range boundaries.
/// - Returns: Array of NSRange objects.
func r87_findRangesWithCharaters(charactersToFind: String) -> [NSRange] {
// Array to store the resulting ranges.
var resultArray: [NSRange] = []

// Flag to track whether we're inside a range (between a pair of specified characters).
var insideTheRange = false

// Starting location for each range.
var startingRangeLocation = 0

// While there are still occurrences of the specified characters in the mutable string.
while mutableString.range(of: charactersToFind).location != NSNotFound {
let charactersLocation = mutableString.range(of: charactersToFind)

if !insideTheRange {
// If we're not currently inside a range, set the starting location.
startingRangeLocation = charactersLocation.location
insideTheRange = true
mutableString.deleteCharacters(
in: charactersLocation
)
} else {
// If we're currently inside a range, determine the range's length and store it.
let range = NSMakeRange(
startingRangeLocation,
charactersLocation.location - startingRangeLocation
)
insideTheRange = false
resultArray.append(range)
mutableString.deleteCharacters(
in: charactersLocation
)
}
}

return resultArray
}

/// Sets attributes for portions between pairs of characters.
/// - Parameters:
/// - attributes: Attributes to set.
/// - characters: Characters marking attribute boundaries.
func r87_setAttributes(
attributes: [NSAttributedString.Key: Any],
betweenCharacters characters: String
) {
// Find the ranges between the specified characters.
var ranges = r87_findRangesWithCharaters(charactersToFind: characters)

// Set the specified attributes for each range.
ranges.forEach { setAttributes(attributes, range: $0) }
}
}

I’ll try to implement a couple more ideas in another article, like allowing to escape characters for example, but if you would like to see the original repository from Gergő: https://github.com/reden87/R87AttributedString

Thank you for reading, see you next time.

--

--