Indentation in SwiftUI?

I need to display verse so that if a line exceeds the right margin, it is continued on the next line but indented. In UIKit this is easy by using NSParagraphStyle and headIndent and firstLineHeadIndent.

But none of this is available on SwiftUI on the Apple Watch, which marks a big step back compared to WatchKit.

Is there any way to display text indented in this way? I attach two screenshots, one with the indentation and one without. The one with indentation is far more readable!

Answered by DTS Engineer in 897417022

SwiftUI Text ignores paragraph-style indentation — attributes like headIndent and firstLineHeadIndent are silently dropped, so what you see is kind of expected.

To achieve the text layout you described, I'd consider laying out the text with Core Text, and then rendering the lines with SwiftUI. The following code example shows how to do that. You can give it a try and share if it works for you:

import SwiftUI
import CoreText

private let kFontSize: CGFloat = 16

private func poem() -> NSAttributedString {
    let p1 = NSMutableParagraphStyle(); p1.firstLineHeadIndent =  0; p1.headIndent = 36
    let p2 = NSMutableParagraphStyle(); p2.firstLineHeadIndent = 18; p2.headIndent = 36
    let font = UIFont.systemFont(ofSize: kFontSize)
    func a(_ p: NSParagraphStyle) -> [NSAttributedString.Key: Any] { [.paragraphStyle: p, .font: font] }
    let ns = NSMutableAttributedString()
    ns.append(NSAttributedString(string: "He had forty-two boxes, all carefully packed,\n", attributes: a(p1)))
    ns.append(NSAttributedString(string: "With his name painted clearly on each:\n",         attributes: a(p2)))
    ns.append(NSAttributedString(string: "But since he omitted to mention the fact,\n",       attributes: a(p1)))
    ns.append(NSAttributedString(string: "They were all left behind on the beach.\n",         attributes: a(p2)))
    return ns
}

private func displayLines(from attrString: NSAttributedString, width: CGFloat) -> [(text: String, indent: CGFloat)] {
    let setter = CTFramesetterCreateWithAttributedString(attrString as CFAttributedString)
    let path   = CGPath(rect: CGRect(x: 0, y: 0, width: width, height: 1_000_000), transform: nil)
    let frame  = CTFramesetterCreateFrame(setter, CFRange(location: 0, length: 0), path, nil)
    let nsStr  = attrString.string as NSString

    return (CTFrameGetLines(frame) as! [CTLine]).compactMap { line in
        let r = CTLineGetStringRange(line)
        guard r.length > 0 else { return nil }
        let text = nsStr.substring(with: NSRange(location: r.location, length: r.length))
                        .trimmingCharacters(in: .newlines)
        guard !text.isEmpty else { return nil }
        let isFirst = r.location == 0 || nsStr.character(at: r.location - 1) == 10  // '\n'
        let style   = attrString.attribute(.paragraphStyle, at: r.location, effectiveRange: nil) as? NSParagraphStyle
        let indent  = isFirst ? (style?.firstLineHeadIndent ?? 0) : (style?.headIndent ?? 0)
        return (text: text, indent: indent)
    }
}

struct PoetryView: View {
    let attrString: NSAttributedString
    @State private var lines: [(text: String, indent: CGFloat)] = []

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            ForEach(lines.indices, id: \.self) { i in
                Text(lines[i].text)
                    .font(.system(size: kFontSize))
                    .padding(.leading, lines[i].indent)
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .lineLimit(1)
            }
        }
        .frame(maxWidth: .infinity)
        .background(
            GeometryReader { geo in
                Color.clear.onAppear {
                    let w = geo.size.width
                    guard w > 0, lines.isEmpty else { return }
                    lines = displayLines(from: attrString, width: w)
                }
            }
        )
    }
}

struct ContentView: View {
    var body: some View {
        ScrollView {
            PoetryView(attrString: poem())
                .padding()
        }
    }
}

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

SwiftUI Text ignores paragraph-style indentation — attributes like headIndent and firstLineHeadIndent are silently dropped, so what you see is kind of expected.

To achieve the text layout you described, I'd consider laying out the text with Core Text, and then rendering the lines with SwiftUI. The following code example shows how to do that. You can give it a try and share if it works for you:

import SwiftUI
import CoreText

private let kFontSize: CGFloat = 16

private func poem() -> NSAttributedString {
    let p1 = NSMutableParagraphStyle(); p1.firstLineHeadIndent =  0; p1.headIndent = 36
    let p2 = NSMutableParagraphStyle(); p2.firstLineHeadIndent = 18; p2.headIndent = 36
    let font = UIFont.systemFont(ofSize: kFontSize)
    func a(_ p: NSParagraphStyle) -> [NSAttributedString.Key: Any] { [.paragraphStyle: p, .font: font] }
    let ns = NSMutableAttributedString()
    ns.append(NSAttributedString(string: "He had forty-two boxes, all carefully packed,\n", attributes: a(p1)))
    ns.append(NSAttributedString(string: "With his name painted clearly on each:\n",         attributes: a(p2)))
    ns.append(NSAttributedString(string: "But since he omitted to mention the fact,\n",       attributes: a(p1)))
    ns.append(NSAttributedString(string: "They were all left behind on the beach.\n",         attributes: a(p2)))
    return ns
}

private func displayLines(from attrString: NSAttributedString, width: CGFloat) -> [(text: String, indent: CGFloat)] {
    let setter = CTFramesetterCreateWithAttributedString(attrString as CFAttributedString)
    let path   = CGPath(rect: CGRect(x: 0, y: 0, width: width, height: 1_000_000), transform: nil)
    let frame  = CTFramesetterCreateFrame(setter, CFRange(location: 0, length: 0), path, nil)
    let nsStr  = attrString.string as NSString

    return (CTFrameGetLines(frame) as! [CTLine]).compactMap { line in
        let r = CTLineGetStringRange(line)
        guard r.length > 0 else { return nil }
        let text = nsStr.substring(with: NSRange(location: r.location, length: r.length))
                        .trimmingCharacters(in: .newlines)
        guard !text.isEmpty else { return nil }
        let isFirst = r.location == 0 || nsStr.character(at: r.location - 1) == 10  // '\n'
        let style   = attrString.attribute(.paragraphStyle, at: r.location, effectiveRange: nil) as? NSParagraphStyle
        let indent  = isFirst ? (style?.firstLineHeadIndent ?? 0) : (style?.headIndent ?? 0)
        return (text: text, indent: indent)
    }
}

struct PoetryView: View {
    let attrString: NSAttributedString
    @State private var lines: [(text: String, indent: CGFloat)] = []

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            ForEach(lines.indices, id: \.self) { i in
                Text(lines[i].text)
                    .font(.system(size: kFontSize))
                    .padding(.leading, lines[i].indent)
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .lineLimit(1)
            }
        }
        .frame(maxWidth: .infinity)
        .background(
            GeometryReader { geo in
                Color.clear.onAppear {
                    let w = geo.size.width
                    guard w > 0, lines.isEmpty else { return }
                    lines = displayLines(from: attrString, width: w)
                }
            }
        )
    }
}

struct ContentView: View {
    var body: some View {
        ScrollView {
            PoetryView(attrString: poem())
                .padding()
        }
    }
}

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Thank you: this isn't a complete solution yet – mostly because Apple insist repeatedly that one should submit a minimum example of the problem, and that is exactly what I did, removing all features that weren't relevant to the specific problem!

Since the "minimum" example didn't include them, your solution ignores those features and there seems to be no way of putting them back in. There are three that are relevant:

  • You ignore and obliterate the font specified in the original NSAttributedString. In practice a full text would include (for example) headings which use a bold font, as well as lines and paragraphs using a normal font. Is there a way of adapting the solution to do this?
  • Following on from this, it is also necessary to have individual words have a font of their own (suppose, for instance, that in the NSAttributedString in the example, the words forty-two were bold). Is this doable?
  • I appreciate how you have taken the indentation properties from the individual parts of the string and applied them when constructing the Text items. Is it similarly possible to make paragraphSpacing and paragraphSpacingBefore work? We use them both, and need them.

A personal opinion. That's an example why I find SwiftUI problematic, compared to UIKit or WatchKit when you want precise control.

It's like trying to make lace with boxing gloves 😢

Yeah, my code example uses nsStr.substring(...) to extract the String directly, which ignores the attributes that the original NSAttributedString has. That is so that we can focus on the main idea: Using Core Text to layout the text into lines, and then rendering the text line by line.

To retain the original attributes, you can use attributedSubstring(...) instead, and then convert the result NSAttributedString to AttributedString, which SwiftUI.Text can render.

When converting NSAttributedString to AttributedString, however, you may still lose some attributes that NSAttributedString supports but AttributedString + Text doesn't. For those attributes, you can handle them by enumerating the attributes in NSAttributedString and mapping to something that AttributedString + Text supports. In that process, you decide the mapping based on your content, but as @Claude31 mentioned, you won't expect pixel precise control.

You can try with attributedSubstring(...) and see if the result looks good. A compromise, if appropriate to your use case, may be that you use only the attributes that AttributedString + Text supports, which avoids the manual mapping.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Indentation in SwiftUI?
 
 
Q