若要讓側邊控制項與 SwiftUI 長寬比預覽相匹配,請根據可用的視窗空間計算預覽縮放後的尺寸,再讓控制項採用相同高度。這個 macOS 範例會維持合成預覽的比例,並讓超出範圍的控制項可捲動。

這項工作要建立一個可調整大小的桌面編輯器,左側是預覽,右側是固定寬度的控制面板。這裡的「預覽」是指執行中的 App 內部內容。目標是在視窗尺寸變動時,讓兩個面板的頂端與底端版面邊緣對齊。

控制項應依據哪個高度?

先定義要對齊的矩形。符合長寬比的預覽可能只占周圍容器的一部分空間。如果控制項依據容器高度配置,即使兩個檢視都正確排版,控制項仍可能延伸到可見預覽的下方。

Apple 將長寬比定義為寬度除以高度。對於可調整大小的內容,aspectRatio(_:contentMode:) 搭配 .fit 會在符合提議空間的同時保留內容比例。文件中的橢圓範例說明了符合長寬比的形狀與包住它的正方形框架為何可能有不同尺寸。Apple 的長寬比文件。

這個實作會明確指定符合長寬比後的矩形。它為控制項預留固定寬度與面板間距,再將預覽縮放至剩餘寬度及可用高度之內。兩個面板都採用計算出的預覽高度。

這是此範例採用的版面配置方式:預覽決定控制項檢視區的高度。控制項清單過長時,會在該檢視區內捲動,而不會讓預覽變高。Apple 將 ScrollView 說明為在可捲動區域內,顯示其底層內容中可移動的一部分。Apple 的捲動檢視文件。

只計算一次縮放後的矩形

令 W 和 H 分別代表扣除外側內距後的可用內容尺寸,C 代表控制項寬度,G 代表間距,R 代表預覽的寬高比。計算方式如下:

remainingWidth = max(0, W - C - G)
previewWidth = min(remainingWidth, H * R)
previewHeight = previewWidth / R
controlsHeight = previewHeight

第一個限制可避免預覽占用控制項的水平空間。第二個限制可避免視窗過寬時,預覽高度超出可用範圍。無論哪個限制生效,依據選定的寬度計算高度都能保留長寬比。

例如,假設內距前的內容區域為 960 × 600 點。四邊各留 16 點內距,控制面板寬 220 點,面板間距為 16 點,預覽比例為 16:9,則可用區域為 928 × 568 點。預覽剩餘寬度為 692 點,因此高度為 389.25 點。控制項檢視區也就會是 220 × 389.25 點。

這些數值僅用於示範,並非應用程式的實測結果。它們也說明了為什麼若讓控制項配合完整的 568 點可用高度,對齊就會出錯。

版面尺寸請使用 CGFloat,並確保長寬比是有限的正數。在 Swift 中,let ratio: CGFloat = 16.0 / 9.0 可明確表達預期的浮點數計算。若 App 日後允許設定長寬比,請在除法前驗證數值。

建立完整的 macOS 範例

在 Xcode 中建立 macOS SwiftUI App,並將部署目標設為 macOS 13 或更新版本。將產生的 App 與內容檢視宣告替換為以下單一檔案,並確保目標中恰好只有一個 @main 宣告。此範例不需要素材或外部套件。

這項部署版本需求源自此處使用的視窗尺寸 API。此場景使用 .contentMinSize,Apple 說明它會依內容決定視窗最小尺寸,且不設最大尺寸。Apple 的視窗可調整大小文件,Apple 的內容最小尺寸文件。

import SwiftUI

@main
struct MatchedPreviewApp: App {
    var body: some Scene {
        WindowGroup {
            PreviewEditor()
                .frame(minWidth: 640, minHeight: 360)
        }
        .windowResizability(.contentMinSize)
    }
}

struct PreviewEditor: View {
    @State private var intensity: Double = 0.5
    @State private var showsLabel = true

    private let ratio: CGFloat = 16.0 / 9.0
    private let controlsWidth: CGFloat = 220
    private let gap: CGFloat = 16

    var body: some View {
        GeometryReader { geometry in
            let availableHeight = max(0, geometry.size.height)
            let remainingWidth = max(
                0,
                geometry.size.width - controlsWidth - gap
            )
            let previewWidth = min(
                remainingWidth,
                availableHeight * ratio
            )
            let previewHeight = previewWidth / ratio

            HStack(alignment: .top, spacing: gap) {
                RoundedRectangle(cornerRadius: 12)
                    .fill(Color.blue.opacity(0.2 + intensity * 0.7))
                    .aspectRatio(ratio, contentMode: .fit)
                    .overlay {
                        Text("Sample composition")
                            .font(.headline)
                            .opacity(showsLabel ? 1 : 0)
                    }
                    .frame(
                        width: previewWidth,
                        height: previewHeight
                    )

                ScrollView(.vertical) {
                    VStack(alignment: .leading, spacing: 16) {
                        Text("Preview controls")
                            .font(.headline)

                        Toggle("Show label", isOn: $showsLabel)

                        VStack(alignment: .leading, spacing: 8) {
                            Text("Color intensity")
                            Slider(value: $intensity, in: 0...1)
                        }

                        ForEach(1...8, id: \.self) { index in
                            Text("Sample setting \(index)")
                                .font(.caption)
                        }
                    }
                    .padding(16)
                    .frame(maxWidth: .infinity, alignment: .leading)
                }
                .frame(
                    width: controlsWidth,
                    height: previewHeight
                )
                .background(Color.gray.opacity(0.08))
            }
            .frame(
                width: geometry.size.width,
                height: geometry.size.height,
                alignment: .center
            )
        }
        .padding(16)
    }
}

GeometryReader 會提供分配給編輯器區域的尺寸。Apple 說明,它會根據自身尺寸與座標空間定義內容,並具有彈性的偏好尺寸。將內距放在讀取器外層,計算時就會使用扣除內距後的內容區域。Apple 的幾何讀取器文件。

兩個固定框架會建立共用高度。Apple 說明,frame(width:height:alignment:) 會建立指定尺寸的不可見框架。控制項背景放在該框架之後,因此會覆蓋控制項檢視區,包括短清單下方的空間。Apple 的固定框架文件。

最後的框架會將整列置中於讀取器內,不會放大任一面板。因此,即使高視窗中列的上下有未使用的空間,控制項仍會與預覽高度一致。

調整大小時維持狀態與生命週期行為

請將使用者可編輯的值與計算出的幾何尺寸分開。在此範例中,intensity 和 showsLabel 是狀態;預覽寬度與高度則是根據目前可用尺寸推導出的區域值。

Apple 說明,狀態是由 SwiftUI 管理的儲存空間,其變更會更新檢視階層中相依的部分。因此,將滑桿與切換開關的值保存在擁有它們的編輯器中是合適的。Apple 的狀態文件。

版面計算不包含狀態指派、外觀回呼、非同步工作或測量回饋。調整大小時,變更的是相同檢視結構所使用的尺寸。標籤會持續顯示,並在切換開關變更時調整不透明度。

將此做法套用到現有編輯器時,請保留原有的模型擁有權與繫結型別。將尺寸配置方式套用在預覽與控制項周圍;不要把模型的建立移進幾何計算。此範例的算術只需要可用尺寸與選定的長寬比。

檢查調整大小、溢出與修飾子的順序

請將以下尺寸作為計算檢查。它們指的是套用範例外側內距之前的內容區域,不是視窗的外部框架。預期數值四捨五入至小數點後兩位。

內容區域 預覽尺寸 控制項檢視區
960 × 600 點 692 × 389.25 220 × 389.25
640 × 360 點 372 × 209.25 220 × 209.25
1200 × 400 點 654.22 × 368 220 × 368
700 × 900 點 432 × 243 220 × 243

這些算術結果已用簡短的 Python 計算檢查,並包含斷言以確認符合長寬比的預覽不超出可用高度,且兩個面板與間距能容納於水平空間內。研究環境中無法使用 Swift 與 Xcode,因此完整的 SwiftUI 程式已檢視,但未編譯或執行。

在 Xcode 中執行 App,並將視窗調整成寬、矮與高的形狀。確認面板邊緣持續對齊、控制項內容超出檢視區時可以捲動,以及調整大小期間滑桿與切換開關都會保留其值。

若套用範例後仍未對齊,請檢查以下具體情況:

  • 額外的面板內距: 若只在其中一個面板的固定框架外套用內距,該面板的總占用空間就會增加。請如範例所示,將控制項內距保留在捲動檢視內。
  • 更大的預覽外框: 若在預覽外加上更大的框架,請決定控制項應與該外框還是符合長寬比的內容對齊。此處的計算是以符合長寬比的矩形為準。
  • 溢出的自訂內容: 框架會指定版面尺寸,但不會自動隱藏超出框架的繪製內容。Apple 說明可使用 .clipped() 進行矩形裁切;若要隱藏預覽刻意溢出的部分,請使用它。控制項請保持可捲動,確保所有設定都能操作。Apple 的裁切文件。
  • 主容器空間不足: 此範例依賴內容最小尺寸。若嵌入較小的主容器,固定的控制項寬度可能會使預覽沒有足夠的空間。請為該主容器選擇較窄的面板、垂直排列,或明確設定最小尺寸。

何時應改用自訂版面配置?

此方法適合使用已知長寬比,且接收有界視窗區域的編輯器。它明確設定尺寸配置方式,不必測量預覽的內部內容。

若可重複使用的元件必須依據子檢視決定偏好尺寸,請考慮使用自訂 Layout。Apple 文件所述的合約將透過 sizeThatFits(proposal:subviews:cache:) 回報複合尺寸,以及透過 placeSubviews(in:proposal:subviews:cache:) 放置子檢視,分成兩個步驟。子檢視代理提供測量與放置操作。Apple 的版面配置文件。

對於此處的有界桌面編輯器,共用的縮放後矩形就已足夠:為控制項預留水平空間,根據兩個可用尺寸縮放預覽,再將其高度指定給控制項檢視區。如此便能清楚定義調整大小的規則,同時讓控制項內容自由捲動。

來源