Search code examples
iosnsattributedstringdarkmode

NSTextAttachment images are not dynamic (light/dark mode)


I have an image that is dynamic for light/dark mode. If I place this image in a UIImageView, the dynamism works: when the user switches from light to dark mode and back, the image changes the version of itself that is displayed. But if I place the same image in an NSAttributedString as an NSTextAttachment and display the string in a label, the dynamism does not work: when the user switches from light to dark mode, the image does not change.

To see the problem in action, paste this code into your viewDidLoad:

    let size = CGSize(width: 20, height: 20)
    let renderer = UIGraphicsImageRenderer(size: size)
    let image1 = renderer.image {
        UIColor.red.setFill()
        $0.fill(.init(origin: .zero, size: size))
    }
    let image2 = renderer.image {
        UIColor.green.setFill()
        $0.fill(.init(origin: .zero, size: size))
    }
    let asset = UIImageAsset()
    asset.register(image1, with: .init(userInterfaceStyle: .light))
    asset.register(image2, with: .init(userInterfaceStyle: .dark))

    let iv = UIImageView(image: image1)
    iv.frame.origin = .init(x: 100, y: 100)
    self.view.addSubview(iv)

    let text = NSMutableAttributedString(string: "Howdy ", attributes: [
        .foregroundColor: UIColor(dynamicProvider: { traits in
            switch traits.userInterfaceStyle {
            case .light: return .red
            case .dark: return .green
            default: return .red
            }
        })
    ])
    let attachment = NSTextAttachment(image: image1)
    let attachmentCharacter = NSAttributedString(attachment: attachment)
    text.append(attachmentCharacter)

    let label = UILabel()
    label.attributedText = text
    label.sizeToFit()
    label.frame.origin = .init(x: 100, y: 150)
    self.view.addSubview(label)

I have deliberately made the text font color dynamic so that you can see that in general color dynamism does work in an attributed string. But not in an attributed string's text attachment!

So: Is this really how iOS behaves, or am I making some mistake in the way I'm configuring the text attachment? If this is how iOS behaves, how are you working around the issue?

[Note that I cannot use an iOS 15 attachment view provider, because I have to be compatible with iOS 13 and 14. So maybe that would solve the problem, but that solution is not open to me.]


Solution

  • I think that's unfortunately the normal behavior, but I considered it as a forgotten feature non-dev by Apple.

    The only way I got currently, is to listen to func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) to detect the mode change.

    Then, you can either reconstruct your NSAttributedString, or enumerate over it and update it when needed.

    With a enumeration, ie update only what's needed, and not regenerate the whole NSAttributedString:

    In your initial attachment creation:

    let attachment = NSTextAttachment(image: asset.image(with: traitCollection))
    let attachmentCharacter = NSAttributedString(attachment: attachment)
    

    Side note: I used asset.image(with: traitCollection) instead of image1, else when starting with dark mode, your image will be of light mode instead. So this should set the correct image.

    Then, I'd update it with:

    func switchAttachment(for attr: NSAttributedString?) -> NSAttributedString? {
        guard let attr = attr else { return nil }
        let mutable = NSMutableAttributedString(attributedString: attr)
        mutable.enumerateAttribute(.attachment, in: NSRange(location: 0, length: mutable.length), options: []) { attachment, range, stop in
            guard let attachment = attachment as? NSTextAttachment else { return }
            guard let asset = attachment.image?.imageAsset else { return }
            attachment.image = asset.image(with: .current)
            mutable.replaceCharacters(in: range, with: NSAttributedString(attachment: attachment))
        }
        return mutable
    }
    

    And update when:

    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
        super.traitCollectionDidChange(previousTraitCollection)        
        label.attributedText = switchAttachment(for: label.attributedText)
    }