Attributed Strings with SwiftUI

In the last few weeks, I have been asked frequently about how to work with attributed strings and SwiftUI, so I decided to write an article to share what I know about the topic.

Before we begin, let’s put it right there: SwiftUI is not prepared to handle attributed strings easily. With that out of the way, let’s see the best approaches to fill that void and the limitations or problems we will find along the way.

Attributed Strings

In this article, I am assuming you know what an NSAttributedString is and how to create one. They have been around for a very long time. If you need to know how to build one, the Internet is full of resources. Google’s your friend there.

To simplify the examples in this post, they will all use the same attributed string, defined as a global variable and initialized with the following code:

let myAttributedString: NSMutableAttributedString = {
    
    let a1: [NSAttributedString.Key: Any] = [.foregroundColor: UIColor.systemRed, .kern: 5]
    let s1 = NSMutableAttributedString(string: "Red spaced ", attributes: a1)
    
    let a2: [NSAttributedString.Key: Any] = [.strikethroughStyle: 1, .strikethroughColor: UIColor.systemBlue]
    let s2 = NSAttributedString(string: "strike through", attributes: a2)
    
    let a3: [NSAttributedString.Key: Any] = [.baselineOffset: 10]
    let s3 = NSAttributedString(string: " raised ", attributes: a3)

    let a4: [NSAttributedString.Key: Any] = [.font: UIFont(name: "Papyrus", size: 36.0)!, .foregroundColor: UIColor.green]
    let s4 = NSAttributedString(string: " papyrus font ", attributes: a4)

    let a5: [NSAttributedString.Key: Any] = [.foregroundColor: UIColor.systemBlue, .underlineStyle: 1, .underlineColor: UIColor.systemRed]
    let s5 = NSAttributedString(string: "underlined ", attributes: a5)

    s1.append(s2)
    s1.append(s3)
    s1.append(s4)
    s1.append(s5)
    
    return s1
}()

The Quick Way Out: Ignoring Attributes

Sometimes you may have an attributed string that has no attributes, or you don’t mind losing their effect. If you happen to be so lucky, you can just use its plain string value:

struct ContentView: View {
    var body: some View {
        Text(myAttributedString.string)
            .padding(10)
            .border(Color.black)
    }
}

Alternatively, you can have a Text extension with a custom initializer that does the same. It may seem overkill, but I’d like to introduce this technique here, as we will expand it later.

extension Text {
    init(dumbAttributedString: NSAttributedString) {
        self.init(dumbAttributedString.string)
    }
}

struct ContentView: View {
    var body: some View {
        Text(dumbAttributedString: myAttributedString)
            .padding(10)
            .border(Color.black)
    }
} 

A Limited, but Effective Approach: Text Concatenation

In 2020, SwiftUI added the ability to concatenate Text views. Adding two Text views together results in a new Text view:

struct ContentView: View {
    var body: some View {

        Text("Hello ") + Text("world!")
    }
}

You may also use modifiers on those Text views:

struct ContentView: View {
    var body: some View {

        let t = Text("Hello ").foregroundColor(.red) + Text("world!").fontWeight(.heavy)
        
        return t
            .padding(10)
            .border(Color.black)
    }
}

However, not all modifiers will work, only those that return Text. If a modifier returns some View, you are out of luck. It is worth noting that some modifiers exist in two versions. For example, foregroundColor is a method of the Text view:

extension Text {
    public func foregroundColor(_ color: Color?) -> Text
}

But there is another method with the same name in the View protocol:

extension View {
    @inlinable public func foregroundColor(_ color: Color?) -> some View
}

They both do the same, but if you pay attention to the return type, one returns Text, while the other returns some View. The return type will depend on the view you are modifying. If it is a Text view, it will return Text. Otherwise, it will be some View.

At the time of this writing, the modifiers that can return Text are:

  • baselineOffset()
  • bold()
  • font()
  • fontWeight()
  • foregroundColor()
  • italic()
  • kerning()
  • strikethrough()
  • tracking()
  • underline()

From Attributed String to Concatenated Text

Now that we know how we can concatenate Text views, let’s explore a way of going from an attributed string to a composed Text view. What we want to achieve is a custom Text initializer. It will receive an attributed string and return a Text view made of concatenated pieces, each with the appropriate modifiers that correspond to the attributes found in the attributed string. Using it would look something like this:

struct ContentView: View {
    var body: some View {
        Text(myAttributedString)
            .padding(10)
            .border(Color.black)
    }
}

See the code for the initializer below:

extension Text {
    init(_ astring: NSAttributedString) {
        self.init("")
        
        astring.enumerateAttributes(in: NSRange(location: 0, length: astring.length), options: []) { (attrs, range, _) in
            
            var t = Text(astring.attributedSubstring(from: range).string)

            if let color = attrs[NSAttributedString.Key.foregroundColor] as? UIColor {
                t  = t.foregroundColor(Color(color))
            }

            if let font = attrs[NSAttributedString.Key.font] as? UIFont {
                t  = t.font(.init(font))
            }

            if let kern = attrs[NSAttributedString.Key.kern] as? CGFloat {
                t  = t.kerning(kern)
            }
            
            
            if let striked = attrs[NSAttributedString.Key.strikethroughStyle] as? NSNumber, striked != 0 {
                if let strikeColor = (attrs[NSAttributedString.Key.strikethroughColor] as? UIColor) {
                    t = t.strikethrough(true, color: Color(strikeColor))
                } else {
                    t = t.strikethrough(true)
                }
            }
            
            if let baseline = attrs[NSAttributedString.Key.baselineOffset] as? NSNumber {
                t = t.baselineOffset(CGFloat(baseline.floatValue))
            }
            
            if let underline = attrs[NSAttributedString.Key.underlineStyle] as? NSNumber, underline != 0 {
                if let underlineColor = (attrs[NSAttributedString.Key.underlineColor] as? UIColor) {
                    t = t.underline(true, color: Color(underlineColor))
                } else {
                    t = t.underline(true)
                }
            }
            
            self = self + t
            
        }
    }
}

This is a pretty nice solution. Unfortunately, not all attributes can be handled, because only a few modifiers return Text (the ones from the list above). For example, if the attributed string has a hyperlink, this solution will flat-out ignore it. If you are unfortunate to have an unsupported attribute, you may need to use the next approach.

Fallback to UIKit, with UIViewRepresentable (iOS)

When we exhaust all SwiftUI resources, it may be time to create a UIViewRepresentable. As usual, this has its own set of problems, but at least all attributes are supported. We will start with a simple UIViewRepresentable:

struct ContentView: View {
    var body: some View {
        AttributedText(attributedString: myAttributedString)
            .padding(10)
            .border(Color.black)
    }
}

struct AttributedText: UIViewRepresentable {
    
    let attributedString: NSAttributedString
    
    init(_ attributedString: NSAttributedString) {
        self.attributedString = attributedString
    }
    
    func makeUIView(context: Context) -> UILabel {
        let label = UILabel()
        
        label.lineBreakMode = .byClipping
        label.numberOfLines = 0

        return label
    }
    
    func updateUIView(_ uiView: UILabel, context: Context) {
        uiView.attributedText = attributedString
    }
}

As you can observe, the problem we have with this approach is that the UIViewRepresentable grows to occupy all the available space. Here’s where we need to get creative to make our view follow the layout we need.

When trying to solve this problem, we first need to identify what we want exactly. Should the text wrap? Should it grow to its ideal maximum width? Favor height? Will you honor the attributed string paragraph style, or perhaps ignore it? Once you answer these questions, knowing a little UIKit/AppKit will go a long way.

I’ll propose a possible solution that may help in some scenarios. Of course, your needs may vary. If so, I hope this article provided you with a good starting point.

Using a Binding with sizeThatFits

To prevent the view from growing (or shrinking too much), we can force it to a specific size, using frame(width:height:). The challenge is finding the right values for width and height. The following example creates a Binding to connect a State size variable with the result of calling sizeThatFits on the UILabel.

struct ContentView: View {   
    var body: some View {
        AttributedText(myAttributedString)
            .padding(10)
            .border(Color.black)
    }
}

struct AttributedText: View {
    @State private var size: CGSize = .zero
    let attributedString: NSAttributedString
    
    init(_ attributedString: NSAttributedString) {
        self.attributedString = attributedString
    }
    
    var body: some View {
        AttributedTextRepresentable(attributedString: attributedString, size: $size)
            .frame(width: size.width, height: size.height)
    }
    
    struct AttributedTextRepresentable: UIViewRepresentable {
        
        let attributedString: NSAttributedString
        @Binding var size: CGSize

        func makeUIView(context: Context) -> UILabel {
            let label = UILabel()
            
            label.lineBreakMode = .byClipping
            label.numberOfLines = 0

            return label
        }
        
        func updateUIView(_ uiView: UILabel, context: Context) {
            uiView.attributedText = attributedString
            
            DispatchQueue.main.async {
                size = uiView.sizeThatFits(uiView.superview?.bounds.size ?? .zero)
            }
        }
    }
}

This solution will not work for every scenario, other options may be setting the hugging priority and compression resistance of the UILabel (UILabel.setContentHuggingPriority() and UILabel.setContentCompressionResistancePriority()). The bottom line, you will need to get creative, depending on the results you expect.

Fallback to AppKit, with NSViewRepresentable (macOS)

Following a similar pattern, the same can be achieved with macOS. We will use NSTextView to replace the UILabel view, and instead of sizeThatFit, we will call the NSTextView.textStorage.size() method.

Because we are now using macOS, myAttributedString is created with a slightly modified code. It is the same, but replacing UIColor with NSColor and UIFont with NSFont:

let myAttributedString: NSMutableAttributedString = {
    
    let a1: [NSAttributedString.Key: Any] = [.foregroundColor: NSColor.systemRed, .kern: 5]
    let s1 = NSMutableAttributedString(string: "Red spaced ", attributes: a1)
    
    let a2: [NSAttributedString.Key: Any] = [.strikethroughStyle: 1, .strikethroughColor: NSColor.systemBlue]
    let s2 = NSAttributedString(string: "strike through", attributes: a2)
    
    let a3: [NSAttributedString.Key: Any] = [.baselineOffset: 10]
    let s3 = NSAttributedString(string: " raised ", attributes: a3)

    let a4: [NSAttributedString.Key: Any] = [.font: NSFont(name: "Papyrus", size: 36.0)!, .foregroundColor: NSColor.green]
    let s4 = NSAttributedString(string: " papyrus font ", attributes: a4)

    let a5: [NSAttributedString.Key: Any] = [.foregroundColor: NSColor.systemBlue, .underlineStyle: 1, .underlineColor: NSColor.systemRed]
    let s5 = NSAttributedString(string: "underlined ", attributes: a5)

    s1.append(s2)
    s1.append(s3)
    s1.append(s4)
    s1.append(s5)
    
    return s1
}()

struct ContentView: View {
    var body: some View {
        VStack {
            AttributedText(myAttributedString)
                .padding(10)
                .border(Color.black)

        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

struct AttributedText: View  {
    @State var size: CGSize = .zero
    let attributedString: NSAttributedString
    
    init(_ attributedString: NSAttributedString) {
        self.attributedString = attributedString
    }

    var body: some View {
        AttributedTextRepresentable(attributedString: attributedString, size: $size)
            .frame(width: size.width, height: size.height)
    }
    
    struct AttributedTextRepresentable: NSViewRepresentable {

        let attributedString: NSAttributedString
        @Binding var size: CGSize
        
        func makeNSView(context: Context) -> NSTextView {
            let textView = NSTextView()

            textView.textContainer!.widthTracksTextView = false
            textView.textContainer!.containerSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
            textView.drawsBackground = false

            return textView
        }

        func updateNSView(_ nsView: NSTextView, context: Context) {
            nsView.textStorage?.setAttributedString(attributedString)
            
            DispatchQueue.main.async {
                size = nsView.textStorage!.size()
            }
        }
    }
}

Summary

SwiftUI is far from supporting attributed strings, but with a little effort, we can get at least a little closer. I guess I’ll put it in my wishlist for next year’s additions.

Please feel free to comment below, and follow me on twitter if you would like to be notified when new articles come out. Until next time!

11 thoughts on “Attributed Strings with SwiftUI”

  1. An additional reason to avoid concatenation is localisation. It’s harder to localise text when it’s composed from phrase fragments at run time – in some languages, the word or phrase order may need to change vs the reference language.

    Reply
  2. there’s a typo in the code section of Using a Binding with sizeThatFits

    
        var body: some View {
            AttributedTextRepresentable(attributedString: myAttributedString, size: $size)
                .frame(width: size.width, height: size.height)
        }
    

    should be

    
        var body: some View {
            AttributedTextRepresentable(attributedString: attributedString, size: $size)
                .frame(width: size.width, height: size.height)
        }
    

    Thanks!!

    Reply
  3. Very cool post. Thanks for that!
    I tried out the examples till the last one however in my view, SwiftUI doesn’t break the lines, so I get a long long text made of one line only, and it ignores the frame modifier.
    I guess once I will understand more about SwiftUI I will be able to troubleshoot this, but for now I have to pass! Thanks

    Reply
  4. Just anted to say thank for this. I’d been wrangling a solution with concocting Text() but I’ve put soemnthig together based from this and it work great  👍

    Reply
  5. Instead of using a UILabel to achieve this, i’ve had a good experience using a UITextView. UITextView has a field called “attributedText” that you can assign your NSAttributedText object too. This gets you some extra freebies like link detection (which was my use case) as well as rendering HTML formatting stuff like bold text, italics, etc. But you do lose some of swiftUI text concatenation niceness when doing this.

    Reply
  6. Very cool post. Thanks for that!
    I tried out the examples till the last one however in my view, SwiftUI doesn’t break the lines, so I get a long long text made of one line only, and it ignores the frame modifier.

    Reply
  7. Does the “From Attributed String to Concatenated Text” code actually work for anyone? I find that conditionals like “if let color” or “if let font” are skipped, even when the attribute is clearly present. This would be the best solution for my scenario if I could actually get it to work.

    Reply

Leave a Comment

By continuing to use the site, you agree to the use of cookies. more information

The cookie settings on this website are set to "allow cookies" to give you the best browsing experience possible. If you continue to use this website without changing your cookie settings or you click "Accept" below then you are consenting to this.

Close