肘子的 Swift 记事本

Mixing Text and Image in SwiftUI

Published on

Get weekly handpicked updates on Swift and SwiftUI!

SwiftUI offers powerful layout capabilities, but these layout operations are performed between views. When we want to mix text and images in Text, we need to adopt a different mindset and approach than with view layout. This article will first introduce some knowledge related to Text and, through a practical case, outline the approach to implementing Text and Image mixing in SwiftUI.

One and a Group

In SwiftUI, Text is one of the most frequently used components, and almost all text display operations are completed by it. With the continuous improvement of the SwiftUI version, the functionality of Text has also been continuously enhanced. In addition to basic text content, it also provides support for types such as AttributedString, Image (to a limited extent), and Formatter.

If the Text view cannot display all content within the given suggested width, and in cases where the suggested height allows (with no height limit or number of displayed lines), Text will wrap the content and ensure its integrity by displaying it on multiple lines. The above feature has one basic requirement - line wrapping is performed within a single Text view. In the following code, although we have arranged the Text views horizontally through a layout container view, SwiftUI still considers them as multiple Text views (a group) and performs line wrap operations separately for each Text:

Swift
struct TempView:View{
    let str = "道可道,非常道;名可名,非常名。"
    var body: some View{
        HStack{
            Text(str)
            Text(str)
            Text(str)
        }
        .padding()
    }
}

https://cdn.fatbobman.com/image-20220814083426515.png

SwiftUI provides two ways to combine multiple Text views into one:

  • By interpolating with LocalizedStringKey
Swift
HStack{
    let a = Text(str)
    let b = Text(str)
    let c = Text(str)
    Text("\(a) \(b) \(c)")
}

https://cdn.fatbobman.com/image-20220814084617352.png

Not only can we add Text through interpolation, we can also add many other types such as Image and Date. Wang Wei provides a detailed introduction to this in his article Text Interpolation and Localization in SwiftUI.

Please note: Starting from the second Text interpolation element, you must add a space before the interpolation symbol ”(” to avoid display issues. You can reproduce this error by changing the code above from Text(“(a) (b) (c)”) to Text(“(a)(b)(c)”). This issue has been fixed in iOS 16+.

  • Using the addition operator
Swift
HStack{
    let a = Text(str)
    let b = Text(str)
    let c = Text(str)
    a + b + c
}

Addition operation can only be performed between Text types. This means that when configuring part of the Text, we can only use modifiers that do not change the Text type (this principle also applies to merging through interpolation), for example:

Swift
HStack{
    let a = Text(str)
        .foregroundColor(.red) // Text-specific version, does not change Text type
        .underline() // Does not change Text type
//      .background(Color.yellow) // The background is a modifier for the View protocol, which will change the Text type and cannot be used
    let b = Text(str)
        .foregroundColor(.blue)
        .font(.title)
    let c = Text(str)
        .foregroundColor(.green)
        .bold()
    a + b + c
}

https://cdn.fatbobman.com/image-20220814090556878-0439158.png

If you often have the need to compose complex text, you can create a result builder to simplify the process:

Swift
@resultBuilder
enum TextBuilder {
    static func buildBlock(_ components: Text...) -> Text {
        components.reduce(Text(""),+)
    }
}

Using this constructor, we can synthesize complex text more clearly and quickly:

Swift
@TextBuilder
func textBuilder() -> Text {
    Text(str)
        .foregroundColor(.red)
        .underline()
    Text(str)
        .foregroundColor(.blue)
        .font(.title)
    Text(str)
        .foregroundColor(.green)
        .bold()
}

Read and master the article on Result builders to learn more about the content of structure builders.

Using SF Symbols in Text

SF Symbols is a great gift from Apple to developers, allowing them to use a vast collection of icons created by professional designers almost for free in the Apple ecosystem. As of 2022, SF Symbols has more than 4,000 symbols, each with nine weights and three scales, and can align automatically with text labels.

In SwiftUI, we need to use Image to display SF Symbols and can use some modifiers to configure them:

Swift
Image(systemName: "ladybug")
    .symbolRenderingMode(.multicolor) // Specify the rendering mode, an Image-specific modifier that does not change the Image type
    .symbolVariant(.fill) // Set the variant, a modifier applicable to the View protocol that changes the Image type
    .font(.largeTitle) // A View modifier that is not Text-specific

https://cdn.fatbobman.com/image-20220814103141010.png

SF Symbols provides the ability to seamlessly integrate with Apple’s system font, San Francisco, which is treated as ordinary text and uniformly processed during typesetting. The two methods introduced above are both applicable to adding SF Symbols to Text:

Swift
let bug = Image(systemName: "ladybug.fill") // We add variants directly to the name to keep the type stable since symbolVariant will change the type of Image.
.symbolRenderingMode(.multicolor) // Specify the rendering mode, Image-specific modifier, Image type does not change
let bugText = Text(bug)
.font(.largeTitle) // Text-specific version, Text type does not change

// Using interpolation
Text("Hello \(bug)") // Using Image type in interpolation, the font will change the type of Image, so it's not possible to adjust the size of bug separately.

Text("Hello \(bugText)") // Using Text in interpolation, font (Text-specific modifier) will not change the Text type, so it's possible to adjust the size of bug separately.

// Using addition operator
Text("Hello ") + bugText

https://cdn.fatbobman.com/image-20220814104652581.png

It can be said that in Text, the Image type can be directly used, and this feature is mainly provided for SF Symbols. In possible cases, the combination of Text + SF Symbols is the best solution for achieving mixed text and graphics.

Swift
struct SymbolInTextView: View {
    @State private var value: Double = 0
    private let message = Image(systemName: "message.badge.filled.fill") // 􁋭
        .renderingMode(.original)
    private let wifi = Image(systemName: "wifi") // 􀙇
    private var animatableWifi: Image {
        Image(systemName: "wifi", variableValue: value)
    }

    var body: some View {
        VStack(spacing:50) {
            VStack {
                Text(message).font(.title) + Text("Mixed text and SF Symbols.\(wifi) Text regards interpolated images as part of text.") + Text(animatableWifi).foregroundColor(.blue)
            }
        }
        .task(changeVariableValue)
        .frame(width:300)
    }

    @Sendable
    func changeVariableValue() async {
        while !Task.isCancelled {
            if value >= 1 { value = 0 }
            try? await Task.sleep(nanoseconds: 1000000000)
            value += 0.25
        }
    }
}

https://cdn.fatbobman.com/sfsymbols_In_Text_2022-08-14_10.53.10.2022-08-14%2010_53_54.gif

Although we can use SF Symbols app to modify or create custom symbols, SF Symbols still cannot meet the requirements in many cases due to limitations such as color and proportion. In this case, we need to use real Images for text and image integration.

Swift
VStack {
    let logo = Image("logo")  // logo is an 80 x 28 size image, by default, the height of the title is 28

    Text("Welcome to \(logo)!")
        .font(.title)

    Text("Welcome to \(logo)!")
        .font(.body)
}

https://cdn.fatbobman.com/image-20220814155725538.png

When using a real Image (not SF Symbols) in Text, Text can only render it in its original size (SVG, PDF follows the specified size), and the size of the image does not change with the font size.

On the other hand, because the textBaseline of Image (not SF Symbols) is consistent with its bottom by default, when mixed with other text in Text, the image and text will be misaligned due to different baselines. We can adjust it by using the Text version of the baselineOffset modifier.

Swift
let logo = Text(Image("logo")).baselineOffset(-3) // Text version of the modifier, will not change the Text type, using alignmentGuide will change the type

Text("Welcome to \(logo)!")
    .font(.title)

https://cdn.fatbobman.com/image-20220814160547051.png

For information on baseline alignment, refer to the SwiftUI layout - Align article.

Once again, we can only use modifiers that do not change the type of Text or Image. Modifiers such as frame, scaleEffect, scaleToFit, and alignmentGuide that change the type state will prevent interpolation and arithmetic operations on Text!

Therefore, to perfectly match views with text, we need to prepare different sizes of views for different sizes of text.

Dynamic Type (Automatic Font Scaling)

Apple has been working hard to improve the user experience of its ecosystem. Taking into account factors such as the distance between the user and the display, visual acuity, movement, lighting conditions, and more, Apple provides users with the Dynamic Type feature to improve the readability of content.

The Dynamic Type feature allows users to set the size of text displayed on the device screen. It can help users who need larger text for readability, as well as those who can read smaller text, allowing more information to appear on the screen. Applications that support Dynamic Type also provide users with a more consistent reading experience.

Users can change the text display size of individual or all applications in the Control Center or through “Settings” - “Accessibility” - “Display & Text Size” - “Larger Text”.

https://cdn.fatbobman.com/DynamicType.png

Starting from Xcode 14, developers can quickly check how a view behaves under different Dynamic Types in the preview.

Swift
Text("Welcome to \(logo)!")
    .font(.title) // The size of "title" font varies under different Dynamic Types.

https://cdn.fatbobman.com/image-20220814173320321-0469602.png

In SwiftUI, unless specifically configured, the size of all fonts will change with dynamic type changes. As shown in the figure above, dynamic types only affect text, and the size of images in Text will not change.

When using Text for image and text mixing, if the image size cannot change with the text size, the result shown in the figure above will appear. Therefore, we must find a way to make the image size adapt to dynamic type changes.

Using the @ScaledMetric attribute wrapper provided by SwiftUI, a value that can automatically scale with dynamic types can be created. The relativeTo parameter can associate the value with a specific text style size change curve.

Swift
@ScaledMetric(relativeTo: .body) var imageSize = 17

The size value curves that respond to dynamic type changes for different text styles are not the same. For details, please read Apple’s Design Documentation.

Swift
struct TempView: View {
    @ScaledMetric(relativeTo:.body) var height = 17 // default height of body
    var body: some View {
        VStack {
            Image("logo")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(height:height)

            Text("Welcome!")
                .font(.body)
        }
        .padding()
    }
}

In the above code, the height of the image is associated with the size of the .body text style through ScaledMetric. When dynamic types change, the size of the image will also be adjusted accordingly.

https://cdn.fatbobman.com/image-20220814181138809.png

Unfortunately, due to the fact that frames will change the type of Image, we cannot embed images that have dynamically changed sizes through frames into Text to achieve dynamic size adjustment for mixed text and images.

Using .dynamicTypeSize(DynamicTypeSize.xSmall...DynamicTypeSize.xxxLarge) can make views only change within the specified dynamic type range.

Using .font(custom(_ name: String, size: CGFloat)) to set a custom size font will also automatically adjust the size when the dynamic type changes.

Using .font(custom(_ name: String, size: CGFloat, relativeTo textStyle: Font.TextStyle)) can associate the custom sized font with the dynamic type size change curve of a preset text style.

Using .font(custom(_ name: String, fixedSize: CGFloat)) will make the custom sized font ignore dynamic type changes, and the size will always remain the same.

A question about mixed text and images

A few days ago, a friend asked in the chat room if SwiftUI could achieve the layout effect of the tag (supermarket label) + product introduction shown in the figure below. I replied directly that there was no problem, but only when considering the specific implementation did I realize that the situation was not that simple.

https://cdn.fatbobman.com/image-20220815082801108.png

  • The rounded background of the tag means that a solution based on AttributedString has been excluded.
  • The specific size and content of the tag means that a solution based on custom SF Symbols has been excluded.
  • Adding an image to the Text for mixed text and image layout requires consideration of how to handle dynamic type changes (it is impossible to pre-generate so many sizes of images).
  • Is it possible to solve the current problem without pre-made tag images (using dynamic views)?

In the following text, I will provide three solution ideas and corresponding code to achieve the current requirement in different ways.

Due to length limitations, the sample code in the following text will not be thoroughly explained. It is recommended that you read the content in conjunction with the sample code provided in this article. The dynamically created images may not be displayed immediately when running the sample code from Xcode (this is an issue with Xcode). Running directly from the simulator or device will not experience the aforementioned delay.

Solution 1: Using Images Directly in Text

Solution 1 Approach

Since providing different sizes of images for different dynamic types can meet the needs of Text and image mixing, Solution 1 is based on this and automatically scales the given pre-made images proportionally according to the changes in dynamic types.

  • Retrieve label images from the application or the network
  • Scale the image to match the associated text style size when the dynamic type changes
Swift
VStack(alignment: .leading, spacing: 50) {
    TitleWithImage(title: "Durian D197 from Johor, Malaysia by Jia Nong", fontStyle: .body, tagName: "JD_Tag")

    TitleWithImage(title: "Durian D197 from Johor, Malaysia by Jia Nong", fontStyle: .body, tagName: "JD_Tag")
        .environment(\.sizeCategory, .extraExtraExtraLarge)
}

https://cdn.fatbobman.com/image-20220815112324138.png

Notes for Plan 1

  • To ensure the quality of the scaled image, SVG format is used in the example.
  • As all the image scaling modifiers provided by SwiftUI will change the type, the scaling operation will use UIGraphicsImageRenderer to work with UIImage.
Swift
extension UIImage {
    func resized(to size: CGSize) -> UIImage {
        return UIGraphicsImageRenderer(size: size).image { _ in
            draw(in: CGRect(origin: .zero, size: size))
        }
    }
}
  • As UIFont.preferredFont is used to obtain the size of the text style, the text style parameter is of the UIFont.TextStyle type.
  • The initial height of the image is set to be the same as the given text style, and @ScaledMetric is used to keep their sizes synchronized when changing.
Swift
let uiFont = UIFont.preferredFont(forTextStyle: fontStyle)
pointSize = uiFont.pointSize
textStyle = Font.TextStyle.convert(from: fontStyle)
_fontSize = ScaledMetric(wrappedValue: pointSize, relativeTo: textStyle)
  • Use .font(.custom("", size: pointSize, relativeTo: textStyle)) to set the font size and associate it with the given text style.
  • Use the task modifier correctly to ensure that the size scaling operation is performed in the background thread to reduce the impact on the main thread.
@Sendable
func resizeImage() async {
    if var image = UIImage(named: tagName) {
        let aspectRatio = image.size.width / image.size.height
        let newSize = CGSize(width: aspectRatio * fontSize, height: fontSize)
        image = image.resized(to: newSize)
        tagImage = Image(uiImage: image)
    }
}

.task(id: fontSize, resizeImage)
  • Use baselineOffset to adjust the image’s text baseline. The offset value should be fine-tuned according to different dynamic types (I used a fixed value in the example code here).

Advantages and disadvantages of solution one

  • The solution is simple and easy to implement.
  • Due to the need to pre-make images, it is not suitable for scenarios with many tag types and frequent changes.
  • In order to ensure the effect after scaling, high-resolution original images need to be provided when vector images cannot be used, which will cause more system burden.

Option 2: Use Overlay Views on Text

Solution for Option 2

  • Do not use pre-made images, create tags through SwiftUI views
  • Create blank placeholder images based on the size of the tag view
  • Add placeholder images to Text and mix them
  • Use overlay to position the tag view at the leadingTop position and overlay it on the placeholder image
Swift
TitleWithOverlay(title: "佳农 马来西亚冷冻 猫山王浏览果肉 D197", tag: "京东超市", fontStyle: .body)

TitleWithOverlay(title: "佳农 马来西亚冷冻 猫山王浏览果肉 D197", tag: "京东超市", fontStyle: .body)
    .environment(\.sizeCategory, .extraExtraExtraLarge)

https://cdn.fatbobman.com/image-20220815134505932.png

Notes for Plan Two

  • Use fixedSize to prevent the tag view from responding to dynamic types on its own. The text size in the TagView is entirely controlled by TitleWithOverlay.
Swift
Text(tag)
    .font(.custom("", fixedSize: fontSize))
  • Use alignmentGuide to fine-tune the position of the tag view, so that it aligns with the Text. Similar to Plan One, it is best to fine-tune the offset, padding, fontSize, etc. based on dynamic types (the author did not fine-tune it out of laziness, but the final effect is acceptable).
Swift
TagView(tag: tag, textStyle: textStyle, fontSize: fontSize - 6, horizontalPadding: 5.5, verticalPadding: 2)
    .alignmentGuide(.top, computeValue: { $0[.top] - fontSize / 18 })
  • When the fontSize (current text size under dynamic types) changes, update the size of the tag view.
Swift
Color.clear
    .task(id:fontSize) { // Use task(id:)
        tagSize = proxy.size
    }
  • When the tag view size tagSize changes, create the placeholder image again.
Swift
.task(id: tagSize, createPlaceHolder)
  • Use the task decorator correctly to ensure that the operation of creating placeholder images runs on a background thread, reducing the impact on the main thread.
Swift
extension UIImage {
    @Sendable
    static func solidImageGenerator(_ color: UIColor, size: CGSize) async -> UIImage {
        let format = UIGraphicsImageRendererFormat()
        let image = UIGraphicsImageRenderer(size: size, format: format).image { rendererContext in
            color.setFill()
            rendererContext.fill(CGRect(origin: .zero, size: size))
        }
        return image
    }
}

@Sendable
func createPlaceHolder() async {
    let size = CGSize(width: tagSize.width, height: 1) // 仅需横向占位,高度够用就行
    let uiImage = await UIImage.solidImageGenerator(.clear, size: size)
    let image = Image(uiImage: uiImage)
    placeHolder = Text(image)
}

Pros and Cons of Solution Two

  • No need for pre-made images
  • Tag content and complexity are no longer limited
  • Only suitable for the current special case (tag in the top left corner), once the tag’s position is changed, this solution will no longer be effective (it is difficult to align other positions in the overlay)

Solution Three: Convert the View to an Image and Insert it into Text

Solution Three’s Approach

  • Same as option two, do not use pre-made images, create labels using SwiftUI views
  • Convert label view to image and add it to text for inline display
Swift
TitleWithDynamicImage(title: "佳农 马来西亚冷冻 猫山王浏览果肉 D197", tag: "京东超市", fontStyle: .body)

TitleWithDynamicImage(title: "佳农 马来西亚冷冻 猫山王浏览果肉 D197", tag: "京东超市", fontStyle: .body)
    .environment(\.sizeCategory, .extraExtraExtraLarge)

https://cdn.fatbobman.com/image-20220815141821917.png

Notes for Solution Three

  • Ensure that the view is converted to an image in the background process.
Swift
@Sendable
func createImage() async {
    let tagView = TagView(tag: tag, textStyle: textStyle, fontSize: fontSize - 6, horizontalPadding: 5.5, verticalPadding: 2)
    tagView.generateSnapshot(snapshot: $tagImage)
}
  • The correct scale value must be set during the process of converting images to ensure the quality of the images.
Swift
func generateSnapshot(snapshot: Binding<Image>) {
    Task {
        let renderer = await ImageRenderer(content: self)
        await MainActor.run {
            renderer.scale = UIScreen.main.scale // Set the correct scale value
        }
        if let image = await renderer.uiImage {
            snapshot.wrappedValue = Image(uiImage: image)
        }
    }
}

Advantages and Disadvantages of Solution Three

  • No need to pre-create images.
  • The content and complexity of tags are no longer restricted.
  • There is no need to limit the position of tags and they can be placed anywhere in the Text.
  • Since the example code uses ImageRenderer provided by SwiftUI 4 to convert views to images, it only supports iOS 16+.

In lower versions of SwiftUI, the conversion operation can be completed under UIKit by wrapping the view with UIHostingController. However, since UIHostingController can only run on the main thread, this conversion operation has a significant impact on the main thread. Please choose accordingly.

Summary

After reading this article, you may feel that SwiftUI is cumbersome and requires so many operations to complete such a simple requirement. But isn’t using existing methods to solve practical problems also a challenge and fun? At least for me.

I'm really looking forward to hearing your thoughts! Please Leave Your Comments Below to share your views and insights.

Fatbobman(东坡肘子)

I'm passionate about life and sharing knowledge. My blog focuses on Swift, SwiftUI, Core Data, and Swift Data. Follow my social media for the latest updates.

You can support me in the following ways