アスペクト比を保つ SwiftUI プレビューにサイドコントロールを合わせるには、ウィンドウ内で利用できる領域から縮小後のプレビューサイズを計算し、コントロールにも同じ高さを設定します。この macOS の例では、合成プレビューの比率を維持し、はみ出すコントロールをスクロールできるようにします。
ここで扱うのは、左側にプレビュー、右側に幅固定のコントロールパネルを配置した、サイズ変更可能なデスクトップエディタです。ここでの「プレビュー」とは、実行中のアプリ内のコンテンツを指します。ウィンドウのサイズが変わっても、2つのパネルの上端と下端をそろえることが目標です。
コントロールはどの高さに合わせるべきか?
まず、どの矩形に合わせるかを決めます。アスペクト比に合わせて縮小したプレビューは、周囲のコンテナより小さくなることがあります。コントロールをコンテナの高さに合わせると、両方のビューが正しくレイアウトされていても、コントロールが表示中のプレビューより下にはみ出すことがあります。
Apple はアスペクト比を幅÷高さと定義しています。サイズ変更可能なコンテンツでは、.fit を指定した aspectRatio(_:contentMode:) によって、提案された領域内に収めながら比率を維持できます。ドキュメントの楕円の例は、縮小後の図形と、それを囲む正方形のフレームとで寸法が異なる理由を示しています。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
最初の制限によって、プレビューがコントロール用の横幅を使わないようにします。2つ目の制限によって、ウィンドウが横に広い場合でも、プレビューの高さが利用可能な領域を超えないようにします。選択した幅から高さを計算することで、どちらの場合も比率が保たれます。
たとえば、余白を差し引く前のコンテンツ領域が 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 によって、意図した浮動小数点数の計算を明示できます。アプリで後から比率を設定可能にする場合は、除算の前に値を検証してください。
macOS の例を完成させる
Xcode で macOS の SwiftUI アプリを作成し、デプロイメントターゲットを macOS 13 以降に設定します。生成されたアプリとコンテンツビューの宣言をこの単一ファイルで置き換え、ターゲット内の @main 宣言は1つだけにしてください。この例では、アセットや外部パッケージは必要ありません。
この要件は、ここで使用するウィンドウサイズ調整 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 の説明によると、GeometryReader は自身のサイズと座標空間に基づいてコンテンツを定義し、柔軟な優先サイズを持ちます。パディングをリーダーの外側に置くことで、計算には余白を差し引いたコンテンツ領域が使われます。Apple のジオメトリリーダーに関するドキュメント。
2つの固定フレームによって、両方のパネルの高さがそろいます。Apple のドキュメントでは、frame(width:height:alignment:) は指定した寸法の不可視フレームを作成すると説明されています。コントロールの背景はこのフレームの後に設定するため、短いリストの下の空き領域も含め、コントロールの表示領域全体を覆います。Apple の固定フレームに関するドキュメント。
最後のフレームは、リーダー内で行全体を中央に配置します。どちらのパネルも拡大しません。そのため、ウィンドウが高い場合、行の上下に空き領域ができても、コントロールはプレビューと同じ高さを保ちます。
サイズ変更中も状態とライフサイクルの動作を維持する
ユーザーが編集する値は、計算したジオメトリと分けて保持します。この例では、intensity と showsLabel が状態であり、プレビューの幅と高さは現在の利用可能なサイズから導出するローカル値です。
Apple のドキュメントでは、状態を、変更されるとビュー階層内の依存部分を更新する、SwiftUI 管理のストレージと説明しています。そのため、スライダーとトグルの値は、それらを所有するエディタ内に保持できます。Apple の状態に関するドキュメント。
レイアウト計算には、状態の代入、表示時のコールバック、非同期処理、計測結果のフィードバックはありません。サイズ変更によって、同じビュー構造に使われる寸法が変わります。ラベルは常に表示され、トグルの変更に応じて不透明度が変化します。
既存のエディタに適用する場合は、モデルの所有関係とバインディングの型を維持してください。サイズ調整の方針はプレビューとコントロールの周囲に適用し、モデルの作成場所をジオメトリ計算の中に移さないでください。この例の計算で必要なのは、利用可能な寸法と選択した比率だけです。
サイズ変更、はみ出し、モディファイアの順序を確認する
次の寸法を計算結果の確認に使ってください。これらは、ウィンドウの外枠ではなく、この例の外側の余白を差し引く前のコンテンツ領域を表します。期待値は小数点以下2桁に丸めています。
| コンテンツ領域 | プレビューサイズ | コントロールの表示領域 |
|---|---|---|
| 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 スクリプトを使い、縮小後のプレビューが利用可能な高さ以内に収まることと、2つのパネルと間隔の合計が横幅に収まることをアサーションで確認しました。調査環境では Swift と Xcode を利用できなかったため、SwiftUI のプログラム全体を確認しましたが、コンパイルも実行もしていません。
Xcode でアプリを実行し、横に広い形、低い形、高い形にウィンドウのサイズを変えてください。パネルの端がそろうこと、コンテンツが表示領域を超えたときにコントロールをスクロールできること、サイズ変更中もスライダーとトグルの値が保持されることを確認します。
例を既存のコードに適用した後も位置がそろわない場合は、次の点を確認してください。
- パネルに余分なパディングがある: 片方のパネルだけ固定フレームの外側にパディングを適用すると、そのパネル全体の占有サイズが増えます。例のように、コントロールのパディングはスクロールビューの内側に置いてください。
- プレビューの外側に大きなラッパーがある: プレビューの周囲にさらに大きなフレームを追加する場合、コントロールをそのラッパーに合わせるのか、縮小後のコンテンツに合わせるのかを決めてください。ここでの計算は、縮小後の矩形に合わせます。
- カスタムコンテンツがはみ出す: フレームはレイアウト上の寸法を決めますが、その範囲外への描画を自動的に隠すわけではありません。Apple のドキュメントでは、矩形でクリッピングするには
.clipped()を使います。プレビューの意図しないはみ出しを隠す場合に使ってください。すべての設定にアクセスできるよう、コントロールはスクロール可能な状態にします。Apple のクリッピングに関するドキュメント。 - ホスト側のスペースが足りない: この例では、コンテンツの最小サイズが確保されることを前提としています。より小さいホストに埋め込む場合、固定幅のコントロールパネルによって、プレビューに必要な幅が残らないことがあります。そのホストに合わせて、パネルの幅を小さくする、縦方向に並べる、または明示的な最小サイズを設定してください。
カスタムレイアウトを使うべきなのはどんな場合か?
この方法は、上限のあるウィンドウ領域を受け取り、既知のアスペクト比を使うエディタに適しています。サイズ調整の方針が明確で、プレビュー内部のコンテンツを計測する必要がありません。
再利用可能なコンポーネントで、子ビューから優先サイズを決める必要がある場合は、カスタム Layout を検討してください。Apple のドキュメントでは、合成ビューのサイズを sizeThatFits(proposal:subviews:cache:) で報告することと、placeSubviews(in:proposal:subviews:cache:) で子ビューを配置することを別の処理として定義しています。サブビューのプロキシは、計測と配置の操作を提供します。Apple のレイアウトに関するドキュメント。
ここに示した、利用可能な領域が限られたデスクトップエディタでは、縮小後の矩形を共有するだけで十分です。コントロール用の横幅を確保し、利用可能な寸法の両方に合わせてプレビューを収め、その高さをコントロールの表示領域に設定します。これにより、コントロールのコンテンツをスクロール可能に保ちながら、明確なサイズ変更ルールを実現できます。
参考資料
- aspectRatio(_:contentMode:) — Apple Developer Documentation。
- ScrollView — Apple Developer Documentation。
- windowResizability(_:) — Apple Developer Documentation。
- contentMinSize — Apple Developer Documentation。
- GeometryReader — Apple Developer Documentation。
- frame(width:height:alignment:) — Apple Developer Documentation。
- State — Apple Developer Documentation。
- clipped(antialiased:) — Apple Developer Documentation。
- Layout — Apple Developer Documentation.