swiftui

How to embed an SFSymbol into an AttributedString in SwiftUI


I'm curious, is there a way to embed an SFSymbol into an AttributedString?

I would like to be able to include an SFSymbol in a string that has styling applied to it. To illustrate, in the following example, I'd like the "Hello, world!" to be blue, and the globe icon to be red. Using LocalizedStringKey as I have done below lets me embed the SFSymbol in the Text, but I don't have styling control over the parts as I would with an AttributedString.

struct ContentView: View {
    // I'd like to use AttributedString instead.
    var string: LocalizedStringKey {
        "Hello, world! \(Image(systemName: "globe"))"
    }
    
    var body: some View {
        Text(string)
    }
}

If there's a different way (not using AttributedString) of achieving the same result, that would probably be appreciated too!


Solution

  • If you don't need to consider localisation problems like changing the position of the image within the text depending on locale, the simple solution of concatenating Texts (koen's answer) can be used.


    The SwiftUI attribute scope does not include the attachment attribute or anything similar, so SwiftUI cannot display an AttributedString with attachments. LocalizedStringKey probably uses a different system from AttributedStringKey.

    What you can do instead is use UIKit/AppKit to display the string (their attribute scopes support attachment). For example, here is a UIViewRepresentable modified from Asperi's answer here.

    struct LabelView: View {
        let text: AttributedString
    
        @State private var height: CGFloat = .zero
    
        var body: some View {
            InternalLabelView(text: text, dynamicHeight: $height)
                .frame(minHeight: height)
        }
    
        struct InternalLabelView: UIViewRepresentable {
            let text: AttributedString
            @Binding var dynamicHeight: CGFloat
    
            func makeUIView(context: Context) -> UILabel {
                let label = UILabel()
                label.numberOfLines = 0
                label.lineBreakMode = .byWordWrapping
                label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
                label.setContentHuggingPriority(.defaultHigh, for: .horizontal)
    
                return label
            }
    
            func updateUIView(_ uiView: UILabel, context: Context) {
                uiView.attributedText = NSAttributedString(text)
    
                DispatchQueue.main.async {
                    dynamicHeight = uiView.sizeThatFits(CGSize(width: uiView.bounds.width, height: CGFloat.greatestFiniteMagnitude)).height
                }
            }
        }
    }
    

    This implementation resizes itself to fit the content and also works in a ScrollView. You may need a slightly different implementation depending on your needs.

    Instead of using a dynamicHeight binding, also consider overriding sizeThatFits if you are on iOS 16+ (in which case you can put everything in LabelView and don't need InternalLabelView).

    struct LabelView: UIViewRepresentable {
        let text: AttributedString
    
        func makeUIView(context: Context) -> UILabel {
            let label = UILabel()
            label.numberOfLines = 0
            label.lineBreakMode = .byWordWrapping
            label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
            label.setContentHuggingPriority(.defaultHigh, for: .horizontal)
    
            return label
        }
        
        func sizeThatFits(_ proposal: ProposedViewSize, uiView: UILabel, context: Context) -> CGSize? {
            uiView.sizeThatFits(.init(width: proposal.width ?? .infinity, height: proposal.height ?? .infinity))
        }
    
        func updateUIView(_ uiView: UILabel, context: Context) {
            uiView.attributedText = NSAttributedString(text)
        }
    }
    

    To support localisation, you need some kind of placeholder substring. Here is an example:

    struct ContentView: View {
        @Environment(\.locale) var locale
        var body: some View {
            LabelView(text: localizedAttributedStringWithImage("Hello @@@ World", images: ["@@@": "globe"]))
        }
        
        // images is a dictionary with placeholders as keys and system image names as values
        func localizedAttributedStringWithImage(_ string: String.LocalizationValue, images: [String: String]) -> AttributedString {
            var attributedString = AttributedString(localized: string, locale: locale)
            // adding some random coloring just as an example
            // remember to use UIColors because we are displaying this using UIKit
            attributedString.foregroundColor = UIColor.blue
            for (placeholder, image) in images {
                guard let range = attributedString.range(of: placeholder) else { continue }
                var attachment = AttributedString(NSAttributedString(attachment: .init(image: UIImage(systemName: image)!)))
                attachment.foregroundColor = UIColor.red
                attributedString.replaceSubrange(range, with: attachment)
            }
            return attributedString
        }
    }
    

    enter image description here