58

Say I have an NSMutableAttributedString .

The string has a varied mix of formatting throughout:

Here is an example:

This string is hell to change in iOS, it really sucks.

However, the font per se is not the font you want.

I want to:

for each and every character, change that character to a specific font (say, Avenir)

BUT,

for each and every character, keep the mix of other attributions (bold, italic, colors, etc etc) which was previously in place on that character.

How the hell do you do this?


Note:

if you trivially add an attribute "Avenir" over the whole range: it simply deletes all the other attribute ranges, you lose all formatting. Unfortunately, attributes are not, in fact "additive".

Community
  • 1
  • 1
Fattie
  • 27,874
  • 70
  • 431
  • 719
  • 1
    https://developer.apple.com/reference/foundation/nsmutableattributedstring/1409691-removeattribute to remove the `NSFontAttributeName`. You can also use https://developer.apple.com/reference/foundation/nsattributedstring/1412461-enumerateattribute to enumerate and decide if the font is to be removed or not. – Larme May 01 '17 at 18:04
  • Why don't you simply get the string from your attributedString and create a new attributedString with the desired font http://stackoverflow.com/a/28132610/2303865 – Leo Dabus May 02 '17 at 01:39
  • 1
    You said remove all attributes – Leo Dabus May 02 '17 at 03:02
  • 3
    I see you changed your question, then unupvoted & downvoted my answer to your original question and finished up by writing your own answer and accepting it as the solution. Poor show. – Yasir Nov 02 '17 at 11:38
  • Man, no offense, but your formatting sucks. You should remove some font attributes - pun intended. – LinusGeffarth Jan 07 '18 at 21:17
  • @Fattie, how does manmal's post not answer your question? I had *exactly* your problem and his solution works really well. – LinusGeffarth Jan 07 '18 at 23:41

8 Answers8

103

Since rmaddy's answer did not work for me (f.fontDescriptor.withFace(font.fontName) does not keep traits like bold), here is an updated Swift 4 version that also includes color updating:

extension NSMutableAttributedString {
    func setFontFace(font: UIFont, color: UIColor? = nil) {
        beginEditing()
        self.enumerateAttribute(
            .font, 
            in: NSRange(location: 0, length: self.length)
        ) { (value, range, stop) in

            if let f = value as? UIFont, 
              let newFontDescriptor = f.fontDescriptor
                .withFamily(font.familyName)
                .withSymbolicTraits(f.fontDescriptor.symbolicTraits) {

                let newFont = UIFont(
                    descriptor: newFontDescriptor, 
                    size: font.pointSize
                )
                removeAttribute(.font, range: range)
                addAttribute(.font, value: newFont, range: range)
                if let color = color {
                    removeAttribute(
                        .foregroundColor, 
                        range: range
                    )
                    addAttribute(
                        .foregroundColor, 
                        value: color, 
                        range: range
                    )
                }
            }
        }
        endEditing()
    }
}

Or, if your mix-of-attributes does not include font,
then you don't need to remove old font:

let myFont: UIFont = .systemFont(ofSize: UIFont.systemFontSize);

myAttributedText.addAttributes(
    [NSAttributedString.Key.font: myFont],
    range: NSRange(location: 0, length: myAttributedText.string.count));

Notes

The problem with f.fontDescriptor.withFace(font.fontName) is that it removes symbolic traits like italic, bold or compressed, since it will for some reason override those with default traits of that font face. Why this is so totally eludes me, it might even be an oversight on Apple's part; or it's "not a bug, but a feature", because we get the new font's traits for free.

So what we have to do is create a font descriptor that has the symbolic traits from the original font's font descriptor: .withSymbolicTraits(f.fontDescriptor.symbolicTraits). Props to rmaddy for the initial code on which I iterated.

I've already shipped this in a production app where we parse a HTML string via NSAttributedString.DocumentType.html and then change the font and color via the extension above. No problems so far.

Top-Master
  • 7,611
  • 5
  • 39
  • 71
manmal
  • 3,900
  • 2
  • 30
  • 42
  • This is awesome. Thanks! – LinusGeffarth Jan 07 '18 at 21:49
  • IMHO this should really be the accepted answer, since it actually solves what OP asked for. – LinusGeffarth Jan 07 '18 at 23:26
  • Yeah I know, I was just wondering why you opened a bounty if the answer has already been provided... – LinusGeffarth Jan 08 '18 at 13:39
  • 1
    It's `f.fontDescriptor.withFamily(font.familyName).withSymbolicTraits(f.fontDescriptor.symbolicTraits)` that was different in rmaddy's code. Compare the code in his edit history. – LinusGeffarth Jan 08 '18 at 13:40
  • 1
    @Fattie Oh I was silently logged out for a time and totally missed your comments, sorry! Thanks for the bounty :) In the meantime, I've released the above snippet as part of a production app, so yes it does indeed work. I'll try and elaborate a bit in the post above. – manmal Feb 04 '18 at 10:00
  • @LinusGeffarth Thanks for removing method chaining, I agree that it's not of much use here. – manmal Feb 04 '18 at 10:04
  • 2
    understood @manmal thanks again. yes what a nightmare! in some apps you have to do things that iOS really "doesn't want to do" and this is an example of that. – Fattie Feb 04 '18 at 14:42
  • This is the most perfect approach. Thank you! – fahrulazmi Feb 21 '18 at 06:47
  • 3
    I discovered a minor bug in this implementation. If any part of the string has NO font attribute, then it won't be replaced. So you need to add `} else { addAttribute(.font, value: font, range: range) }` after the inner `if` block. Same for color if you need that. – Steve Landey May 09 '18 at 18:23
  • Thanks @manmal. I'm having a hard time trying to get the bold text to work. Your code just ignores the bold parts of the html. Any idea how i can achieve this? – Shyam Bhat Sep 19 '18 at 17:20
  • @ShyamBhat the code should actually work for bold text. my idea is that perhaps the text in your html is not made bold with `` tags, but with CSS? That would perhaps work with inline CSS (haven't tried this yet), but not with an external CSS file. – manmal Oct 02 '18 at 08:58
  • @manmal nope the code snippet doesn't work with attributes, do you have an idea to fix this problem ? – JAHelia Oct 25 '18 at 06:44
  • @JAHelia sorry i can't check right now - have you tried with inline CSS like `style="font-weight: bold"`? – manmal Oct 30 '18 at 09:41
  • There's also the possibility that the font you are using does not have a bold trait. I noticed that even the system fonts react differently depending on iOS version. – manmal Oct 30 '18 at 09:42
  • @manmal I've tried font-weight:bold but didn't actually worked, but I guess it something relates to the font itself, the font may not have a bold weight – JAHelia Oct 31 '18 at 04:15
  • 1
    @JAHelia if you are using a font that is shipped with ios, you can check here whether there is bold available: http://iosfonts.com – manmal Oct 31 '18 at 08:16
  • Why is the `if let color = color` nested inside the `if let f = value as? UIFont`, when they are modifying different attributes (i.e. `.font` vs. `.foregroundColor`)? Seems more appropriate to hike the color mod outside the `enumerateAttribute()` call so it is always executed. – Andrew Kirna Oct 05 '19 at 19:24
  • 1
    @AndrewKirna The color mod needs `range` as parameter, so it cannot be put outside `enumerateAttribute()`. I also put it inside the other `if let` because I don't want to change the color if the font is not changed. That's a personal preference of course. – manmal Oct 08 '19 at 08:37
  • @manmal, yes, it's personal preference. I'm applying a static color to the full range. It's working for me with `let fullStringRange = NSRange(location: 0, length: length)`, `removeAttribute(.foregroundColor, range: fullStringRange)`, and `addAttribute(.foregroundColor, value: color, range: fullStringRange)` in a `NSMutableAttributedString` extension. – Andrew Kirna Oct 17 '19 at 15:05
  • @AndrewKirna Oh I see what you mean now. Yes your approach also makes sense. – manmal Nov 08 '19 at 19:04
  • Works with dynamic type fonts as well. Nice! – Mark Thormann Jan 29 '20 at 22:07
  • 1
    as the years go by, an answer always worth readin @manmal ! heh! – Fattie Oct 04 '21 at 19:40
  • 1
    @Fattie Hehe yeah! And omg those 4 years flew by fast – manmal Oct 06 '21 at 15:04
11

Here is a much simpler implementation that keeps all attributes in place, including all font attributes except it allows you to change the font face.

Note that this only makes use of the font face (name) of the passed in font. The size is kept from the existing font. If you want to also change all of the existing font sizes to the new size, change f.pointSize to font.pointSize.

extension NSMutableAttributedString {
    func replaceFont(with font: UIFont) {
        beginEditing()
        self.enumerateAttribute(.font, in: NSRange(location: 0, length: self.length)) { (value, range, stop) in
            if let f = value as? UIFont {
                let ufd = f.fontDescriptor.withFamily(font.familyName).withSymbolicTraits(f.fontDescriptor.symbolicTraits)!
                let newFont = UIFont(descriptor: ufd, size: f.pointSize)
                removeAttribute(.font, range: range)
                addAttribute(.font, value: newFont, range: range)
            }
        }
        endEditing()
    }
}

And to use it:

let someMutableAttributedString = ... // some attributed string with some font face you want to change
someMutableAttributedString.replaceFont(with: UIFont.systemFont(ofSize: 12))
rmaddy
  • 314,917
  • 42
  • 532
  • 579
  • 1
    Dude I fear this may not actually work. As someone else also reports below there is a problem w/ bold. It takes an incredibly long time to fully test this stuff, and, unfortunately, me and my merry men have not yet had a chance to fully investigate it, but some quick tests seemed to fail. :O There are so many factors involved it is a real nightmare; it could be our test was messed up. :O – Fattie Jan 07 '18 at 23:27
  • I really could have sworn that I tested this with a bold font but sure enough it wasn't working in a playground with Xcode 9.2. I've update the creating of the font descriptor and it's now working as expected. – rmaddy Jan 07 '18 at 23:45
  • @LinusGeffarth Yes, manmal fixed my answer (and added support for a color). I've updated my answer by fixing that one line of code so it works in all conditions. – rmaddy Jan 07 '18 at 23:48
5

my two cents for OSX/AppKit>

extension NSAttributedString {
// replacing font to all:
func setFont(_ font: NSFont, range: NSRange? = nil)-> NSAttributedString {
    let mas = NSMutableAttributedString(attributedString: self)
    let range = range ?? NSMakeRange(0, self.length)
    mas.addAttributes([.font: font], range: range)
    return NSAttributedString(attributedString: mas)
}


// keeping font, but change size:
func setFont(size: CGFloat, range: NSRange? = nil)-> NSAttributedString {
    let mas = NSMutableAttributedString(attributedString: self)
    let range = range ?? NSMakeRange(0, self.length)
    
    
    mas.enumerateAttribute(.font, in: range) { value, range, stop in
        if let font = value as? NSFont {
            let name = font.fontName
            let newFont = NSFont(name: name, size: size)
            mas.addAttributes([.font: newFont!], range: range)
        }
    }
    return NSAttributedString(attributedString: mas)
    
}
ingconti
  • 10,876
  • 3
  • 61
  • 48
3

Obj-C version of @manmal's answer

@implementation NSMutableAttributedString (Additions)

- (void)setFontFaceWithFont:(UIFont *)font color:(UIColor *)color {
    [self beginEditing];
    [self enumerateAttribute:NSFontAttributeName
                     inRange:NSMakeRange(0, self.length)
                     options:0
                  usingBlock:^(id  _Nullable value, NSRange range, BOOL * _Nonnull stop) {
                      UIFont *oldFont = (UIFont *)value;
                      UIFontDescriptor *newFontDescriptor = [[oldFont.fontDescriptor fontDescriptorWithFamily:font.familyName] fontDescriptorWithSymbolicTraits:oldFont.fontDescriptor.symbolicTraits];
                      UIFont *newFont = [UIFont fontWithDescriptor:newFontDescriptor size:font.pointSize];
                      if (newFont) {
                          [self removeAttribute:NSFontAttributeName range:range];
                          [self addAttribute:NSFontAttributeName value:newFont range:range];
                      }

                      if (color) {
                          [self removeAttribute:NSForegroundColorAttributeName range:range];
                          [self addAttribute:NSForegroundColorAttributeName value:newFont range:range];
                      }
                  }];
    [self endEditing];
}

@end
landonandrey
  • 1,271
  • 1
  • 16
  • 26
1

Important -

rmaddy has invented an entirely new technique for this annoying problem in iOS.

The answer by manmal is the final perfected version.

Purely for the historical record here is roughly how you'd go about doing it the old days...


// carefully convert to "our" font - "re-doing" any other formatting.
// change each section BY HAND.  total PITA.

func fixFontsInAttributedStringForUseInApp() {

    cachedAttributedString?.beginEditing()

    let rangeAll = NSRange(location: 0, length: cachedAttributedString!.length)

    var boldRanges: [NSRange] = []
    var italicRanges: [NSRange] = []

    var boldANDItalicRanges: [NSRange] = [] // WTF right ?!

    cachedAttributedString?.enumerateAttribute(
            NSFontAttributeName,
            in: rangeAll,
            options: .longestEffectiveRangeNotRequired)
                { value, range, stop in

                if let font = value as? UIFont {

                    let bb: Bool = font.fontDescriptor.symbolicTraits.contains(.traitBold)
                    let ii: Bool = font.fontDescriptor.symbolicTraits.contains(.traitItalic)

                    // you have to carefully handle the "both" case.........

                    if bb && ii {

                        boldANDItalicRanges.append(range)
                    }

                    if bb && !ii {

                        boldRanges.append(range)
                    }

                    if ii && !bb {

                        italicRanges.append(range)
                    }
                }
            }

    cachedAttributedString!.setAttributes([NSFontAttributeName: font_f], range: rangeAll)

    for r in boldANDItalicRanges {
        cachedAttributedString!.addAttribute(NSFontAttributeName, value: font_fBOTH, range: r)
    }

    for r in boldRanges {
        cachedAttributedString!.addAttribute(NSFontAttributeName, value: font_fb, range: r)
    }

    for r in italicRanges {
        cachedAttributedString!.addAttribute(NSFontAttributeName, value: font_fi, range: r)
    }

    cachedAttributedString?.endEditing()
}

.


Footnote. Just for clarity on a related point. This sort of thing inevitably starts as a HTML string. Here's a note on how to convert a string that is html to an NSattributedString .... you will end up with nice attribute ranges (italic, bold etc) BUT the fonts will be fonts you don't want.

fileprivate extension String {
    func htmlAttributedString() -> NSAttributedString? {
        guard let data = self.data(using: String.Encoding.utf16, allowLossyConversion: false) else { return nil }
        guard let html = try? NSMutableAttributedString(
            data: data,
            options: [NSDocumentTypeDocumentAttribute: NSHTMLTextDocumentType],
            documentAttributes: nil) else { return nil }
        return html
    }
}  

.

Even that part of the job is non-trivial, it takes some time to process. In practice you have to background it to avoid flicker.

Fattie
  • 27,874
  • 70
  • 431
  • 719
  • 1
    I just saw your footnote.. as I mentioned in the (newly written) notes in my answer, I'm also using the extension to override properties of text loaded from HTML. I'm also backgrounding and even caching it because it's so expensive. In case you need this, here is a gist: https://gist.github.com/manmal/c11ef79e306ffab25251ed911b3b0e80 – manmal Feb 04 '18 at 10:30
  • Definitely a huge PITA! – manmal Feb 04 '18 at 21:54
1

There is a tiny bug in the accepted answer causing the original font size to get lost. To fix this simply replace

font.pointSize

with

f.pointSize

This ensures that e.g. H1 and H2 headings have the correct size.

Thomas
  • 11
  • 1
1

Swift 5

This properly scales the font size, as other answers overwrite the font size, which may differ, like with sub, sup attribute

For iOS replace NSFont->UIFont and NSColor->UIColor

extension NSMutableAttributedString {
    
    func setFont(_ font: NSFont, textColor: NSColor? = nil) {
        
        guard let fontFamilyName = font.familyName else {
            return
        }
        
        beginEditing()
        enumerateAttribute(NSAttributedString.Key.font, in: NSMakeRange(0, length), options: []) { (value, range, stop) in
            
            if let oldFont = value as? NSFont {
                let descriptor = oldFont
                    .fontDescriptor
                    .withFamily(fontFamilyName)
                    .withSymbolicTraits(oldFont.fontDescriptor.symbolicTraits)
                // Default font is always Helvetica 12
                // See: https://developer.apple.com/documentation/foundation/nsattributedstring?language=objc
                let size = font.pointSize * (oldFont.pointSize / 12)
                let newFont = NSFont(descriptor: descriptor, size: size) ?? oldFont
                addAttribute(NSAttributedString.Key.font, value: newFont, range: range)
            }
        }
        if let textColor = textColor {
            addAttributes([NSAttributedString.Key.foregroundColor:textColor], range: NSRange(location: 0, length: length))
        }
        endEditing()
        
    }
    
}

extension NSAttributedString {
    
    func settingFont(_ font: NSFont, textColor: NSColor? = nil) -> NSAttributedString {
        let ms = NSMutableAttributedString(attributedString: self)
        ms.setFont(font, textColor: textColor)
        return ms
    }
    
}
Peter Lapisu
  • 19,915
  • 16
  • 123
  • 179
-3

Would it be valid to let a UITextField do the work?

Like this, given attributedString and newfont:

let textField = UITextField()
textField.attributedText = attributedString
textField.font = newFont
let resultAttributedString = textField.attributedText

Sorry, I was wrong, it keeps the "Character Attributes" like NSForegroundColorAttributeName, e.g. the colour, but not the UIFontDescriptorSymbolicTraits, which describe bold, italic, condensed, etc.

Those belong to the font and not the "Character Attributes". So if you change the font, you are changing the traits as well. Sorry, but my proposed solution does not work. The target font needs to have all traits available as the original font for this to work.

Fattie
  • 27,874
  • 70
  • 431
  • 719
user2782993
  • 183
  • 11
  • I tried it only with one color attribute and it did work, so hope the others work as well, had no time to fully test it. – user2782993 Jan 13 '18 at 20:29
  • Sorry, I was wrong, it keeps the "Character Attributes" like `NSForegroundColorAttributeName`, e.g. the colour, but not the `UIFontDescriptorSymbolicTraits`, which describe bold, italic, condensed, etc. which belong to the font and not the "Character Attributes". So if you change the font, you are changing the traits as well. Sorry, but my proposed solution does not work. The target font needs to have all traits available as the original font for this to work. – user2782993 Jan 14 '18 at 12:02
  • Instead of adding an edit that states the answer is wrong, it would be better to delete the answer (or fix it if you can). – rmaddy May 09 '19 at 16:46