iosswiftnsattributedstringattributedstring

How to chain UIKit attributes of an AttributeContainer?


Starting in iOS 15, you can style text in UIKit with an AttributedString. You can treat the attributes as properties of the AttributedString, or you can make an AttributeContainer and treat the attributes as properties of the AttributeContainer and then apply the AttributeContainer to some or all of the AttributedString. Here's a complete example (from the viewDidLoad of a view controller):

// first syntax:
var container = AttributeContainer()
container.uiKit.font = .boldSystemFont(ofSize: 30)
container.uiKit.foregroundColor = .blue
let attributedString = AttributedString("Howdy", attributes: container)
let label = UILabel()
label.attributedText = NSAttributedString(attributedString)
label.sizeToFit()
label.frame.origin = .init(x: 100, y: 100)
self.view.addSubview(label)

So far, so good. But there's also an ability to chain the attributes of an AttributeContainer by calling them as functions, like this:

// second syntax
let container = AttributeContainer()
    .font(.boldSystemFont(ofSize: 30))
    .foregroundColor(.blue)
let attributedString = AttributedString("Howdy", attributes: container)
let label = UILabel()
label.attributedText = NSAttributedString(attributedString)
label.sizeToFit()
label.frame.origin = .init(x: 100, y: 100)
self.view.addSubview(label)

But here's the problem. You notice how in the first example I said .uiKit to disambiguate the properties? I can't do that in the second example. And that means that in some situations, my styled text is not styled properly. This should be a sufficient example to reproduce; this is the entire view controller:

import UIKit
import SwiftUI

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        let container = AttributeContainer()
            .font(.boldSystemFont(ofSize: 30))
            .foregroundColor(.blue)
        let attributedString = AttributedString("Howdy", attributes: container)
        let label = UILabel()
        label.attributedText = NSAttributedString(attributedString)
        label.sizeToFit()
        label.frame.origin = .init(x: 100, y: 100)
        self.view.addSubview(label)
    }
}

The label text is not blue. It seems that the mere act of importing SwiftUI causes the attributes to break. And you can readily see why this is, by printing out label.attributedText; you'll see this:

"SwiftUI.ForegroundColor" = blue;

You see what's happened? We've defaulted to a SwiftUI color, which doesn't work in a UIKit context.

So at last I can enunciate my question! Is there a way to use the second syntax, with chaining, while also disambiguating the attributes to be UIKit attributes as I did in the first syntax?


Solution

  • The issue you're encountering is related to the interaction between UIKit and SwiftUI's color systems. In SwiftUI, the Color.blue corresponds to the default system blue color, while in UIKit, the .blue color refers to a specific shade of blue.

    To ensure consistent behavior across UIKit and SwiftUI, you can manually specify the exact color using the RGB values in your UIKit code. See below:

    import UIKit
    import SwiftUI
    
    class ViewController: UIViewController {
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            let container = AttributeContainer()
                .font(.boldSystemFont(ofSize: 30))
                .foregroundColor(UIColor(red: 0, green: 0, blue: 1, alpha: 1)) // <--- here!
            let attributedString = AttributedString("Howdy", attributes: container)
            let label = UILabel()
            label.attributedText = NSAttributedString(attributedString)
            label.sizeToFit()
            label.frame.origin = .init(x: 100, y: 100)
            self.view.addSubview(label)
        }
    }