How to combine text weights in SwiftUI

Combining multiple text weights in SwiftUI might not look straight forward at first. If you’re used to using UIKit you were probably looking into support for NSAttributedString in which you could apply different text styles for certain ranges.

SwiftUI makes it possible to combine different text styles with the built-in plus (+) operator implementation. Multiple text views are combined into one and wrap nicely if used correctly.

Approaches you might have considered

Before we dive into the solution that worked best in my case I’d like to go over a few often suggested solutions on forums like StackOverflow.

HStack with wrapping

You could see combine multiple weights in a single text view as displaying multiple keywords in a grid kind of view. Searching for a solution might bring you at this page which tells you to use a GeometryReader combined with custom alignment guides to mimic text wrapping.

Although the solution might work in the end, the code is complex and sizing might not always work as expected.

Using UIViewRepresentable

The solution closest to using an NSAttributedString is using a UIViewRepresentable that bridges an attributed string implementation to SwiftUI. This might be your quickest solution if you’re migrating code from UIKit to SwiftUI but it feels a lot less like native SwiftUI code.

ForEach to combine multiple Text views

The solutions closest to what I’ve found to work best is using a ForEach to iterate over the words of a sentence to combine multiple Text views in an HStack.

The downside of this approach is that we end up with unrelated individual text views that don’t wrap nicely together. The views don’t know each other which takes away the support for multiline text. It might work if you have short sentences that stay on one line but it easily becomes limited when you’d like to add weights to multiline text content.

Adding support for multiple weights in SwiftUI Text views

Combining multiple weights might be useful to emphasize words in a sentence. In this example, we would like to display certain words in bold with the following result:

SwiftLee – A weekly blog about Swift, iOS and Xcode Tips and Tricks

To work efficiently we need a way to define the ranges of text that need to be styled. We also want to have a result of a single Text element that takes care of wrapping words on multiple lines.

The final code looks as follows:

@main
struct RichTextApp: App {
    var body: some Scene {
        WindowGroup {
            RichText("SwiftLee - A *weekly blog* about Swift, iOS and Xcode *Tips and Tricks*")
                .padding()
                .multilineTextAlignment(.center)
        }
    }
}

As you can see we’ve marked “weekly blog” and “Tips and Tricks” with asterisks to indicate these words should be converted into bold text elements. To do this, we make use of a regex implementation that allows us to use Markdown kind of styling.

Let’s go through code step by step.

Creating a custom RichText view

We start by creating a custom view named RichText. The view comes with a String initializer and defines an Element struct which we will use to define the different elements of our input sentence.

import SwiftUI

struct RichText: View {

    struct Element: Identifiable {
        let id = UUID()
        let content: String
        let isBold: Bool

        init(content: String, isBold: Bool) {
            var content = content.trimmingCharacters(in: .whitespacesAndNewlines)

            if isBold {
                content = content.replacingOccurrences(of: "*", with: "")
            }

            self.content = content
            self.isBold = isBold
        }
    }

    let elements: [Element]

    init(_ content: String) {
        elements = content.parseRichTextElements()
    }

    var body: some View {
        ...
    }
}

The elements are parsed using the parseRichTextElements() method. This method splits the sentence up using asterisks and marks elements that need to be displayed using a bold font weight:

extension String {

    /// Parses the input text and returns a collection of rich text elements.
    /// Currently supports asterisks only. E.g. "Save *everything* that *inspires* your ideas".
    ///
    /// - Returns: A collection of rich text elements.
    func parseRichTextElements() -> [RichText.Element] {
        let regex = try! NSRegularExpression(pattern: "\\*{1}(.*?)\\*{1}")
        let range = NSRange(location: 0, length: count)

        /// Find all the ranges that match the regex *CONTENT*.
        let matches: [NSTextCheckingResult] = regex.matches(in: self, options: [], range: range)
        let matchingRanges = matches.compactMap { Range<Int>($0.range) }

        var elements: [RichText.Element] = []

        // Add the first range which might be the complete content if no match was found.
        // This is the range up until the lowerbound of the first match.
        let firstRange = 0..<(matchingRanges.count == 0 ? count : matchingRanges[0].lowerBound)

        self[firstRange].components(separatedBy: " ").forEach { (word) in
            guard !word.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
            elements.append(RichText.Element(content: String(word), isBold: false))
        }

        // Create elements for the remaining words and ranges.
        for (index, matchingRange) in matchingRanges.enumerated() {
            let isLast = matchingRange == matchingRanges.last

            // Add an element for the matching range which should be bold.
            let matchContent = self[matchingRange]
            elements.append(RichText.Element(content: matchContent, isBold: true))

            // Add an element for the text in-between the current match and the next match.
            let endLocation = isLast ? count : matchingRanges[index + 1].lowerBound
            let range = matchingRange.upperBound..<endLocation
            self[range].components(separatedBy: " ").forEach { (word) in
                guard !word.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
                elements.append(RichText.Element(content: String(word), isBold: false))
            }
        }

        return elements
    }

    /// - Returns: A string subscript based on the given range.
    subscript(range: Range<Int>) -> String {
        let startIndex = index(self.startIndex, offsetBy: range.lowerBound)
        let endIndex = index(self.startIndex, offsetBy: range.upperBound)
        return String(self[startIndex..<endIndex])
    }
}

This is a lot of code but reading through it using the inline comments should give you a sense of what it’s doing. The essential part of this blog post is to give you an idea of how you can benefit from combining multiple Text elements into one. This could be a starting point for writing your own Markdown parsing text element, for example.

Finally, we need to create a body for our custom view and parse all the elements into one Text element:

var body: some View {
    var content = text(for: elements.first!)
    elements.dropFirst().forEach { (element) in
        content = content + self.text(for: element)
    }
    return content
}

private func text(for element: Element) -> Text {
    let postfix = shouldAddSpace(for: element) ? " " : ""
    if element.isBold {
        return Text(element.content + postfix)
            .fontWeight(.bold)
    } else {
        return Text(element.content + postfix)
    }
}

private func shouldAddSpace(for element: Element) -> Bool {
    return element.id != elements.last?.id
}

The content of our body method is the most important part. If we had used a ForEach instead, we would end up with multiple independent Text elements that don’t wrap nicely together over multiple lines.

Taking a closer look at this piece of code:

var content = text(for: elements.first!)
elements.dropFirst().forEach { (element) in
    content = content + self.text(for: element)
}
return content

We start by taking the first element as our starting Text element. We go over the elements collection and we make use of the + operator which basically combines the existing Text view with a new Text view for the given element.

Finally, we return the joined version of our sentence containing our marked words in bold:

Creating a SwiftUI Text View with multiple weights
Creating a SwiftUI Text View with multiple weights

Conclusion

SwiftUI comes with challenges and might make it tempting to use custom solutions using a UIViewRepresentable implementation. However, with the right knowledge it’s not always needed as we can make use of the advanced API that comes with SwiftUI. Combining multiple text views into one allows us to show multiple weights as a combined, nicely wrapping, text view.

If you like to improve your SwiftUI knowledge, even more, check out the SwiftUI category page. Feel free to contact me or tweet to me on Twitter if you have any additional tips or feedback.

Thanks!