要让叠加层与按比例适配的 SwiftUI 图表保持对齐,请测量适配后的图表本身,并在该局部坐标空间中放置标记。请在任何会增加空白区域的外部 frame 之前添加测量用的 overlay。

本教程将构建一个小型 macOS 应用,其中包含一张合成图表和两个编号标记。目标是在窗口形状改变时,让每个标记始终位于图表对应特征的中心。实现使用归一化坐标,因此注释位置不受窗口尺寸影响。

为什么叠加层会偏离按比例适配的图表?

按比例适配会在保留图表比例的同时,将其放入可用空间。根据容器的比例,图表上方和下方或两侧可能会留出空白区域。Apple 在将图像适配到可用空间中说明了这一行为。

当图稿和标记使用不同的矩形时,就会发生偏移。例如,图稿可能位于一个 600×700 点的容器内,居中的矩形尺寸为 600×375 点。如果根据容器高度的比例来定位标记,计算时就会把周围的空白区域也算进去。

修饰符的顺序决定了你测量的是哪个矩形。Apple 说明,视图修饰符会包裹接收它的视图,而 frame 会将其子视图放置在另一个矩形中。因此,添加在扩展 frame 之后的 overlay,测量到的布局边界与直接添加在适配后图稿上的 overlay 不同。请参阅 Apple 的 View 文档和灵活 frame 文档。

对于本任务,请按以下顺序操作:

  1. 绘制可伸缩的图稿。
  2. 使用 .fit 设置明确的宽高比。
  3. 添加包含 GeometryReader 的 overlay。
  4. 使用该 reader 的尺寸来定位标记。
  5. 添加外部 frame 和周围的内边距。

Apple 建议在需要让被修饰视图主导布局时使用 overlay。这样,图表就能作为布局主体,注释则叠加在其上。在 ZStack 中并列放置视图的布局行为有所不同,需要更仔细地考虑由哪个视图决定尺寸。请参阅 overlay(alignment:content:)。

构建完整的 macOS 示例

在 Xcode 中创建一个使用 SwiftUI 界面的 macOS App 项目。将部署目标设为 macOS 12 或更高版本,因为这里使用的闭包形式 overlay API 从 macOS 12 起可用。用下面的完整程序替换自动生成的 App 入口文件,并确保目标中只保留一个 @main App 声明。该 API 的可用性见 overlay(alignment:content:)。

合成图稿表示一个 800×500 的设计空间,宽高比为 1.6。标记坐标是该空间中的比例值:水平坐标零表示左边缘,垂直坐标零表示上边缘,一表示相对的另一边缘。无需资产文件或外部包。

import SwiftUI

@main
struct DiagramAlignmentApp: App {
    var body: some Scene {
        WindowGroup {
            DiagramExample()
                .frame(minWidth: 360, minHeight: 260)
        }
    }
}

struct DiagramMarker: Identifiable {
    let id: Int
    let label: String
    let x: CGFloat
    let y: CGFloat
}

struct DiagramExample: View {
    let markers = [
        DiagramMarker(id: 1, label: "1", x: 0.25, y: 0.40),
        DiagramMarker(id: 2, label: "2", x: 0.75, y: 0.60)
    ]

    var body: some View {
        Rectangle()
            .fill(Color.white)
            .overlay {
                DiagramArtwork()
                    .stroke(Color.gray, lineWidth: 2)
            }
            .aspectRatio(800.0 / 500.0, contentMode: .fit)
            .overlay {
                GeometryReader { geometry in
                    ForEach(markers) { marker in
                        Circle()
                            .fill(Color.blue)
                            .frame(width: 28, height: 28)
                            .overlay {
                                Text(marker.label)
                                    .font(.caption.bold())
                                    .foregroundColor(.white)
                            }
                            .position(
                                x: marker.x * geometry.size.width,
                                y: marker.y * geometry.size.height
                            )
                    }
                }
            }
            .border(Color.gray)
            .frame(
                maxWidth: .infinity,
                maxHeight: .infinity,
                alignment: .center
            )
            .padding(24)
            .background(Color.gray.opacity(0.12))
    }
}

struct DiagramArtwork: Shape {
    func path(in rect: CGRect) -> Path {
        var path = Path()

        path.addRect(CGRect(
            x: rect.minX + 0.10 * rect.width,
            y: rect.minY + 0.20 * rect.height,
            width: 0.30 * rect.width,
            height: 0.40 * rect.height
        ))

        path.addRect(CGRect(
            x: rect.minX + 0.60 * rect.width,
            y: rect.minY + 0.40 * rect.height,
            width: 0.30 * rect.width,
            height: 0.40 * rect.height
        ))

        path.move(to: CGPoint(
            x: rect.minX + 0.25 * rect.width,
            y: rect.minY + 0.40 * rect.height
        ))
        path.addLine(to: CGPoint(
            x: rect.minX + 0.75 * rect.width,
            y: rect.minY + 0.60 * rect.height
        ))

        return path
    }
}

运行 App,并分别水平和垂直调整窗口大小。标记 1 应保持在第一个矩形的中心,标记 2 应保持在第二个矩形的中心。细边框标出适配后图表的边界;灰色周边区域属于外部布局。

为什么这种修饰符顺序有效

基础矩形提供可伸缩的图稿,而 .aspectRatio(800.0 / 500.0, contentMode: .fit) 则约束其比例。Apple 将宽高比参数定义为宽度除以高度,并规定适配结果适用于可调整大小的内容。请参阅 aspectRatio(_:contentMode:)。

测量用的 overlay 紧跟在该约束之后。GeometryReader 根据自身尺寸和坐标空间定义其内容,而 geometry.size 会报告容器的尺寸。此处,该容器覆盖适配后的矩形。因此,将归一化坐标分别乘以其宽度和高度,就能得到标记的局部位置。请参阅 GeometryReader 和 GeometryProxy.size。

最后添加的扩展 frame 会将完整的图表和标记组合居中放置在可用区域内。由于标记已经附加到内部矩形上,因此无需计算外部边距。

保持绘制和定位的约定

DiagramArtwork 遵循 Shape 协议,其 path(in:) 方法返回一个基于所提供矩形定义的 Path。它不会返回视图,也不会执行布局变更。这符合 Apple 文档中关于 path(in:) 的约定。

每个标记的 .position(x:y:) 会将其中心放在计算所得的点上,而不是将左上角放在那里。本示例有意将每个编号圆形的中心锚定到图表特征上,这与 Apple 对 position(x:y:) 的定义一致。

窗口调整大小时,圆形仍保持 28 点宽,而它们的中心会跟随图稿移动。这是为了让注释清晰易读而做出的呈现选择。如果标记尺寸代表图稿中的距离,也应根据适配后的尺寸计算这些尺寸。

通过坐标计算检查对齐

数值检查有助于区分坐标错误和样式问题。对于居中适配,设正的源尺寸为 sourceWidth 和 sourceHeight,有限的可用尺寸为 containerWidth 和 containerHeight。以下是数学伪代码,并非单独的 Swift 程序:

scale = min(containerWidth / sourceWidth,
            containerHeight / sourceHeight)

fittedWidth = sourceWidth * scale
fittedHeight = sourceHeight * scale

originX = (containerWidth - fittedWidth) / 2
originY = (containerHeight - fittedHeight) / 2

containerX = originX + normalizedX * fittedWidth
containerY = originY + normalizedY * fittedHeight

这些公式根据文档说明的适配行为,推导出等比缩放和居中放置的结果。使用示例中的 800×500 源图,并将标记 1 放在 (0.25, 0.40),可得到以下结果。此处的容器尺寸指周围内边距处理后的可用区域。

可用容器 适配后图表 图表在容器中的原点 标记在容器中的中心点
1000 × 700 1000 × 625 (0, 37.5) (250, 287.5)
600 × 700 600 × 375 (0, 162.5) (150, 312.5)
1000 × 400 640 × 400 (180, 0) (340, 160)

在中间一行中,将整个容器的高度乘以 0.40 得到 280,与正确的垂直位置 312.5 不符。在示例的局部 overlay 中,垂直坐标只需使用 0.40 × 375 = 150;外层布局会处理 162.5 点的边距。

表格中的计算通过一个小型、结果确定的 Python 计算进行了核对。本环境中仅检查了 SwiftUI 程序,并未进行编译;请在目标 Mac 上使用 Xcode 验证其渲染效果。

将此模式应用于现有图表

对于基于图像的图表,在应用按比例适配和 overlay 修饰符之前,先让 Image 可调整大小。Apple 的图像适配指南说明,图像默认会以原始尺寸渲染,而且仅使用 .resizable() 会分别缩放各个轴。保留图像的实际宽高比,然后在外部 frame 之前添加标记 overlay。请参阅将图像适配到可用空间。

如果注释最初使用的是源图坐标,请按照相同的完整图稿边界将其归一化:用源图的宽度除源图水平坐标,用源图的高度除源图垂直坐标。图像内部的透明边距也属于这些边界。如果坐标是针对裁剪区域测量的,请先将其转换到完整图像的坐标系,再使用此模式。

在 Image 上应用特定于图像的修饰符,然后再由通用视图修饰符进行包裹。同样,对于组合视图,保留 var body: some View;对于形状的绘制方法,保留 Path 返回类型。Apple 在 View 中说明了自定义视图组合和修饰符包裹方式。

此计算假定进行等比适配,且没有额外裁剪、旋转或独立的平移缩放变换。如果添加了这些操作,请对图稿和注释应用相同的变换,或明确扩展坐标映射。改用 .fill 会改变适配条件,并可能使内容超出 frame;Apple 在将图像适配到可用空间中将裁剪说明为单独的操作。

诊断剩余的对齐问题

请按以下顺序检查:

  1. 测量边界: 将标记 overlay 放在外部扩展 frame 之前。它的边框应贴合图稿,而不是周围的空白区域。
  2. 坐标范围: 确认归一化坐标使用完整的源矩形,并采用预期的左上角原点。对于本示例,预期范围内的值从零到一;遇到意外值时应拒绝,而不是静默地限制其范围。
  3. 锚点选择: 请记住,.position(x:y:) 锚定的是标记中心。如果要让标签的某个角接触图表特征,需要明确添加偏移量或采用不同的锚定策略。
  4. 较小的布局: 即使标记中心保持正确位置,也可能与其他标记重叠。请为小尺寸下隐藏或重新排列标签制定单独规则。
  5. 边缘行为: 以图稿边缘为中心的标记会有部分超出边缘。添加裁剪之前,请先决定是否接受这种超出。

请根据当前几何信息推导位置,而不要在 .onAppear 中保存首次测得的尺寸。Apple 将该回调定义为视图出现时调用,具体时机取决于视图类型;它并不是窗口调整大小的回调。直接读取几何信息,可以让本示例的位置计算始终关联当前布局。请参阅 onAppear(perform:)。

对于可调整大小的 Mac 窗口中的适配图表,可靠的起点是使用一个共享矩形:让图稿建立其适配后的边界,测量这些边界,再据此推导每个注释的中心位置。先建立这一关系,再添加周围布局。

资料来源