iosswiftuidrawinggraphicscontext

How to get the size of Text when building a custom Shape


This is a follow-up to my question How to fill a Path in a Canvas using a Material style where the comment was made, why use a Canvas when a Shape would be easier?

One difference between a Canvas and a custom Shape is that you get a GraphicsContext with a Canvas and this can be used for resolving text. Here is an example of a case where this is useful. This is a simplified version of the answer to How to add rounded borders that cling to a Text in SwiftUI:

struct ContentView: View {
    private let textLine1 = "The quick brown fox"
    private let textLine2 = "jumps over the lazy dog"
    private let paddingSize: CGFloat = 4

    private var textBackground: some View {
        Canvas { ctx, size in
            let resolvedText1 = ctx.resolve(Text(textLine1))
            let resolvedText2 = ctx.resolve(Text(textLine2))
            let sizeText1 = resolvedText1.measure(in: size)
            let sizeText2 = resolvedText2.measure(in: size)
            let outlineText1 = Path(
                roundedRect: CGRect(
                    x: ((size.width - sizeText1.width) / 2) - paddingSize,
                    y: ((size.height / 2) - sizeText1.height) - paddingSize,
                    width: sizeText1.width + (2 * paddingSize),
                    height: sizeText1.height + (2 * paddingSize)
                ),
                cornerRadius: 4
            )
            let outlineText2 = Path(
                roundedRect: CGRect(
                    x: ((size.width - sizeText2.width) / 2) - paddingSize,
                    y: (size.height / 2) - paddingSize,
                    width: sizeText2.width + (2 * paddingSize),
                    height: sizeText2.height + (2 * paddingSize)
                ),
                cornerRadius: 4
            )
            let combinedOutline = outlineText1.union(outlineText2)
            ctx.fill(combinedOutline, with: .color(white: 0.4, opacity: 0.4))
            ctx.stroke(combinedOutline, with: .foreground, lineWidth: 2)
        }
    }

    var body: some View {
        ZStack {
            textBackground
            VStack(spacing: 0) {
                Text(textLine1)
                Text(textLine2)
            }
        }
        .font(.title2)
    }
}

Screenshot

What would be preferable, would be to construct the background as a custom shape:

struct MyTextBackground: Shape {
    let text1: String
    let text2: String
    func path(in rect: CGRect) -> Path {
        var path = Path()
        //let ctx = GraphicsContext(path) // Error: 'GraphicsContext' cannot be constructed because it has no accessible initializers

        // Help - how do I find the size of the text??

        return path
    }
}

A shape like this could be filled and stroked easily. A Material fill would be easy too, instead of having to use the (rather convoluted) workarounds that were suggested as answers to the post mentioned above:

ZStack {
    MyTextBackground(text1: textLine1, text2: textLine2)
        .fill(.ultraThinMaterial)
        .stroke(.primary, lineWidth: 2)
    VStack(spacing: 0) {
        // ...
    }
}
.font(.title2)

So, how can I get get the size of text inside the path function of a custom Shape, when there is no GraphicsContext available?


Solution

  • In iOS 18, TextRenderer makes available a GraphicsContext, allowing drawing based on actual per-line sizes.

    import SwiftUI
    
    struct MultiTextLineBackground: View {
    
        var body: some View {
            
            if #available(iOS 18.0, *) {
                Text("The quick brown fox jumps over the lazy dog")
                    .frame(width: 150)
                    .multilineTextAlignment(.center)
                    .textRenderer(ContourTextRenderer())
            } else {
                // Fallback on earlier versions
            }
        }
        
    }
    
    struct ContourTextRenderer: TextRenderer {
        func draw(layout: Text.Layout, in ctx: inout GraphicsContext) {
            var combinedPath = Path()
            
            for line in layout {
                let rect = line.typographicBounds.rect.insetBy(dx: -6, dy: -4) //adds padding
                let path = Path(roundedRect: rect, cornerRadius: 4)
                combinedPath = combinedPath.union(path)
            }
            
            // Fill the background of the combined path
            ctx.fill(combinedPath, with: .color(.yellow))
            
            // Stroke the contour of the combined path
            ctx.stroke(combinedPath, with: .color(.orange), lineWidth: 1)
            
            // Draw text
            for line in layout {
                ctx.draw(line)
            }
        }
    }
    
    #Preview {
        MultiTextLineBackground()
    }
    

    enter image description here