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!
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.