3

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?

Aswath
  • 1,236
  • 1
  • 14
  • 28
  • I suggest you read again the Swift basics at: https://docs.swift.org/swift-book/LanguageGuide/TheBasics.html and do the tutorial relating to SwiftUI at: https://developer.apple.com/tutorials/swiftui/. Without understanding the difference between a `String` and a `Image(...)` you will struggle. – workingdog support Ukraine Sep 16 '22 at 09:33
  • @workingdogsupportUkraine Hmm, I've did that already, can you please tell me which section I need to pay attention to? – Aswath Sep 16 '22 at 09:36
  • @workingdogsupportUkraine I understand the difference, however Swift now supports String interpolation that we could use to add images to text without using a AttributedString, right? If you run on the playground `Text(LocalizedStringKey("Hello \(Image(systemName: "globe"))"))` you'd get `Hello ` on the preview. My question is how can I do that with a placeholder text and replacing that with a Image – Aswath Sep 16 '22 at 09:44
  • ha, I think I understand your question now. How about using this: `var world = "globe"` and `Text(LocalizedStringKey("Hello \(Image(systemName: "\(world)"))"))`, then you can change the `world` string as desired. – workingdog support Ukraine Sep 16 '22 at 09:53
  • @workingdogsupportUkraine Interesting, but I think my problem is the other way around. My input string is a variable, that is `"Hello {world}"` and taht comes from an API. The image for `{world}` and other placeholders is stored in a local dictionary that I populate. Can you think of a way? – Aswath Sep 16 '22 at 10:06

5 Answers5

3

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.

Aswath
  • 1,236
  • 1
  • 14
  • 28
3

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

jrturton
  • 118,105
  • 32
  • 252
  • 268
  • This might be the best answer yet. However, there seems to be a bug with SwiftUI that interpolates `"{world}{moon}"` into `"1"` Edit: Weird, Even `Text("\(Image(systemName: "globe"))\(Image(systemName: "moon"))")` produces `"1"` – Aswath Sep 18 '22 at 05:24
  • Oh, that's interesting! Putting a space between them works, it must be some issue with the implementation of `LocalizedString.StringInterpolation` – jrturton Sep 19 '22 at 08:51
  • 1
    Useful note: The method `appendInterpolation` takes an AttributedString as a parameter as well. So, if one want a blue italicised text in place of `{unmapped}` instead of just plain text, you could do that or if you'd want style to be configurable, set up another static dictionary like `imageMap` that defines `AttributeContainer` against a style key, say `"style1__"` and append the resulting `AttributedString` to the components. So that `"Hello {world}! or {moon} or {style1__unmapped}"` could produce a blue `unmapped` text – Aswath Sep 21 '22 at 13:26
  • Great solution! Thanks very much! Any thoughts on how to combine this with markdown support? eg `Text(imageText: "**Hello** {world}")` doesn't work, though `Text("**Hello** \(Image(systemName: "globe"))")` does. – Curious Jorge Apr 03 '23 at 01:25
2

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 !

Thang Phi
  • 1,641
  • 2
  • 7
  • 16
  • I can follow most of the answer, however an you please add a example on how to convert `"Hello {world}!"`, the whole string into the Text `Hello !`. I'm not quite sure how I could achieve this.. – Aswath Sep 16 '22 at 10:10
  • Dear @Aswath, you just need simply add ``Text("!")`` or ``Text("Hello \(Image(systemName: dict["world", default:"globe"]))!")`` then all set. I've just updated above – Thang Phi Sep 16 '22 at 10:14
  • Ok, all your solutions work, but their input is hardcoded, that is `"Hello {world}!"`. What I need is a algorithm that takes a variable input and returns me a Text that would have worked as if it were hardcoded. – Aswath Sep 16 '22 at 10:16
  • Dear @Aswath, as far as your solution is worked – Thang Phi Sep 16 '22 at 10:17
  • 1
    Anyhow, thanks to your answer, I got a hint to separate the text and recombine them – Aswath Sep 16 '22 at 10:17
1

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
    }
}
Timmy
  • 4,098
  • 2
  • 14
  • 34
0

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}")
)
Aswath
  • 1,236
  • 1
  • 14
  • 28