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.

Indentation in SwiftUI?
 
 
Q