skip to Main Content

I want to create something like this:
enter image description here

The background/highlight effect is a rounded rectangle with .regularMaterial and .stroke(.black).

I have tried using AttributedString and setting the .backgroundColor but this doesn’t allow me to use materials, nor can I round the edges.

I tried calculating this "precise" / "clingy" border using information about the font, but Text sometimes automatically hyphenates long words unpredictably and thus my calculations get messed up. I have simply disabled hyphenation for now but I’m wondering if someone has figured out a way to do this.

I thought of rendering each line separately but then again Text doesn’t tell me where in the text the line breaks will be, so I didn’t try this method.

2

Answers


  1. A sample of code that uses these concepts is shown here:

    struct ContentView: View {
    var body: some View {
        Text("Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s,")
            .font(.title)
            .padding() // Add padding around the text
            .background(Material.regular, in: RoundedRectangle(cornerRadius: 10)) // Apply rounded material background
            .clipShape(RoundedRectangle(cornerRadius: 10)) // Ensure the clipping matches the rounded corners
            .padding() // Additional outer padding for aesthetics
    }
    

    }

    Screenshot

    Login or Signup to reply.
  2. One way to approach this is to use a Canvas to show the text:

    • a Canvas receives a GraphicsContext, which can be used to measure the width of some text
    • the source string can be broken up into words and then re-assembled line by line, adding as many words to a line that will fit
    • as each line is processed, a rounded rectangle can be added to a background path using .union
    • once all lines have been processed, the background path can be filled and stroked, then the text can be shown over this filled shape.

    It’s a bit clumsy, especially in the way the text is broken up and then re-assembled, but it gets close to the result you were after. However, the one thing I couldn’t get to work was the Material background. If you try to use GraphicsContext.fill to fill a path using a Material style, it just fills with black (like redacted text). So the version below just uses a semi-transparent background:

    struct ContentView: View {
        private typealias Strings = [String]
        let horizontalPadding: CGFloat = 6
        let verticalPadding: CGFloat = 2
        let lineWidth: CGFloat = 2
        let cornerRadius: CGFloat = 6
    
        let loremIpsum = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
    
        private func wordsForOneLine(allWords: Strings, startingAt: Int, ctx: GraphicsContext, size: CGSize) -> Strings {
            var result = Strings()
            let doubleSize = CGSize(width: size.width * 2, height: size.height * 2)
            let nWordsTotal = allWords.count
            var nWordsInLine = 0
            while startingAt + nWordsInLine < nWordsTotal {
                var tmpResult = result
                tmpResult.append(allWords[startingAt + nWordsInLine])
                let text = Text(tmpResult.joined(separator: " "))
                let resolvedText = ctx.resolve(text)
                let lineWidth = resolvedText.measure(in: size).width
    
                // Check that the text was not cropped by measuring
                // again using a rectangle with double the size
                if lineWidth <= size.width && lineWidth == resolvedText.measure(in: doubleSize).width {
                    result = tmpResult
                } else {
                    break
                }
                nWordsInLine += 1
            }
            return result
        }
    
        private func textLines(fullText: String, ctx: GraphicsContext, size: CGSize) -> Strings {
            var result = Strings()
            let allWords = fullText.components(separatedBy: " ")
            let nWordsTotal = allWords.count
            var startingAt = 0
            while startingAt < nWordsTotal {
                let words = wordsForOneLine(allWords: allWords, startingAt: startingAt, ctx: ctx, size: size)
                if !words.isEmpty {
                    result.append(words.joined(separator: " "))
                    startingAt += words.count
                } else {
                    break
                }
            }
            return result
        }
    
        private func processLines(
            lines: Strings,
            ctx: GraphicsContext,
            size: CGSize,
            action: (String, CGSize, CGFloat) -> Void
        ) {
            var y = CGFloat.zero
            for line in lines {
                let text = Text(line)
                let resolvedText = ctx.resolve(text)
                let textSize = resolvedText.measure(in: size)
                if y == 0 {
                    let fullHeight = CGFloat(lines.count) * textSize.height
                    y = (size.height - fullHeight) / 2
                }
                action(line, textSize, y)
                y += textSize.height
            }
        }
    
        private func renderText(text: String) -> some View {
            Canvas { ctx, size in
                var backgroundPath = Path()
                let reducedSize = CGSize(
                    width: size.width - (2 * horizontalPadding) - lineWidth,
                    height: size.height - (2 * verticalPadding) - lineWidth
                )
                let lines = textLines(fullText: text, ctx: ctx, size: reducedSize)
                processLines(lines: lines, ctx: ctx, size: reducedSize) { line, size, y in
                    let paddedRect = CGRect(
                        x: lineWidth / 2,
                        y: y - verticalPadding + (lineWidth / 2),
                        width: size.width + (2 * horizontalPadding),
                        height: size.height + (2 * verticalPadding)
                    )
                    backgroundPath = backgroundPath.union(
                        Path(roundedRect: paddedRect, cornerSize: .init(width: cornerRadius, height: cornerRadius))
                    )
                }
                ctx.fill(
                    backgroundPath,
                    with: .palette([.color(white: 1, opacity: 0.8), .style(.regularMaterial)])
                )
                if lineWidth > 0 {
                    ctx.stroke(backgroundPath, with: .color(.black), lineWidth: lineWidth)
                }
                processLines(lines: lines, ctx: ctx, size: reducedSize) { line, size, y in
                    let rect = CGRect(origin: .init(x: horizontalPadding, y: y), size: size)
                    ctx.draw(Text(line), in: rect)
                }
            }
        }
    
        var body: some View {
            renderText(text: loremIpsum)
                .font(.title2)
                .padding()
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .background(Color(red: 0.871, green: 0.863, blue: 0.855))
        }
    }
    

    Screenshot

    A Canvas is greedy and consumes all the space available. If you want to restrict the height of the rendered text to the minimum necessary then you can use the technique of a hidden footprint, then show the Canvas in an overlay:

    Text(loremIpsum)
        .padding(.horizontal, horizontalPadding + (lineWidth / 2))
        .padding(.vertical, verticalPadding + (lineWidth / 2))
        .hidden()
        .overlay { renderText(text: loremIpsum) }
        .font(.title2)
        .padding()
        // .background(.yellow)
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(Color(red: 0.871, green: 0.863, blue: 0.855))
    

    It turns out that this reserves a little more height than necessary, because Text adds a little spacing between the wrapped lines. The function renderText does not include any spacing between lines, but you could add it if you wanted to. See also How to get the default LineSpacing of a font in SwiftUI?.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search