3

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 ... Unfortunately, I didn't find the way how to do it. All the tricks known to get some geometry (alignmentGuide, GeometryReader, anchorPreferences ...) works as View modifiers! This means the Text + operator is unusable. Simply calculate the position of characters in Text as a sum of Text(String(Character)) widths doesn't work, for example

Text("WAW")

and

HStack(spacing:0) { Text("W"); Text("A"); Text("W") }

width is (as expected) different.

Finally I got (use copy paste to check it) something like

struct ContentView: View {
    @State var width: [CGFloat] = []
    let font = Font.system(size: 100)
    var body: some View {
        VStack {
            if width.isEmpty {
                text(t: Text("W").font(font), width: $width)
                text(t: Text("WA").font(font), width: $width)
                text(t: Text("WAW").font(font), width: $width)
                text(t: Text("WAWE").font(font), width: $width)
            } else {
                ZStack(alignment: .topLeading) {
                    Text("WAWE").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(Array(width.sorted(by: <).enumerated()), id: \.0) { p in
                        ZStack {
                            Path { path in
                                path.move(to: CGPoint(x: p.1, y: 0))
                                path.addLine(to: CGPoint(x: p.1, y: 150))
                            }.stroke(lineWidth: 1)

                            Text("\(p.1)").rotationEffect(Angle(degrees: 90), anchor: .bottom).position(CGPoint(x: p.1, y: 170))
                        }
                    }
                }.padding()
            }
        }
    }
}
func text(t: Text, width: Binding<[CGFloat]>)->some View {
    let tt = t.background(
        GeometryReader{ proxy->Color in
            DispatchQueue.main.async {
                width.wrappedValue.append(proxy.size.width)
            }
            return Color.clear
        }
    )
    return tt.background(Color.yellow)
}

with this result

enter image description here

Which works but is very hacking solution

I am looking for the better way!

UPDATE with center of each character enter image description here

user3441734
  • 16,722
  • 2
  • 40
  • 59

1 Answers1

3

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:

enter image description here

In right-to-left text, these divisions are just completely wrong.

enter image description here

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

Rob Napier
  • 286,113
  • 34
  • 456
  • 610
  • Rob, I understand that, but this approach is enough to position an overlay, over each character, or for TextFolowingPath component, and so on. It is also effective by performance point of view for such a things and finally it works with iOS dynamic fonts. Here there is end of each character, but it works with center or leading as well. I am going update the question with such an example ... – user3441734 Jan 21 '20 at 00:18
  • Building a TextFollowingPath component is exactly how I discovered that Text is insufficient for this problem. You can get something that somewhat works, but it has many subtle and not-so-subtle limitations. If you want to see this in action, look at https://github.com/rnapier/CurvyText. To see a version of what you're trying to do here using Text, see the swiftui branch. Trying to correctly place combining characters was what finally made me give up on the approach. If you're only going to lay out English in standard fonts, it can work. But anything more, and it starts to fall apart. – Rob Napier Jan 21 '20 at 13:36
  • A few test cases I found useful: `العربية` (Arabic), `ÅX̊Z` (comparing ring-above A and ring-above X; the rings should line up), Zapfino font (particularly strings like `The` and `Zapfino` which employ interesting ligatures). – Rob Napier Jan 21 '20 at 13:42
  • thank for very informative answer! between I am using preference too and almost identical source code .-), unfortunately offset of baseline from centerline is available only from alignmentGuide (preference is not usable there). what i am trying to do is to animate english text which looks like snake moving .-) (grand children request) – user3441734 Jan 21 '20 at 15:31
  • Try out the CurvyText project, specifically the CurvyText target (rather than the PathTextDemo). It lets you drag around a bezier curve with text on it using your finger. You should be able to use that as a starting point. And if you prefer a pure-SwiftUI approach, the swiftui branch is all Text views (though it does use NSTextLayoutManager for metrics). The SwiftUI version works fine for English. See also https://stackoverflow.com/questions/59272419/positioning-view-using-anchor-point (though in the end I think I fixed it another way) – Rob Napier Jan 21 '20 at 15:49
  • 1
    Thank, Rob! By the way, I already have an answer on the mentioned question I am happy with (position and rotate character). for positioning i am using my own bezier math with mapping from length to t, which gives you point, tangent and local curvature. All works now with ArrayOfTextWithSingleChar and now with generic Text (almost fine) – user3441734 Jan 21 '20 at 16:19