アスペクトフィットされたSwiftUIの図にオーバーレイを位置合わせするには、フィット後の図そのものを測定し、そのローカル座標空間にマーカーを配置します。余白を加える外側のフレームより前に、測定用のオーバーレイを付けます。

このチュートリアルでは、合成図と番号付きマーカー2つを使った小さなmacOSアプリを作ります。ウィンドウの形が変わっても、各マーカーを図の対応する特徴の中心に保つことが目的です。実装では正規化座標を使うため、注釈の位置はウィンドウの寸法に左右されません。

アスペクトフィットされた図からオーバーレイがずれるのはなぜですか?

アスペクトフィットでは、図の縦横比を保ちながら、利用可能な領域に収めます。コンテナの縦横比によっては、図の上下や左右に余白ができます。Appleはこの動作を利用可能な領域に画像を収めるで説明しています。

図とマーカーが異なる矩形を基準にすると、ずれが生じます。たとえば、600×700ポイントのコンテナ内で、図が中央の600×375ポイントの矩形を占めているとします。コンテナの高さに対する割合でマーカーを配置すると、計算に周囲の余白まで含まれてしまいます。

どの矩形を測定するかは、モディファイアの順序で決まります。Appleの説明によると、ビューのモディファイアは対象のビューを包み込み、フレームは子ビューを別の矩形内に配置します。そのため、拡張するフレームの後に付けたオーバーレイは、フィットされた図に直接付けた場合とは異なるレイアウト境界を測定します。AppleのViewドキュメントと柔軟なフレームのドキュメントを参照してください。

このタスクでは、次の順序にします。

  1. 柔軟な図を描画する。
  2. .fitで明示的なアスペクト比を適用する。
  3. GeometryReaderを含むオーバーレイを付ける。
  4. そのリーダーのサイズを使ってマーカーを配置する。
  5. 外側のフレームと周囲のパディングを追加する。

変更対象のビューにレイアウトを主導させる場合、Appleはオーバーレイの使用を推奨しています。図をレイアウトの基準とし、その上に注釈を重ねる構成が適しています。ZStackで兄弟ビューとして配置するとレイアウトの動作が異なり、どのビューがサイズを決めるかをより慎重に扱う必要があります。overlay(alignment:content:)を参照してください。

macOSのサンプルを一通り作る

XcodeでSwiftUIインターフェイスを使ったmacOSアプリのプロジェクトを作成します。ここで使うクロージャー形式のoverlay APIはmacOS 12以降で利用できるため、デプロイメントターゲットをmacOS 12以降に設定してください。生成されたアプリのエントリーポイントファイルを、次の完全なプログラムに置き換えます。ターゲット内の@mainアプリ宣言は1つだけにしてください。このAPIの利用可能時期はoverlay(alignment:content:)に記載されています。

合成図は800×500のデザイン領域を表し、アスペクト比は1.6です。マーカー座標はその領域に対する割合です。水平方向の0は左端、垂直方向の0は上端、1はそれぞれ反対側の端を表します。アセットファイルや外部パッケージは不要です。

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
    }
}

アプリを起動し、ウィンドウの幅と高さを変えてください。マーカー1は1つ目の矩形の中心に、マーカー2は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. 座標の境界: 正規化座標が元画像全体と意図した左上原点を基準にしていることを確認します。この例で有効な値の範囲は0から1です。想定外の値は黙ってクランプせず、拒否してください。
  3. アンカーの選択: .position(x:y:)はマーカーの中心を基準にする点に注意してください。ラベルの角を特徴に合わせるには、追加のオフセットを明示するか、別のアンカー方法を使います。
  4. 小さなレイアウト: マーカーが正しく中心にあっても、別のマーカーと重なることがあります。小さいサイズでラベルを隠すか並べ替えるためのルールを別途決めてください。
  5. 端での動作: 図の端に中心を置いたマーカーは、その端からはみ出します。クリッピングを追加する前に、そのはみ出しを許容できるか判断してください。

.onAppearで最初に測定したサイズを保存するのではなく、現在のジオメトリから位置を計算してください。Appleの定義では、このコールバックはビューの種類によってタイミングが異なる表示時の処理であり、ウィンドウサイズ変更のコールバックではありません。ジオメトリを直接読むことで、この例の位置計算を現在のレイアウトに連動させられます。onAppear(perform:)を参照してください。

サイズ変更可能なMacウィンドウにフィットさせた図を配置する場合、まず1つの矩形を共有することが確実な出発点です。図にフィット後の境界を確立させ、その境界を測定し、すべての注釈の中心をそこから求めます。この関係を確立してから、周囲のレイアウトを追加してください。

参考資料