【问题标题】:Positioning View using anchor point使用锚点定位视图
【发布时间】:2019-12-10 17:16:43
【问题描述】:

我有几十个文本要定位,使其前导基线 (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())

这会定位字符串,使其中心位于所需点(标记为红色圆圈):

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

我尝试将.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)
     }
}

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

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

这会移动红点而不是文本,所以我不相信这是在正确的道路上。

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

【问题讨论】:

标签: swiftui


【解决方案1】:

因此,据我所知,目前还不能以这种方式使用对齐指南。希望这将很快到来,但与此同时,我们可以做一些填充和覆盖技巧来获得所需的效果。

注意事项

  • 您需要有某种方法来检索字体指标 - 我正在使用 CTFont 来初始化我的 Font 实例并以这种方式检索指标。
  • 据我所知,Playgrounds 并不总是代表 SwiftUI 布局在设备上的布局方式,并且会出现某些不一致的情况。我已经确定的一个是 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,这些 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))
}

第 2 步:在我们的主要 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)
        }
    }
}

这让我们来到这里:

第 3 步:基线偏移

接下来我们移动文本视图的基线,使其与封闭填充视图的底部齐平。这只是向我们的新glyphView(_:)函数添加一个填充修饰符的例子,它利用了我们上面定义的填充计算。

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

注意字形现在如何与其封闭视图的底部齐平。

第 4 步:添加叠加层

我们需要获取字形的指标,以便我们能够准确地放置它。但是,在我们提出观点之前,我们无法获得这些指标。解决此问题的一种方法是复制我们的视图并将一个视图用作原本隐藏的指标来源,然后呈现我们使用收集的指标定位的重复视图。

我们可以使用覆盖修饰符和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)

第 5 步:翻译

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

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)

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

第 7 步:隐藏我们的工作

最后一步是隐藏我们的源视图并将我们的叠加字形设置为正确的颜色:

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)

最终代码

//: 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 之前,它可能会让我们通过!

【讨论】:

  • 这真是令人难以置信的工作,并提供了许多我仍在思考的非常有用的想法。然而,问题在于它将position 放在底部中心而不是底部领先(这是我的计算所基于的;这就是 NSLayoutManager 和 Core Text 生成的)。由于每个字形框都有不同的宽度,因此单独的一般字体指标并不能提供一种简单的方法来水平调整点。所以这会产生不正确的间距,尤其是对于像 il 这样的窄字形。
  • 谢谢!很高兴它很有用。我已经更新了我的答案以适应上述情况,现在使用一种稍微不同的方法来解决问题。旧答案存档在这里:gist.github.com/tcldr/6169f16ec9dcb98f8ee5ebd7559a56d5
  • 再次感谢。这非常有用。昨晚我实现了你的底部中心版本,它实际上比底部引导效果更好(因为它更正确地将字形与曲线对齐)。我目前的解决方案是使用 CoreText 计算字形的所有边界框,并直接使用它们,但是您对使用负填充的洞察力是完成这项工作的关键。 (我的完整问题处理同一字符串中的多种字体,因此每个填充可能不同。)但是您的几何阅读器解决方案非常有趣,我可能需要考虑这一点。
  • 太棒了——很高兴它对你有用!覆盖解决方案的一个优点是它不依赖于手动计算 Text 视图高度,这似乎因平台而异,并且可能在 SwiftUI 的未来版本中发生变化。您应该能够进行调整,使其中心对齐也很容易。
  • @RobNapier 尝试检查我的答案,它关心字体指标,很容易更改位置参考点,并且它保留了文本的视觉外观及其背景和边框大小和样式.它还支持缩放等...
【解决方案2】:

此代码负责字体指标,并按照您的要求定位文本 (如果我正确理解了您的要求:-))

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

【讨论】:

    【解决方案3】:

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

    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)
    

    【讨论】:

    • 我相信你误解了目标。目标是,给定红点,将文本前导基线与其对齐。 (添加红点是为了展示该点的位置。)这仍然与中心对齐,并且还移动了应该是固定坐标的红点。
    • 我明白你的意思...无论如何,以前的变种也很有趣...请查看更新。
    • 感谢您的更新。我目前正在考虑涉及 lineHeight 的情况,但为了有用,我需要计算计算量更大的宽度(您的方法会错误地分隔字形,特别是在处理像 li 这样的窄字形时)。但是您对 offset+position 的使用是我没有考虑过的有趣组合。我需要考虑您的订购如何影响轮换。
    猜你喜欢
    • 2019-06-09
    • 2012-03-09
    • 2016-06-05
    • 2013-03-29
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2013-03-31
    • 2014-04-22
    相关资源
    最近更新 更多