How do I replace a placeholder text with an Image in SwiftUI?

Viewed 98

I have a string "Hello {world}" which I need to replace with "Hello ". The placeholder's position is not fixed at the end. And I may have more than a single placeholder.

I am using SwiftUI and tried to make this work with

Text("Hello {world}".replacingOccurrences(of: "{world}", with: "\(Image(systemName: "globe"))"))

but soon found that this doesn't work and presented with this Hello Image(provider: SwiftUI.ImageProviderBox<SwiftUI.Image.(unknown context at $1ba606db0).NamedImageProvider>)

Since this worked

Text(LocalizedStringKey("Hello \(Image(systemName: "globe"))"))

I assumed I needed to pass a LocalizedStringKey into the Text I tried again with

Text(LocalizedStringKey("Hello {world}".replacingOccurrences(of: "{world}", with: "\(Image(systemName: "globe"))")))
Text(LocalizedStringKey("Hello" + "\(Image(systemName: "globe"))")) //this doesn't work either

but presented with a similar issue SwiftUI.Text.Storage.anyTextStorage(SwiftUI.(unknown context at $1ba668448).LocalizedTextStorage

I looked at the API for LocalizedStringKey and LocalizedStringKey.StringInterpolation but could not find a solution this problem. Is there a way to make replacement of placeholder string work?

5 Answers

After looking at @bewithyou's answer, I got the idea that I need to split this into multiple substrings and recombine the texts individually. This is the best solution I could come up with:

public extension String {
    
    func componentsKeepingSeparator(separatedBy separator: Self) -> Array<String> {
        
        self.components(separatedBy: separator)
            .flatMap { [$0, separator] }
            .dropLast()
            .filter { $0 != "" }
    }
}

And on playground, if I were to run this, it works perfectly.

PlaygroundPage.current.setLiveView(

    "Hello {world}!"
        .componentsKeepingSeparator(separatedBy: "{world}")
        .reduce(Text("")) { text, str in
            if str == "{world}" { return text + Text("\(Image(systemName: "globe"))") }
            return text + Text(str)
        }
)

I'm sure there is a more optimal solution, but this will do for now.

EDIT:

Since I needed support for multiple placeholders, I've added some more extensions that does the job more comprehensively.

func componentsKeepingSeparators(separatedBy separators: [Self]) -> [String] {
    
    var finalResult = [self]
    separators.forEach { separator in
        
        finalResult = finalResult.flatMap { strElement in
            
            strElement.componentsKeepingSeparator(separatedBy: separator)
        }
    }
    return finalResult
}

and on the playground

PlaygroundPage.current.setLiveView(
    "Hello {world}{world}{world}! {wave}"
        .componentsKeepingSeparators(separatedBy: ["{world}", "{wave}"])
        .reduce(Text("")) { text, str in
            if str == "{world}" { return text + Text("\(Image(systemName: "globe"))") }
            if str == "{wave}" { return text + Text("\(Image(systemName: "hand.wave"))") }
            return text + Text(str)
        }
)

This extension has a double loop and might not be very efficient, so again, if someone can think of a better solution, please do post.

For your question the key here is not LocalizedStringKey but the key here is \() methods means string interpolation.

According to Swift document, string interpolation is a way to construct a new String value from a mix of constants, variables, literals, and expressions by including their values inside a string literal. You can use string interpolation in both single-line and multiline string literals.

In here it combines two things which is Text("hello") and Image(systemName: "globe") into a new String. Your code is wrong because of you append the string of value.

Without LocalizedStringKey, Text will appear as same as your Hello !.

Text("Hello \(Image(systemName: "globe"))!")

Or you can use as combination for easier understanding

Text("hello") + Text(Image(systemName: "globe")) + Text("!")

And for you question about mapping value you can make a dictionary for mapping image or name image do that

var dict : [String:String] = ["world" : "globe"]

// Add default name image value if key is nil
Text("Hello \(Image(systemName: dict["world", default:"globe"]))!")
Text("hello") + Text(Image(systemName: dict["world", default: "globe"])) + Text("!")
var dict : [String:Image] = ["world" : Image(systemName: "globe")]

// Add default image value if key is nil
Text("hello\(dict["world", default: Image(systemName: "globe")])!")
Text("hello") + Text(dict["world", default: Image(systemName: "globe")]) + Text("!")

All of them works the same an print out Hello !

I came to this question via answering this one and it piqued my interest. As I say in my answer there, the secret sauce is that LocalizedStringKey, when initialised with an interpolated string literal, is capable of building in references to SwiftUI Image types which can be rendered in Text.

Because you're not using an interpolated string literal, you can either build things up by multiple Texts, as in the other answers here, or do something smart with LocalizedStringKey.StringInterpolation. The advantage of this approach is that you can also use the image-holding text in any other view that uses LocalizedStringKey (which is, well, pretty much any of them that display text).

This extension on LocalizedStringKey will manually build an interpolated string:

extension LocalizedStringKey {

    private static let imageMap: [String: String] = [
        "world": "globe",
        "moon": "moon"
    ]

    init(imageText: String) {
        var components = [Any]()
        var length = 0
        let scanner = Scanner(string: imageText)
        scanner.charactersToBeSkipped = nil
        while scanner.isAtEnd == false {
            let up = scanner.scanUpToString("{")
            let start = scanner.scanString("{")
            let name = scanner.scanUpToString("}")
            let end = scanner.scanString("}")
            if let up = up {
                components.append(up)
                length += up.count
            }
            if let name = name {
                if start != nil, end != nil, let imageName = Self.imageMap[name] {
                    components.append(Image(systemName: imageName))
                    length += 1
                } else {
                    components.append(name)
                }
            }
        }

        var interp = LocalizedStringKey.StringInterpolation(literalCapacity: length, interpolationCount: components.count)
        for component in components {
            if let string = component as? String {
                interp.appendInterpolation(string)
            }
            if let image = component as? Image {
                interp.appendInterpolation(image)
            }
        }

        self.init(stringInterpolation: interp)
    }
}

You may want to cache these values if they are coming from an API, I haven't checked the performance of this code in a rendering loop.

You add an extension on Text, or any other view:

extension Text {
    init(imageText: String) {
        self.init(LocalizedStringKey(imageText: imageText))
    }
}

So you can do this:

Text(imageText: "Hello {world}! or {moon} or {unmapped}")

Which gives you:

Text view with interpolated images

Using @Aswath's answer, here's a custom container:

struct CText: View {
    var text: String
    var placeholders: [String: String]
    var imagePlaceholders: [String: Image]
    public init(_ text: String) {
        self.text = text
        self.placeholders = [:]
        self.imagePlaceholders = [:]
    }
    private init(_ text: String, placeholders: [String: String], imagePlaceholders: [String: Image]) {
        self.text = text
        self.placeholders = placeholders
        self.imagePlaceholders = imagePlaceholders
    }
    private var array: [String] {
        let strings = Array(placeholders.keys)
        let images = Array(imagePlaceholders.keys)
        return strings + images
    }
    var body: Text {
        text
            .componentsKeepingSeparators(separatedBy: array)
            .reduce(Text("")) { text, str in
                if let place = placeholders[str] {
                    return text + Text(place)
                }else if let place = imagePlaceholders[str] {
                    return text + Text("\(place)")
                } else {
                    return text + Text(str)
                }
            }
    }
    func replacing(_ holder: String, with replacement: String) -> CText {
        var oldPlaceholders = placeholders
        oldPlaceholders[holder] = replacement
        return CText(text, placeholders: placeholders, imagePlaceholders: imagePlaceholders)
    }
    func replacing(_ holder: String, with replacement: Image) -> CText {
        var oldPlaceholders = imagePlaceholders
        oldPlaceholders[holder] = replacement
        return CText(text, placeholders: placeholders, imagePlaceholders: oldPlaceholders)
    }
}

Usage:

struct Test: View {
    var body: some View {
        CText("Hello {world}")
            .replacing("{world}", with: Image(systemName: "globe"))
    }
}

Edit: If you need to access Text instead of View, add .body at the end:

struct Test: View {
    var body: some View {
        CText("Hello {world}")
            .replacing("{world}", with: Image(systemName: "globe"))
            .body
    }
}

This is a small improvement to @jrturton's answer tweaked to my needs. Perhaps this might benefit others. However, this is very different to my original answer, and so it made sense to me to add this as a new answer. The deletingPrefix is from hackingwithswift

import PlaygroundSupport
import Foundation
import SwiftUI

extension String {

    func deletingPrefix(_ prefix: String) -> String {
        guard self.hasPrefix(prefix) else { return self }
        return String(self.dropFirst(prefix.count))
    }
}

extension LocalizedStringKey {
    
    @available(iOS 15, *)
    init(imageText: String, replacementClosure: (String) -> Any) {
        
        var components = [Any]()
        var length = 0
        let scanner = Scanner(string: imageText)
        scanner.charactersToBeSkipped = nil
        
        while scanner.isAtEnd == false {
            let up = scanner.scanUpToString("{")
            let start = scanner.scanString("{")
            let name = scanner.scanUpToString("}")
            let end = scanner.scanString("}")
            if let up = up {
                components.append(up)
                length += up.count
            }
            if let name = name {
                if start == nil || end == nil { self.init(stringLiteral: imageText) }
                let replacement = replacementClosure(name)
                
                switch replacement {
                    
                case let image as Image:
                    components.append(image)
                case let attributedString as AttributedString:
                    components.append(attributedString)
                case let plainString as String:
                    components.append(plainString)
                default:
                    print("No action.")
                }
            }
        }

        var interp = LocalizedStringKey.StringInterpolation(literalCapacity: length, interpolationCount: components.count)
        for component in components {
            if let string = component as? String {
                interp.appendInterpolation(string)
            }
            if let attrString = component as? AttributedString {
                interp.appendInterpolation(attrString)
            }
            if let image = component as? Image {
                interp.appendInterpolation(image)
            }
        }

        self.init(stringInterpolation: interp)
    }
}


extension Text {
    
    init(imageText: String) {
        self.init(LocalizedStringKey(imageText: imageText, replacementClosure: { string in
            
            switch string {
                
            case "world":
                return Image(systemName: "globe")
            case "moon":
                return Image(systemName: "moon")
            case let stylisedString where stylisedString.hasPrefix("style1__"):
                return AttributedString(stylisedString.deletingPrefix("style1__"), attributes: AttributeContainer().foregroundColor(.blue))
                
            default: return string
            }
        }))
    }
}


PlaygroundPage.current.setLiveView(Text(imageText: "Hello {world}! or {moon} or {style1__unmapped}")
)
Related