18

Usually I build app interface in interface builder. Sometimes design requires to use attributed strings (fonts, colors and etc.). It's easy to configure if string is static.
But if string is dynamic (format with arguments) then there are no ways to configure attributes in interface builder. It requires to write a lot of code.
I am looking for some analogues of [NSString stringWithFormat:] for NSAttributedString. So I will be able to set string format and necessary attributes in interface builder, and then provide necessary arguments in code.

For example:
Let's consider that I need display string with such format: "%d + %d = %d" (all numbers are bold).
I want to configure this format in interface builder. In code I want to provide arguments: 1, 1, 2. App should show "1 + 1 = 2".

Vlad Papko
  • 13,184
  • 4
  • 41
  • 57

5 Answers5

15

Compatible with Swift 4.2

public extension NSAttributedString {
    convenience init(format: NSAttributedString, args: NSAttributedString...) {
        let mutableNSAttributedString = NSMutableAttributedString(attributedString: format)

        args.forEach { (attributedString) in
            let range = NSString(string: mutableNSAttributedString.string).range(of: "%@")
            mutableNSAttributedString.replaceCharacters(in: range, with: attributedString)
        }
        self.init(attributedString: mutableNSAttributedString)
    }
}

Usage:

let content = NSAttributedString(string: "The quick brown %@ jumps over the lazy %@")
let fox = NSAttributedString(string: "fox", attributes: [.font: Fonts.CalibreReact.boldItalic.font(size: 40)])
let dog = NSAttributedString(string: "dog", attributes: [.font: Fonts.CalibreReact.lightItalic.font(size: 11)])
attributedLabel.attributedText = NSAttributedString(format: content, args: fox, dog)

Result:

enter image description here

Leo
  • 219
  • 3
  • 4
  • Just a little reminder that this should not be used for unsanitized arguments (e.g. if `fox == "%@"` the result will be `The quick brown dog jups over the lazy %@`). – cutsoy Nov 30 '19 at 14:56
  • results -> Terminating app due to uncaught exception 'NSRangeException', reason: 'NSMutableRLEArray insertObject:range:: Out of bounds' – Utku Dalmaz Dec 11 '19 at 01:31
  • @UtkuDalmaz you might not have matched the number of arguments provided with the format string. I could improve my answer by adding a fatalError line with a more specific message. – Leo Dec 20 '19 at 11:26
  • 3
    Could also be made safer by adding `if range.location != NSNotFound {` – Patrick Feb 02 '20 at 00:22
  • @Patrick is this working fine after adding if range.location != NSNotFound ? – Nitkarsh Gupta Feb 01 '21 at 14:50
  • @Nitkarsh Gupta I'm sure it would be, give it a go :) – Patrick Feb 16 '21 at 22:38
4

I was looking for good existing solution for this question, but no success.
So I was able to implement it on my own.
That's why I am self-answering the question to share the knowledge with community.

Solution

NSAttributedString+VPAttributedFormat category provides methods for building attributed string based on attributed format and arguments that should satisfy this format.
The most suitable case of using this category is text controls with variable attributed text configured in interface builder.
You need set correct string format to attributed text and configure necessary attributes.
Then you need pass necessary arguments in code by using methods of this category.

  • Format syntax is the same as in [NSString stringWithFormat:] method;
  • Can be used in Objective C and Swift code;
  • Requires iOS 6.0 and later;
  • Integrated with CocoaPods;
  • Covered with unit tests.

Usage

1. Import framework header or module

// Objective C
// By header
#import <VPAttributedFormat/VPAttributedFormat.h>

// By module
@import VPAttributedFormat;

// Swift
import VPAttributedFormat

2. Set correct format and attributes for text control in interface builder
usage

3. Create IBOutlet and link it with text control

// Objective C
@property (nonatomic, weak) IBOutlet UILabel *textLabel;

// Swift
@IBOutlet weak var textLabel: UILabel!

4. Populate format with necessary arguments

// Objective C
NSString *hot = @"Hot";
NSString *cold = @"Cold";
  
self.textLabel.attributedText = [NSAttributedString vp_attributedStringWithAttributedFormat:self.textLabel.attributedText,
                                 hot,
                                 cold];

// Swift
let hot = "Hot"
let cold = "Cold"
var arguments: [CVarArgType] = [hot, cold]
textLabel.attributedText = withVaList(arguments) { pointer in
    NSAttributedString.vp_attributedStringWithAttributedFormat(textLabel.attributedText, arguments: pointer)
}

5. See result
result

Examples

VPAttributedFormatExample is an example project. It provides Basic and Pro format examples.
example

Community
  • 1
  • 1
Vlad Papko
  • 13,184
  • 4
  • 41
  • 57
4

Here's a category I wrote to add the method to NSAttributedString. You'll have to pass in NULL as the last argument to the function however, otherwise it will crash to the va_list restrictions on detecting size. [attributedString stringWithFormat:attrFormat, attrArg1, attrArg2, NULL];

@implementation NSAttributedString(stringWithFormat)

+(NSAttributedString*)stringWithFormat:(NSAttributedString*)format, ...{
    va_list args;
    va_start(args, format);

    NSMutableAttributedString *mutableAttributedString = (NSMutableAttributedString*)[format mutableCopy];
    NSString *mutableString = [mutableAttributedString string];

    while (true) {
        NSAttributedString *arg = va_arg(args, NSAttributedString*);
        if (!arg) {
            break;
        }
        NSRange rangeOfStringToBeReplaced = [mutableString rangeOfString:@"%@"];
        [mutableAttributedString replaceCharactersInRange:rangeOfStringToBeReplaced withAttributedString:arg];
    }

    va_end(args);

    return mutableAttributedString;
}
@end
TheJeff
  • 3,665
  • 34
  • 52
  • This will only work if you keep changing mutableString inside the loop as well. Otherwise all parameters will go into the first placeholder and at the end we have the last parameter on place of the first placeholder. – Valeriy Van Aug 28 '17 at 14:29
3

Here is a Swift 4 extension based on TheJeff's answer (corrected for multiple substitutions). It is restricted to substituting placeholders with NSAttributedString's:

public extension NSAttributedString {
    convenience init(format: NSAttributedString, args: NSAttributedString...) {
        let mutableNSAttributedString = NSMutableAttributedString(attributedString: format)

        var nsRange = NSString(string: mutableNSAttributedString.string).range(of: "%@")
        var param = 0
        while nsRange.location != NSNotFound {
            guard args.count > 0, param < args.count else {
                fatalError("Not enough arguments provided for \(format)")
            }

            mutableNSAttributedString.replaceCharacters(in: nsRange, with: args[param])
            param += 1
            nsRange = NSString(string: mutableNSAttributedString.string).range(of: "%@")
        }

        self.init(attributedString: mutableNSAttributedString)
    }
}
GilroyKilroy
  • 847
  • 10
  • 17
  • Can you improve this without fatalError? – Parth Adroja Mar 20 '19 at 05:21
  • 4
    Well, it is a programming bug if you didn’t match the arguments provided with the format string. I consider programming errors fatal and should never ship. If you want you could assert instead and just return the original string but I leave that up to the reader to decide how they want to program. – GilroyKilroy Mar 21 '19 at 14:36
1

Some of the other answers assume that there is a fixed order of the arguments, which is not necessarily the case when using localized strings returned by NSLocalizedString(). The following code takes this into account. It assumes that the format string contains only %@ format specifiers (e.g. when there is a single argument, or a non-localized string), or a sequence of %1$@, %2$@, %3$@, etc., which are allowed to change their position within the format string and are still mapped to the correct argument:

extension NSMutableAttributedString {
    
    private static let formatRegex = try! NSRegularExpression(pattern: "%(?:(\\d+)\\$)?@")
    
    convenience init(format: String, arguments: [NSAttributedString]) {
        self.init(string: format)
        var i = 0
        var location = 0
        while let match = NSMutableAttributedString.formatRegex.firstMatch(in: string, range: NSRange(location: location, length: length - location)) {
            let index = match.range(at: 1).location == NSNotFound ? i : Int((string as NSString).substring(with: match.range(at: 1)))! - 1
            let argument = arguments[index]
            addAttributes(argument.attributes(at: 0, effectiveRange: nil), range: match.range)
            replaceCharacters(in: match.range, with: argument.string)
            i += 1
            location = match.range.location + argument.length
        }
    }
    
}

Usage:

let string = NSMutableAttributedString(format: "From %1$@ to %2$@.", arguments: [
    NSAttributedString(string: "left", attributes: [.foregroundColor: NSColor.red]),
    NSAttributedString(string: "right", attributes: [.foregroundColor: NSColor.blue])
])

You could make it accept variadic arguments instead of an array if you prefer:

convenience init(format: String, arguments: NSAttributedString...) {
    ...
}
Nickkk
  • 2,261
  • 1
  • 25
  • 34