首页 > 解决方案 > 使用锚点定位视图

问题描述

我有几十个要定位的文本,以便它们的前导基线 ( 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 的图片,在他们的位置上处于领先地位

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

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

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

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

标签: swiftui

解决方案


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

注意事项

  • 您将需要某种方式来检索字体指标——我CTFont用来初始化我的Font实例并以这种方式检索指标。
  • 据我所知,Playgrounds 并不总是代表 SwiftUI 布局在设备上的布局方式,并且会出现某些不一致的情况。我已经确定的一个是displayScale环境值(和派生pixelLength值)在游乐场甚至预览中默认设置不正确。因此,如果您想要有代表性的布局 (FB7280058),则必须在这些环境中手动设置。

概述

我们将结合一些 SwiftUI 功能来获得我们想要的结果。具体来说,转换、覆盖和GeometryReader视图。

首先,我们将字形的基线与视图的基线对齐。如果我们有字体的度量,我们可以使用字体的“下降”来将我们的字形向下移动一点,使其与基线齐平——我们可以使用padding视图修饰符来帮助我们解决这个问题。

接下来,我们将用复制视图覆盖我们的字形视图。为什么?因为在叠加层中,我们能够获取下方视图的确切指标。事实上,我们的叠加层将是用户看到的唯一视图,原始视图将仅用于其指标。

几个简单的变换将我们的叠加层定位在我们想要的位置,然后我们将隐藏位于下方的视图以完成效果。

第 1 步:设置

首先,我们需要一些额外的属性来帮助我们进行计算。在适当的项目中,您可以将其组织成视图修改器或类似的东西,但为了简洁起见,我们会将它们添加到现有视图中。

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

我们还需要一个初始化为 a 的字体,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我们上面定义的 Text 视图:

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

glyphView(_:)第 2 步:在我们的主要body通话中采用我们的

这一步很简单,让我们采用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)
        }
    }
}

这让我们在这里:

第2步

第 3 步:基线偏移

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

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

第 3 步

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

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

第4步

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

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

在此处输入图像描述

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

第 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 允许我们使用对齐锚来锚定我们的变换之前,它可能会让我们通过!


推荐阅读