How to Make an Attributed String in Swift (Styled Unit Suffix)

How to Make an Attributed String in Swift (Styled Unit Suffix)

Written by

in

Quick answer: A plain String can’t carry styling, so build an NSMutableAttributedString instead: create it with the number, then append a second NSAttributedString for the “g” that has its own font and color. Assign the result to label.attributedText, and rebuild it every time the number changes.

Why this happens

A String in Swift is just characters. It has no idea what font, size, color or baseline it will be drawn with — that information lives on the view that renders it. So there is no way to make part of a String bold and the rest small.

UILabel solves this with a second property: attributedText, which takes an NSAttributedString. An attributed string is a piece of text plus a dictionary of attributes applied to ranges of it. Attributes are keyed by NSAttributedString.Key and the values are whatever type that key expects — a UIFont for .font, a UIColor for .foregroundColor, an NSNumber for .underlineStyle.

The line from the original problem fails for the same reason:

var coffeeAmount: String = calculatedCoffee + attributedText // does not compile

+ needs two String values or two NSAttributedString values. Mixing them is a type error, and even if it worked you would lose the styling the moment the text became a plain String again.

How to fix it

1. Build one mutable attributed string and append the unit

This is the most direct fix and the one that keeps the number and the unit locked together, so the “g” moves with the digits as they grow.

import UIKit

func coffeeText(grams: Int) -> NSAttributedString {
    let number = NSMutableAttributedString(
        string: "\(grams)",
        attributes: [
            .font: UIFont.systemFont(ofSize: 34, weight: .semibold),
            .foregroundColor: UIColor.label
        ]
    )

    let unit = NSAttributedString(
        string: "g",
        attributes: [
            .font: UIFont.systemFont(ofSize: 17, weight: .regular),
            .foregroundColor: UIColor.secondaryLabel,
            .baselineOffset: 8
        ]
    )

    number.append(unit)
    return number
}

Then hand it to the label:

coffeeLabel.attributedText = coffeeText(grams: 18)

Because the unit is appended rather than placed at a fixed position, the label measures the whole thing as one run of text. Change 18 to 180 and the “g” slides right on its own.

2. Apply attributes to a range instead of appending

If you already have the full string and only want to restyle a slice of it, use addAttributes(_:range:). The important detail is the range: use the NSString length, not String.count, or you will get a crash on emoji and other multi-scalar characters.

let text = "\(grams) g"
let attributed = NSMutableAttributedString(string: text)

let fullRange = NSRange(location: 0, length: (text as NSString).length)
attributed.addAttribute(.font,
                        value: UIFont.systemFont(ofSize: 34, weight: .semibold),
                        range: fullRange)

let unitRange = (text as NSString).range(of: "g", options: .backwards)
if unitRange.location != NSNotFound {
    attributed.addAttributes([
        .font: UIFont.systemFont(ofSize: 17),
        .foregroundColor: UIColor.secondaryLabel
    ], range: unitRange)
}

coffeeLabel.attributedText = attributed

Use this when the unit is not always the same string (“g”, “kg”, “oz”) and you want one formatting pass over the whole line.

3. Keep it updating when the number changes

attributedText is a snapshot. Assigning it once does not make the label track a variable. Rebuild it wherever the value changes — in an @IBAction, in a view model callback, or in a didSet:

var grams: Int = 0 {
    didSet {
        coffeeLabel.attributedText = coffeeText(grams: grams)
    }
}

If you are using a custom font, unwrap the initializer instead of force-unwrapping it:

if let font = UIFont(name: "Chalkduster", size: 18) {
    attributes[.font] = font
}

4. The SwiftUI equivalent

In SwiftUI you rarely need NSAttributedString for this. Text supports +, and modifiers applied to each Text before concatenation survive:

import SwiftUI

struct CoffeeLabel: View {
    let grams: Int

    var body: some View {
        Text("\(grams)")
            .font(.system(size: 34, weight: .semibold))
        + Text("g")
            .font(.system(size: 17))
            .foregroundColor(.secondary)
    }
}

Both sides of + must be Text — you cannot concatenate a Text with a String. If you need to bring an attributed string across from UIKit, wrap it in AttributedString and pass it to Text(_:).

How to prevent it

  • Never mix String and NSAttributedString with +; build one NSMutableAttributedString and append.
  • Type attribute dictionaries as [NSAttributedString.Key: Any] so you can hold fonts, colors and numbers in the same dictionary.
  • Compute ranges with (string as NSString).length, not string.count.
  • Remember .underlineStyle needs NSUnderlineStyle.single.rawValue, not the enum itself.
  • Put the builder in a single function so the number format and the unit style can never drift apart.
  • Reassign attributedText on every value change; it does not observe anything.

FAQ

Why does calculatedCoffee + attributedText not compile?

Because + is only defined for two values of the same type. calculatedCoffee is a String and attributedText is an NSAttributedString, so the compiler has no operator that accepts both. Build the number as an NSMutableAttributedString first, then append.

Can I use a custom font for just the unit?

Yes. Put NSAttributedString.Key.font in the unit’s attribute dictionary with a UIFont you created by name. UIFont(name:size:) is failable, so unwrap it and fall back to a system font rather than force-unwrapping.

Does attributedText still work with Auto Layout and Dynamic Type?

Yes. The label measures the attributed string like any other text and sizes itself accordingly. To keep the text scaling with the user’s settings, build the fonts with UIFontMetrics instead of hard-coded point sizes.

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.