使用锚点定位视图

12

我有几十个文本需要定位,使它们的基线 (lastTextBaseline) 在特定坐标上。然而,position 只能设置中心位置。例如:

import SwiftUI
import PlaygroundSupport
struct Location: Identifiable {
    let id = UUID()
    let point: CGPoint
    let angle: Double
    let string: String
}

let locations = [
    Location(point: CGPoint(x: 54.48386479999999, y: 296.4645408), angle: -0.6605166885682314, string: "Y"),
    Location(point: CGPoint(x: 74.99159120000002, y: 281.6336352), angle: -0.589411952788817, string: "o"),
]

struct ContentView: View {
    var body: some View {
        ZStack {
            ForEach(locations) { run in
                Text(verbatim: run.string)
                    .font(.system(size: 48))
                    .border(Color.green)
                    .rotationEffect(.radians(run.angle))
                    .position(run.point)

                Circle()  // Added to show where `position` is
                    .frame(maxWidth: 5)
                    .foregroundColor(.red)
                    .position(run.point)
            }
        }
    }
}

PlaygroundPage.current.setLiveView(ContentView())

这将定位字符串,使其中心在所需位置(标记为红色圆圈):

Y、o在它们的位置上居中的图像

我想将其调整为使前导基线位于此红点处。在此示例中,正确的布局会将字形向上移动并向右移动。

我尝试将.topLeading对齐添加到ZStack,然后使用offset而不是position。这将让我基于顶部前导角对齐,但那不是我想要布局的角落。例如:

ZStack(alignment: .topLeading) { // add alignment
    Rectangle().foregroundColor(.clear) // to force ZStack to full size
    ForEach(locations) { run in
        Text(verbatim: run.string)
            .font(.system(size: 48))
            .border(Color.green)
            .rotationEffect(.radians(run.angle), anchor: .topLeading) // rotate on top-leading
            .offset(x: run.point.x, y: run.point.y)
     }
}

Y、o、with top-leading at their locations的图像

我还尝试更改文本的“top”对齐指南:

.alignmentGuide(.top) { d in d[.lastTextBaseline]}

这将移动红点而不是文本,因此我认为这不是正确的方法。

我考虑尝试调整位置本身,以考虑文本的大小(我可以使用Core Text进行预测),但我希望避免计算大量额外的包围框。


我不确定我从描述中正确地理解了你要得到的结果。你能提供一个模拟或其他东西吗? - Asperi
@Asperi,我目前正在完全使用SwiftUI重建我的以前的工作:https://github.com/rnapier/CurvyText。另请参阅https://stackoverflow.com/a/59219561/97337,了解问题的核心。 - Rob Napier
3个回答

11
据我所知,目前无法以这种方式使用对齐指南。希望不久将来能够实现,但在此期间,我们可以通过一些填充和叠加技巧来实现所需效果。
注意事项:
- 您需要有某种检索字体度量的方法——我正在使用CTFont初始化我的Font实例并通过此方式检索度量。 - 就我所知,Playgrounds并不能总是代表SwiftUI布局在设备上的布局方式,并且会出现某些不一致之处。我已经确定的一个问题是,在playgrounds甚至预览中,默认情况下未正确设置displayScale环境值(以及派生的pixelLength值)。因此,在这些环境中,如果您想要一个代表性的布局(FB7280058),则必须手动设置它。
概述:
我们将结合一些SwiftUI功能来实现所需结果。具体来说,是变换、叠加和GeometryReader视图。
首先,我们将使字形的基线与视图的基线对齐。如果我们有字体度量,我们可以使用字体的“下降”将我们的字形向下移动一点,使其与基线齐平——我们可以使用padding视图修饰符来帮助我们完成此操作。
接下来,我们将用重复视图覆盖我们的字形视图。为什么?因为在叠加中,我们能够获取底部视图的精确度量。实际上,我们的叠加将是用户看到的唯一视图,原始视图只用于其度量。

一些简单的变换将把我们的覆盖层定位到我们想要的位置,然后隐藏底下的视图以完成效果。

步骤1:设置

首先,我们需要一些额外的属性来帮助我们进行计算。在一个正式的项目中,您可以将其组织成视图修饰符或类似的内容,但为了简洁起见,我们将它们添加到现有视图中。

@Environment(\.pixelLength) var pixelLength: CGFloat
@Environment(\.displayScale) var displayScale: CGFloat

我们还需要将字体初始化为CTFont,以便我们可以获取其指标:
let baseFont: CTFont = {
    let desc = CTFontDescriptorCreateWithNameAndSize("SFProDisplay-Medium" as CFString, 0)
    return CTFontCreateWithFontDescriptor(desc, 48, nil)
}()

然后进行一些计算。这些计算会为文本视图计算一些EdgeInsets,从而将文本视图的基线移动到包含填充视图的底部边缘:

var textPadding: EdgeInsets {
    let baselineShift = (displayScale * baseFont.descent).rounded(.down) / displayScale
    let baselineOffsetInsets = EdgeInsets(top: baselineShift, leading: 0, bottom: -baselineShift, trailing: 0)
    return baselineOffsetInsets
}

我们还将向CTFont添加一些辅助属性:

extension CTFont {
    var ascent: CGFloat { CTFontGetAscent(self) }
    var descent: CGFloat { CTFontGetDescent(self) }
}

最后,我们创建一个新的辅助函数来生成我们的文本视图,该函数使用我们上面定义的CTFont

private func glyphView(for text: String) -> some View {
    Text(verbatim: text)
        .font(Font(baseFont))
}

步骤二:在主体body中使用我们的glyphView(_:)

这一步很简单,让我们使用我们上面定义的glyphView(_:)辅助函数:

var body: some View {
    ZStack {
        ForEach(locations) { run in
            self.glyphView(for: run.string)
                .border(Color.green, width: self.pixelLength)
                .position(run.point)

            Circle()  // Added to show where `position` is
                .frame(maxWidth: 5)
                .foregroundColor(.red)
                .position(run.point)
        }
    }
}

这将带我们到这里:

Step 2

步骤三:基线偏移

接下来,我们将文本视图的基线偏移,使其与包含填充视图的底部对齐。这只需要在我们新的glyphView(_:)函数中添加一个填充修饰符,利用我们上面定义的填充计算即可。

private func glyphView(for text: String) -> some View {
    Text(verbatim: text)
        .font(Font(baseFont))
        .padding(textPadding) // Added padding modifier
}

Step 3

注意到字形现在与其所属视图的底部对齐。
第四步:添加覆盖
我们需要获取字形的度量,以便能够准确地放置它。但是,在布局视图之前,我们无法获取这些指标。解决这个问题的一种方法是复制我们的视图并使用一个作为度量来源但又隐藏的视图,然后呈现一个重复的视图,我们使用已收集的指标来定位它。
我们可以使用覆盖修饰符和GeometryReader视图来实现此目的。我们还将添加紫色边框并使我们的覆盖文本蓝色以区分它与前一步骤。
self.glyphView(for: run.string)
    .border(Color.green, width: self.pixelLength)
    .overlay(GeometryReader { geometry in
        self.glyphView(for: run.string)
            .foregroundColor(.blue)
            .border(Color.purple, width: self.pixelLength)
    })
    .position(run.point)

Step 4

步骤五:翻译

利用我们现在可以使用的指标,我们可以将叠加层向上和向右移动,以便字形视图的左下角位于我们的红色定位点上方。

self.glyphView(for: run.string)
    .border(Color.green, width: self.pixelLength)
    .overlay(GeometryReader { geometry in
        self.glyphView(for: run.string)
            .foregroundColor(.blue)
            .border(Color.purple, width: self.pixelLength)
            .transformEffect(.init(translationX: geometry.size.width / 2, y: -geometry.size.height / 2))
    })
    .position(run.point)

Step 5

步骤6:旋转

现在我们的视图已经就位,我们终于可以进行旋转了。

self.glyphView(for: run.string)
    .border(Color.green, width: self.pixelLength)
    .overlay(GeometryReader { geometry in
        self.glyphView(for: run.string)
            .foregroundColor(.blue)
            .border(Color.purple, width: self.pixelLength)
            .transformEffect(.init(translationX: geometry.size.width / 2, y: -geometry.size.height / 2))
            .rotationEffect(.radians(run.angle))
    })
    .position(run.point)

enter image description here

步骤七:隐藏我们的操作过程

最后一步是隐藏我们的源代码视图,并将覆盖层图标设置为其适当的颜色:

self.glyphView(for: run.string)
    .border(Color.green, width: self.pixelLength)
    .hidden()
    .overlay(GeometryReader { geometry in
        self.glyphView(for: run.string)
            .foregroundColor(.black)
            .border(Color.purple, width: self.pixelLength)
            .transformEffect(.init(translationX: geometry.size.width / 2, y: -geometry.size.height / 2))
            .rotationEffect(.radians(run.angle))
    })
    .position(run.point)

Step 7

最终代码

//: A Cocoa based Playground to present user interface

import SwiftUI
import PlaygroundSupport

struct Location: Identifiable {
    let id = UUID()
    let point: CGPoint
    let angle: Double
    let string: String
}

let locations = [
    Location(point: CGPoint(x: 54.48386479999999, y: 296.4645408), angle: -0.6605166885682314, string: "Y"),
    Location(point: CGPoint(x: 74.99159120000002, y: 281.6336352), angle: -0.589411952788817, string: "o"),
]

struct ContentView: View {

    @Environment(\.pixelLength) var pixelLength: CGFloat
    @Environment(\.displayScale) var displayScale: CGFloat

    let baseFont: CTFont = {
        let desc = CTFontDescriptorCreateWithNameAndSize("SFProDisplay-Medium" as CFString, 0)
        return CTFontCreateWithFontDescriptor(desc, 48, nil)
    }()

    var textPadding: EdgeInsets {
        let baselineShift = (displayScale * baseFont.descent).rounded(.down) / displayScale
        let baselineOffsetInsets = EdgeInsets(top: baselineShift, leading: 0, bottom: -baselineShift, trailing: 0)
        return baselineOffsetInsets
    }

    var body: some View {
        ZStack {
            ForEach(locations) { run in
                self.glyphView(for: run.string)
                    .border(Color.green, width: self.pixelLength)
                    .hidden()
                    .overlay(GeometryReader { geometry in
                        self.glyphView(for: run.string)
                            .foregroundColor(.black)
                            .border(Color.purple, width: self.pixelLength)
                            .transformEffect(.init(translationX: geometry.size.width / 2, y: -geometry.size.height / 2))
                            .rotationEffect(.radians(run.angle))
                    })
                    .position(run.point)

                Circle()  // Added to show where `position` is
                    .frame(maxWidth: 5)
                    .foregroundColor(.red)
                    .position(run.point)
            }
        }
    }

    private func glyphView(for text: String) -> some View {
        Text(verbatim: text)
            .font(Font(baseFont))
            .padding(textPadding)
    }
}

private extension CTFont {
    var ascent: CGFloat { CTFontGetAscent(self) }
    var descent: CGFloat { CTFontGetDescent(self) }
}

PlaygroundPage.current.setLiveView(
    ContentView()
        .environment(\.displayScale, NSScreen.main?.backingScaleFactor ?? 1.0)
        .frame(width: 640, height: 480)
        .background(Color.white)
)

就是这样。虽然不完美,但在SwiftUI提供允许我们使用对齐锚点来锚定转换的API之前,它可能会帮助我们解决问题!


1
这真是惊人的工作,提供了许多非常有用的想法,我仍在思考中。然而,问题在于它将"position"放在底部中心而不是底部顶端(这是我的计算基础;这是NSLayoutManager和Core Text生成的)。由于每个字形框的宽度都不同,一般的字体度量本身并没有提供一个简单的方法来水平调整点。因此,这将导致不正确的间距,尤其是对于像"i"和"l"这样的窄字形。 - Rob Napier
谢谢!很高兴它有用。我已经更新了我的答案以适应上述情况,现在使用稍微不同的方法来解决问题。旧答案存档在这里:https://gist.github.com/tcldr/6169f16ec9dcb98f8ee5ebd7559a56d5 - Tricky
1
再次感谢。这对我非常有用。昨晚我实现了您的底部中心版本,它实际上比底部前导版本效果更好(因为它更正确地将字形与曲线对齐)。我的当前解决方案是使用CoreText计算所有字形的边界框,并直接使用它们,但是您关于使用负填充的见解是使其正常工作的关键。(我的完整问题处理同一字符串中的多个字体,因此每个填充可能不同。)但是您的几何阅读器解决方案非常有趣,我可能需要考虑一下。 - Rob Napier
1
太好了 - 很高兴它对你有用!叠加解决方案的一个优点是它不依赖手动计算文本视图高度,这似乎在不同平台上有所不同,并且在SwiftUI的未来版本中可能会发生变化。您应该能够轻松地调整使其居中对齐。 - Tricky
@RobNapier 请尝试检查我的答案,它考虑了字体度量,易于更改位置参考点,并保留文本及其背景和边框大小和样式的视觉外观。它还支持缩放等功能... - user3441734

1
这段代码处理字体度量,并根据您的要求定位文本(如果我正确理解了您的要求 :-))。
import SwiftUI
import PlaygroundSupport


struct BaseLine: ViewModifier {
    let alignment: HorizontalAlignment
    @State private var ref = CGSize.zero
    private var align: CGFloat {
        switch alignment {
        case .leading:
            return 1
        case .center:
            return 0
        case .trailing:
            return -1
        default:
            return 0
        }
    }
    func body(content: Content) -> some View {
        ZStack {
            Circle().frame(width: 0, height: 0, alignment: .center)
        content.alignmentGuide(VerticalAlignment.center) { (d) -> CGFloat in
            DispatchQueue.main.async {
                self.ref.height =  d[VerticalAlignment.center] - d[.lastTextBaseline]
                self.ref.width = d.width / 2
            }
            return d[VerticalAlignment.center]
        }
        .offset(x: align * ref.width, y: ref.height)
        }
    }
}

struct ContentView: View {
    var body: some View {
        ZStack {

            Cross(size: 20, color: Color.red).position(x: 200, y: 200)
            Cross(size: 20, color: Color.red).position(x: 200, y: 250)
            Cross(size: 20, color: Color.red).position(x: 200, y: 300)
            Cross(size: 20, color: Color.red).position(x: 200, y: 350)


            Text("WORLD").font(.title).border(Color.gray).modifier(BaseLine(alignment: .trailing))
                .rotationEffect(.degrees(45))
                .position(x: 200, y: 200)

            Text("Y").font(.system(size: 150)).border(Color.gray).modifier(BaseLine(alignment: .center))
            .rotationEffect(.degrees(45))
            .position(x: 200, y: 250)

            Text("Y").font(.system(size: 150)).border(Color.gray).modifier(BaseLine(alignment: .leading))
            .rotationEffect(.degrees(45))
            .position(x: 200, y: 350)

            Text("WORLD").font(.title).border(Color.gray).modifier(BaseLine(alignment: .leading))
                .rotationEffect(.degrees(225))
                .position(x: 200, y: 300)

        }
    }
}

struct Cross: View {
    let size: CGFloat
    var color = Color.clear
    var body: some View {
            Path { p in
                p.move(to: CGPoint(x: size / 2, y: 0))
                p.addLine(to: CGPoint(x: size / 2, y: size))
                p.move(to: CGPoint(x: 0, y: size / 2))
                p.addLine(to: CGPoint(x: size, y: size / 2))
            }
            .stroke().foregroundColor(color)
            .frame(width: size, height: size, alignment: .center)
    }
}

PlaygroundPage.current.setLiveView(ContentView())

enter image description here


0

更新:您可以尝试以下变体

Letter at point

let font = UIFont.systemFont(ofSize: 48)
var body: some View {
    ZStack {
        ForEach(locations) { run in
            Text(verbatim: run.string)
                .font(Font(self.font))
                .border(Color.green)
                .offset(x: 0, y: -self.font.lineHeight / 2.0)
                .rotationEffect(.radians(run.angle))
                .position(run.point)

            Circle()  // Added to show where `position` is
                .frame(maxWidth: 5)
                .foregroundColor(.red)
                .position(run.point)
        }
    }
}

还有一种有趣的变体,可以使用ascender代替上面的lineHeight

.offset(x: 0, y: -self.font.ascender / 2.0)

enter image description here


我相信你误解了目标。目标是,给定红色点,将文本的前导基线与其对齐。(红色点是为了演示该点的位置而添加的。)这仍然是居中对齐,并且移动了红色点,这些点应该是固定坐标。 - Rob Napier
我明白你的意思了...不管怎样,之前的版本也很有趣...请看更新后的。 - Asperi
感谢更新。我目前正在考虑涉及 lineHeight 的情况,但要有用,我需要计算更多计算量密集的宽度(您的方法将在处理像“l”和“i”这样的窄字形时不正确地放置字形)。但是您使用偏移量+位置的组合是一个我没有考虑过的有趣组合。我需要思考您的排序如何影响旋转。 - Rob Napier

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接