My first idea was based on Text + operator. Seems to be easy, constructing the whole Text by composition /one by one character/ and check the width of partial result ... Unfortu
This approach will not work. Text layout of a string is dramatically different than the layout of individual characters. The thing you're addressing in this is kerning, but you still have ligatures, composing characters, and letter forms (particularly in Arabic) to deal with. Text is the wrong tool here.
You really can't do this in SwiftUI. You need to use CoreText (CTLine) or TextKit (NSLayoutManager).
That said, this is not promised to exactly match Text. We don't know what kinds of things Text does. For example, will it tighten spacing when presented with a smaller frame than it desires? We don't know, and we can't ask it (and this approach won't handle it if it does). But CoreText and TextKit will at least give you reliable answers, and you can use them to layout text yourself that matches the metrics you generate.
While I don't think this approach is how you want to do it, the code itself can be improved. First, I recommend a preference rather calling async inside of a GeometryReader.
struct WidthKey: PreferenceKey {
static var defaultValue: [CGFloat] = []
static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) {
value.append(contentsOf: nextValue())
}
}
You can capture the width data into that with:
extension View {
func captureWidth() -> some View {
background(GeometryReader{ g in
Color.clear.preference(key: WidthKey.self, value: [g.size.width])
})
}
}
This will be read later with an onPreferenceChange
:
.onPreferenceChange(WidthKey.self) { self.widths = $0 }
And as a helper on the string:
extension String {
func runs() -> [String] {
indices.map { String(prefix(through: $0)) }
}
}
With all that, we can write a captureWidths() function that captures all the widths, but hides the result:
func captureWidths(_ string: String) -> some View {
Group {
ForEach(string.runs(), id: \.self) { s in
Text(verbatim: s).captureWidth()
}
}.hidden()
}
Notice that the font isn't set. That's on purpose, it'll be called like this:
captureWidths(string).font(font)
That applies .font
to the Group, which applies it to all the Texts inside it.
Also notice the use of verbatim
here (and later when creating the final Text). Strings passed to Text aren't literal by default. They're localization keys. That means you need to look up the correct localized value to break down the characters. That adds some complexity I'm assuming you don't want, so you should be explicit and say this string is verbatim (literal).
And all together:
struct WidthKey: PreferenceKey {
static var defaultValue: [CGFloat] = []
static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) {
value.append(contentsOf: nextValue())
}
}
extension View {
func captureWidth() -> some View {
background(GeometryReader{ g in
Color.clear.preference(key: WidthKey.self, value: [g.size.width])
})
}
}
extension String {
func runs() -> [String] {
indices.map { String(prefix(through: $0)) }
}
}
func captureWidths(_ string: String) -> some View {
Group {
ForEach(string.runs(), id: \.self) { s in
Text(s).captureWidth()
}
}.hidden()
}
struct ContentView: View {
@State var widths: [CGFloat] = []
@State var string: String = "WAWE"
let font = Font.system(size: 100)
var body: some View {
ZStack(alignment: .topLeading) {
captureWidths(string).font(font)
Text(verbatim: string).font(font).border(Color.red)
Path { path in
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: 0, y: 150))
}.stroke(lineWidth: 1)
Text("\(0)").rotationEffect(Angle(degrees: 90), anchor: .bottom)
.position(CGPoint(x: 0, y: 170))
ForEach(widths, id: \.self) { p in
ZStack {
Path { path in
path.move(to: CGPoint(x: p, y: 0))
path.addLine(to: CGPoint(x: p, y: 150))
}.stroke(lineWidth: 1)
Text("\(p)").rotationEffect(Angle(degrees: 90), anchor: .bottom).position(CGPoint(x: p, y: 170))
}
}
}
.padding()
.onPreferenceChange(WidthKey.self) { self.widths = $0 }
}
}
To see how this algorithm behaves for things that aren't simple, though:
In right-to-left text, these divisions are just completely wrong.
Note how the T box is much too narrow. That's because in Zapfino, The Th ligature is much wider than the letter T plus the letter h. (In fairness, Text can barely handle Zapfino at all; it almost always clips it. But the point is that ligatures can significantly change layout, and exist in many fonts.)