user6631314
user6631314

Reputation: 1958

Bold part of an attributed string using a Swift Extension

I would like to do what would seem like a fairly simple thing that would be trivial in SwiftUI, here on SO, Markdown or HTML: bold a couple words in a string in Swift ideally using an extension for re-use.

For example, bold the word "world" in "Hello world". I would be happy with an extension of any type of String eg String, NSString, NSAttributedString, NSMutableAttributedString and so forth as I can go back and forth. All of the examples I have found--dating typically from a number of years ago--do not compile. For example (with errors commented out):

extension NSMutableAttributedString {
    func bold(_ text: String, font: UIFont = .systemFont(ofSize: 17, weight: .bold)) -> Self {
        let range = self.range(of: text)!
        self.addAttribute(.font, value: font, range: range) //error 'Value of type self has no member range'
        return self
    }
}

public extension String {
        func attributedString(with boldText: String, boldTextStyle: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 17)]) -> NSAttributedString {
            let attributedString = NSMutableAttributedString(string: self)
            let boldTextRange = range(of: boldText)!
            attributedString.addAttributes(boldTextStyle, range: boldTextRange) //Cannot convert value of type 'Range<String.Index>' to expected argument type 'NSRange' (aka '_NSRange')
            return attributedString
        }
}

One more SO answer in Objective-C not using extension that does compile for me either. Throws about 10 errors:

NSString *normalText = @"Hello ";
NSString *boldText = @"world";

NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:normalText];

NSDictionary<NSAttributedString.key, id> *attrs = @{ NSAttributedString.Key.font: [UIFont boldSystemFontOfSize:15] };//throws 5 errors
NSMutableAttributedString *boldString = [[NSMutableAttributedString alloc] initWithString:boldText attributes:attrs];//throws 5 errors

[attributedString appendAttributedString:boldString];

(I have tried many other approaches from old answers and multiple AIs without success.)

How can I get the above to work in modern Swift short of SwiftUI or how to create a string extension to bold a portion of a string?

Upvotes: -1

Views: 473

Answers (3)

user20325868
user20325868

Reputation: 315

What the compiler is telling you is that NSMutableAttributedString has no method named range this is true there is no default implementation of any such method on NSMutableAttributedString there is however a property called mutableString that property returns an NSMutableString that does have a function range see below.

Notice I am returning an AttributedString using an init provided by Apple. The reason I'm doing this is because you are using swiftUI and will need something that views like Text and Label will accept.

extension NSMutableAttributedString {
    func bold(_ text: String, font: UIFont = .systemFont(ofSize: 17, weight: .bold)) -> AttributedString {
        let range = self.mutableString.range(of: text)
        self.addAttribute(.font, value: font, range: range)
        
        return try! AttributedString(self, including: \.uiKit)
    }
}

here

 let boldTextRange = range(of: boldText)!
        attributedString.addAttributes(boldTextStyle, range: boldTextRange)

you're being told that func range defined on String returns a Range<String.Index> and in fact it does as per documentation. The simplest repair for that would be to work in either the new world String.Index or the old world NSRange(idx, length) exclusively. Maybe something like below, but please look at my updated answer for what I consider a better approach.

extension String {
    func setAttributes(for text: String, attributes: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 17)] ) -> AttributedString {

    let container = AttributeContainer.init(attributes)
        var attributedString = AttributedString(self)
       
        guard let range = attributedString.range(of: text) else {fatalError()}
        attributedString[range].mergeAttributes(container)
        
        return attributedString
    }
}

then to use 

            VStack {
            Text("Hello, world")
            Text("Hello, world".setAttributes(for: "world"))
            Text(NSMutableAttributedString(string: "Hello, world").bold("Hello") )
        }

Upvotes: 1

user20325868
user20325868

Reputation: 315

I kind of like this approach..

extension StringProtocol {
    func attributeText(rangeOf text: any StringProtocol, with attributes: [NSAttributedString.Key: Any]) -> any AttributedStringProtocol {
        var attributedString = AttributedString(self)
        if let range = attributedString.range(of: text) {
            attributedString[range].mergeAttributes(AttributeContainer(attributes))
        }
        return attributedString
    }




 func replaceFont(with font: Font, rangeOf text: any StringProtocol) -> any AttributedStringProtocol {
        attributeText(rangeOf: text, with: [
            .font: font
        ])
    }
}

you could still make a call like

attributeText(rangeOf: "Hello", with: [
.foregroundColor : UIColor.blue,
.font : UIFont.boldSystemFont(ofSize: 12)
])

but I like being able to make it specific with little extensions seeing as how NSAttributedString.Key is BROAD

Upvotes: 1

matt
matt

Reputation: 534987

The problem here is that "boldification" is not a thing. Given a font Georgia, you cannot simply boldify it; you have to know that the bold variant is a different font, Georgia-Bold. To find that out, or the equivalent, you have to pass thru UIFontDescriptor. Here's a couple of extensions that might let you find the bold variant of a given font:

extension UIFontDescriptor {
    func boldVariant() -> UIFontDescriptor? {
        var traits = self.symbolicTraits
        traits.insert(.traitBold)
        return self.withSymbolicTraits(traits)
    }
}

extension UIFont {
    func boldVariant() -> UIFont? {
        if let descriptor = self.fontDescriptor.boldVariant() {
            return UIFont(descriptor: descriptor, size: 0)
        }
        return nil
    }
}

Once you do know that, then here's a working extension that lets you apply an attribute container to a substring of an attributed string:

protocol Configurable {}

extension Configurable where Self: Any {
    func configured(with handler: (inout Self) -> Void) -> Self {
        var copy = self
        handler(&copy)
        return copy
    }
}

extension AttributedString: Configurable {}

extension AttributedString {
    func applying(_ attributes: AttributeContainer, to text: any StringProtocol) -> Self {
        guard let range = range(of: text) else { return self }
        return self.configured {
            $0[range].mergeAttributes(attributes)
        }
    }
}

That's more general than what you asked for, but in my opinion, generality is better. You can always add more code to pick out a specific case in point. Anyway, here's an example of how to use it (which is, after all, the whole point):

let font = UIFont(name: "Georgia", size: 12)!
var text = AttributedString(
    "Hello, world!",
    attributes: AttributeContainer.font(font)
)
if let boldifiedFont = font.boldVariant() {
    text = text.applying(
        AttributeContainer.font(boldifiedFont),
        to: "world"
    )
}

That boldifies just the "world" part of "Hello, world!"

Upvotes: 1

Related Questions