若要讓覆蓋項與採用等比例縮放的 SwiftUI 圖表保持對齊,請測量縮放後的圖表本身,並在該局部座標空間中放置標記。請在任何會增加空白的外層框架之前附加測量用覆蓋項。

本教學會建立一個小型 macOS App,包含合成圖表和兩個編號標記。目標是在視窗形狀改變時,讓每個標記都保持在圖表特徵的中心。此實作使用正規化座標,因此註解位置不受視窗尺寸影響。

為什麼覆蓋項會偏離等比例縮放的圖表?

等比例縮放會在圖表符合可用空間時保留其比例。視容器比例而定,圖表上方和下方或左右兩側可能會留下空白。Apple 在將影像調整至可用空間中說明了這項行為。

當圖稿和標記使用不同的矩形時,就會發生偏移。例如,圖稿可能位於 600 × 700 點的容器中,並佔據一個置中的 600 × 375 點矩形。若使用容器高度的比例來定位標記,計算時就會把周圍的空白也算進去。

修飾器的順序決定了你測量的是哪個矩形。Apple 說明,視圖修飾器會包覆接收修飾器的視圖,而框架會將其子視圖放在另一個矩形內。因此,附加在擴展框架之後的覆蓋項,測量到的版面邊界會不同於直接附加在等比例縮放圖稿上的覆蓋項。請參閱 Apple 的View 文件和彈性框架文件。

針對此任務,請依照以下順序操作:

  1. 繪製彈性圖稿。
  2. 使用 .fit 套用明確的長寬比。
  3. 附加包含 GeometryReader 的覆蓋項。
  4. 使用該 reader 的尺寸定位標記。
  5. 加上外層框架和周圍內距。

Apple 建議在需要讓已修飾的視圖主導版面配置時使用覆蓋項。因此,圖表適合作為版面配置的主體,註解則疊加在其上。在 ZStack 中使用同層視圖的排列方式,其版面行為不同,也需要更仔細處理由哪個視圖決定尺寸。請參閱 overlay(alignment:content:)。

建立完整的 macOS 範例

在 Xcode 中建立 macOS App 專案,並使用 SwiftUI 介面。將部署目標設為 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:)。

測量用覆蓋項緊接在該限制之後。GeometryReader 會依自身尺寸和座標空間建立其內容,而 geometry.size 會提供容器的尺寸。此處的容器涵蓋等比例縮放後的矩形。因此,將正規化座標乘以其寬度和高度,就能得到標記的局部位置。請參閱 GeometryReader 和 GeometryProxy.size。

最後的擴展框架會將完成的圖表與標記組合置中於可用區域內。由於標記已附加在內部矩形上,因此不必計算外側留白。

保留繪圖與定位的契約

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 的示意來源,以及位於 (0.25, 0.40) 的標記 1,可得出以下結果。此處的容器尺寸是指扣除周圍內距後的可用區域。

可用容器 等比例縮放後的圖表 圖表在容器中的原點 標記在容器中的中心
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 不符。在範例的局部覆蓋項中,垂直座標就是 0.40 × 375 = 150;外層版面配置會提供 162.5 點的留白。

表格中的算術已使用簡短且確定性的 Python 計算進行檢查。SwiftUI 程式已檢視,但未在此環境中編譯;請在目標 Mac 上使用 Xcode 確認其呈現結果。

將此模式套用至現有圖表

若圖表使用影像,請先讓 Image 可調整大小,再套用等比例縮放和覆蓋項修飾器。Apple 的影像縮放指南說明,否則影像會以原始尺寸呈現,而單獨使用 .resizable() 可能會讓各座標軸以不同倍率縮放。保留影像實際的長寬比,然後在外層框架之前附加標記覆蓋項。請參閱將影像調整至可用空間。

若註解是以來源座標表示,請以相同的完整圖稿邊界將其正規化:來源水平座標除以來源寬度,來源垂直座標除以來源高度。影像內的透明留白仍屬於這些邊界的一部分。若座標是以裁切區域為基準測量,請先將其轉換至完整影像的座標系統,再使用此模式。

請先在 Image 上保留影像專用修飾器,再由一般視圖修飾器包覆它。同樣地,組合視圖仍應使用 var body: some View,而形狀的繪圖方法仍應以 Path 作為傳回型別。Apple 在 View 中說明了自訂視圖組合和修飾器包覆。

此計算假設採用等比例縮放,且沒有額外裁切、旋轉或獨立的平移與縮放轉換。若加入這些操作,請對圖稿和註解套用相同的轉換,或明確擴充座標映射。改用 .fill 會改變縮放條件,並可能使內容超出框架;Apple 在將影像調整至可用空間中將裁切記載為一項獨立操作。

診斷仍未解決的對齊問題

請依序檢查以下條件:

  1. 測量邊界: 將標記覆蓋項放在外層擴展框架之前。其邊框應沿著圖稿,而非周圍的空白區域。
  2. 座標邊界: 確認正規化座標是以完整來源矩形和預期的左上原點為基準。此範例中,預期的界內值介於零和一之間;遇到非預期值時應拒絕,而不是悄悄將其限制在範圍內。
  3. 錨點選擇: 請記住,.position(x:y:) 會將標記中心作為錨點。若要讓標籤的角落貼住某個特徵,就需要明確增加偏移,或採用不同的錨定策略。
  4. 小型版面: 標記可能會在保持正確置中的同時與其他標記重疊。請另訂一套規則,決定在較小尺寸時要隱藏或重新排列標籤。
  5. 邊緣行為: 位於圖稿邊緣的置中標記會伸出邊界。加入裁切前,請先決定這樣的突出是否可接受。

位置應根據目前幾何尺寸推導,而不是在 .onAppear 中儲存首次測量的尺寸。Apple 將該回呼定義為與視圖出現相關的回呼,觸發時機則取決於視圖類型;它不是視窗大小調整回呼。直接讀取幾何尺寸,能讓此範例的位置計算依據目前的版面配置。請參閱 onAppear(perform:)。

對於可調整大小的 Mac 視窗中的等比例縮放圖表,可靠的起點是使用一個共用矩形:讓圖稿建立等比例縮放後的邊界、測量該邊界,並以此推導所有註解中心。建立這層關係後,再加入周圍的版面配置。

來源