カスタムMacサーフェスで名前付きアクションを実行する場合は、SwiftUIのButtonを使います。キャンバス上の位置など、操作にジェスチャ情報が必要な場合はクリックジェスチャを使います。このガイドでは、小さな図形エディタを作り、それぞれを使う場面を示します。

Appleは、要素がボタンとして機能する場合はボタンを使うことを明確に推奨しています。ボタンのラベルには任意のビューを使えるため、見た目をカスタマイズしても、その操作方法を置き換える必要はありません。SwiftUIは、アクセシビリティラベルやヒントなど、標準的なボタンの動作を提供します。ジェスチャ

サーフェスに必要な操作はどれですか?

Macアプリを開発する際に役立つ問いは、操作からどのような情報を得る必要があるかです。Add circleとラベル付けされたタイルはコマンドを表します。空の描画サーフェスをクリックして円を配置する操作は、コマンドに位置情報が加わったものです。この追加の入力によって、適切な実装が変わります。

次の判断ガイドは、一般的なデスクトップ向け図形エディタに、Appleが文書化している区別を当てはめたものです。あらゆるインターフェイスに関する規則ではなく、APIに基づく実装上の推奨事項です。

想定する操作 最初に使うもの 理由
Add circleとラベル付けされたカスタムタイルをクリックする カスタムラベルを持つButton サーフェスが名前付きアクションを開始するため。
タイルに特徴的な背景と押下時の表示を設定する ButtonStyle 標準的なボタン操作を保ちながら、見た目をカスタマイズするため。
キャンバスをクリックして、その位置に円を配置する SpatialTapGesture 結果に操作位置が含まれるため。
座標を必要とせず、コンテンツビューで2回のクリックを認識する onTapGesture(count: 2) 必要なクリック回数を指定できるため。
AppKitの操作で特定のマウスボタンを設定する NSClickGestureRecognizer マウスボタンとクリック回数を設定できるため。

Appleはボタンを、アクションとラベルを組み合わせたものとして説明し、ButtonStyleによる見た目のカスタマイズと、PrimitiveButtonStyleによる操作方法のカスタマイズを区別しています。また、ジェスチャAPIではクリック回数と位置情報を含む結果について、それぞれ別に説明しています。Button、onTapGesture(count:perform:)、SpatialTapGesture、NSClickGestureRecognizer

したがって、カスタムのアクションタイルはボタンから始めます。サーフェスに必要な追加の入力や認識要件が明確な場合に、ジェスチャを選びます。

動作を作り直さずにカスタムボタンを作るには?

コマンドはボタンのアクションクロージャに、図柄はラベルに記述します。表示にはButtonStyleを使います。Appleの説明によると、このプロトコルはプラットフォーム標準の操作動作を保ちます。意図的に操作方法を変えるための別の拡張ポイントがPrimitiveButtonStyleです。ButtonStyle

次の例には、カスタムのAdd circleボタンと長方形の配置キャンバスという、2つの独立したサーフェスがあります。どちらも同じ変更関数を呼び出します。ボタンは予測可能な位置を選び、キャンバスはローカル座標を渡します。これにより、ファイルや外部コンテンツ、別のアプリを導入せずに違いを確認できます。

SwiftUIを使ったmacOS Appプロジェクトを作成し、デプロイメントターゲットをmacOS 13以降に設定します。これはSpatialTapGestureの文書化された最小要件です。生成されたアプリとコンテンツビューの定義を最初のファイルの内容に置き換え、2つ目のファイルを同じターゲットに追加します。@mainのアプリ宣言は1つだけにしてください。このコードは確認済みですが、macOS SDK環境ではコンパイルも実行もしていません。SpatialTapGesture

アプリケーション全体は次の2つのファイルで構成されます。まず、ShapeSurfaceApp.swiftです。

import SwiftUI
import Foundation

@main
struct ShapeSurfaceApp: App {
    var body: some Scene {
        WindowGroup {
            ShapeSurfaceView()
        }
    }
}

private struct PlacedCircle: Identifiable {
    let id = UUID()
    let center: CGPoint
}

struct ShapeSurfaceView: View {
    @State private var circles: [PlacedCircle] = []
    private let limit = 6

    private var isFull: Bool {
        circles.count >= limit
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Button {
                let x = 35 + CGFloat(circles.count) * 50
                addCircle(at: CGPoint(x: x, y: 100))
            } label: {
                HStack(spacing: 12) {
                    Circle()
                        .fill(Color.blue)
                        .frame(width: 24, height: 24)
                    Text("Add circle")
                }
                .frame(width: 280, height: 64)
                .contentShape(RoundedRectangle(cornerRadius: 12))
            }
            .buttonStyle(SurfaceButtonStyle())
            .keyboardShortcut("a", modifiers: [.command, .shift])
            .disabled(isFull)
            .opacity(isFull ? 0.4 : 1)

            Text("Click the canvas to place a circle. Maximum: 6.")

            ZStack(alignment: .topLeading) {
                Color.gray.opacity(0.1)

                ForEach(circles) { circle in
                    Circle()
                        .fill(Color.blue)
                        .frame(width: 24, height: 24)
                        .position(circle.center)
                }
            }
            .frame(width: 320, height: 200)
            .contentShape(Rectangle())
            .gesture(
                SpatialTapGesture()
                    .onEnded { value in
                        addCircle(at: value.location)
                    }
            )
            .disabled(isFull)
            .border(Color.gray)
            .accessibilityElement(children: .ignore)
            .accessibilityLabel("Circle canvas")
            .accessibilityValue("\(circles.count) of \(limit) circles")

            Text(isFull
                 ? "Six circles placed. Clear to start again."
                 : "\(circles.count) of \(limit) circles placed")

            Button("Clear circles") {
                circles.removeAll()
            }
            .disabled(circles.isEmpty)
        }
        .padding(24)
    }

    private func addCircle(at point: CGPoint) {
        guard !isFull else { return }

        // Keep each 24-point circle inside the 320-by-200 canvas.
        let center = CGPoint(
            x: min(max(point.x, 12), 308),
            y: min(max(point.y, 12), 188)
        )
        circles.append(PlacedCircle(center: center))
    }
}

次に、SurfaceButtonStyle.swiftです。

import SwiftUI

struct SurfaceButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .background(
                RoundedRectangle(cornerRadius: 12)
                    .fill(Color.blue.opacity(
                        configuration.isPressed ? 0.25 : 0.1
                    ))
            )
    }
}

このスタイルはフィードバックの描画にconfiguration.isPressedを読み取ります。アクション自体は呼び出しません。Appleは、このプロパティをボタンが現在押されている状態を示すものと定義しています。これにより、描画とアクションの実行を別の場所に保てます。isPressed

円を6個までに制限するのは、説明用のアプリ内ルールです。どちらの操作経路でもaddCircle(at:)を使い、追加前に上限を確認します。無効時の表示で上限を伝え、共通のガード処理で変更ルールを統一します。Appleは、disabled(_:)がクリックやジェスチャを含むビューとの操作を制御すると説明しています。disabled(_:)

キャンバスで位置情報付きクリックジェスチャを使う理由は?

キャンバスでは、ユーザーがクリックした位置が必要です。基本のonTapGesture(count:perform:)オーバーロードは、位置パラメーターなしでアクションを渡します。SpatialTapGestureは位置を明示的に報告し、そのイニシャライザーはローカル座標空間での1回の操作を既定値とします。onTapGesture(count:perform:)、SpatialTapGesture、init(count:coordinateSpace:)

このプログラムでは、円の位置を決めるローカル座標を持つ固定サイズのキャンバスにジェスチャを付けています。配置関数は、円の半径に合わせて中心が縁から12ポイント内側になるよう制限します。そのため、端の近くをクリックしても円全体がキャンバス内に収まります。円の重なりは許可され、重なっても個数は表示されます。

ボタンとキャンバスでは、キーボード操作も意図的に異なります。ボタンのショートカットはボタンを有効化した場合と同じ操作を行い、次の位置に円を追加します。ポインター位置を作り出すものではありません。Appleは、キーボードショートカットがコントロールの主アクションを呼び出すと説明し、重複するショートカットが割り当てられている場合は最初に見つかった一致コントロールに解決されるとしています。この例のウィンドウでは、このショートカットを1つだけ割り当ててください。keyboardShortcut(_:modifiers:)

キャンバスのアクセシビリティラベルと個数は、その内容を説明しますが、任意の座標に配置するためのキーボード操作を提供するものではありません。ボタンを使えば、ポインターなしであらかじめ決められた位置に円を追加できます。キーボード操作でも正確な配置が必要なら、ジェスチャや説明的なラベルで代用できると考えず、位置を編集する明示的なインターフェイスを追加してください。

ヒットテストをカスタムサーフェスに合わせるには?

ラベルのサイズを決めた後に、意図した操作領域を定義します。この例のボタンラベルは280×64ポイントのフレームを持ち、その後に角丸長方形のcontentShapeが続きます。キャンバスは320×200ポイントのフレームを設定した後、長方形のコンテンツシェイプを宣言します。AppleはcontentShape(_:eoFill:)をヒットテストに使うシェイプと定義しています。contentShape(_:eoFill:)

これにより、表示されている各サーフェスに明確な操作範囲が設定されます。デザイン変更でフレームをボタンラベルの外に移動した場合は、結果をもう一度確認してください。この例では、サイズ設定とコンテンツシェイプはラベル内で一体になっています。反応しない余白を別のアクションハンドラーで直すのではなく、描画、レイアウト、操作領域を意図的に設計してください。

サーフェスが反応しなくなった場合は、次の項目を順に確認します。

  1. ジオメトリを確認する。 ラベルのテキスト、図形、空白部分をクリックします。コンテンツシェイプが意図した範囲を示しているか確認します。
  2. 親ビューの無効状態を確認する。 無効なコンテナは有効な子孫ビューにも優先するため、ローカルの.disabled(false)で操作を再び有効にできない場合があります。disabled(_:)
  3. 装飾用オーバーレイを確認する。 純粋に視覚的なビューは、.allowsHitTesting(false)でヒットテストから除外できます。これを適用するのは装飾要素であり、操作対象のサーフェスではありません。allowsHitTesting(_:)
  4. 競合するハンドラーを確認する。 ジェスチャの優先順位を変える前に、どのビューがアクションを担当するか特定します。

競合するハンドラーや所有権の誤りを防ぐには?

意図した有効化経路ごとにハンドラーを1つ使います。この例では、カスタムボタンがコマンドを担当し、キャンバスが位置情報に基づく配置を担当します。どちらも、別の配置用ジェスチャを持つ親ビューの中にはありません。この構造なら、クリック1回で起こることを簡単に確認できます。

同じ変更関数を呼び出すためだけに、ボタンへ2つ目のタップハンドラーを付けるのは避けてください。同様に、親と子の両方の操作を実行すべきか決めないまま、複数のコントロールを囲む親ジェスチャを追加しないでください。AppleのsimultaneousGestureの例では、親と子のハンドラーが両方実行される場合が示されています。一方、highPriorityGestureは、付加されたジェスチャの優先順位を高くします。これらの修飾子は操作ポリシーを表すもので、相互に置き換えられる修正手段ではありません。simultaneousGesture(_:including:)、highPriorityGesture(_:including:)

アプリの状態は、それを所有するビューに置きます。ここではShapeSurfaceViewが合成した円を保持し、スタイルは渡されたラベルを描画するだけです。Appleは、必要とする最上位のビューにプライベートな状態を置き、必要に応じて値やバインディングを子孫ビューへ渡すことを推奨しています。State

後でAppKitアダプターにコールバックを保持させる場合は、キャプチャ関係を別途確認してください。Swiftのクロージャは周囲の値をキャプチャできます。同じインスタンスをキャプチャするクロージャをクラスが保持すると、強参照サイクルが生じることがあります。実際のオブジェクトのライフタイムに応じてキャプチャリストを選んでください。ボタンをジェスチャに変えても、コールバックの所有権の問題は解決しません。Closures

AppKitを次の手段として使うのはどんなときですか?

独自のサーフェスに、文書化されているマウス固有の設定が必要な場合はAppKitを検討します。NSClickGestureRecognizerは、必要なクリック回数とマウスボタンマスクをサポートします。Appleの仕様では、ビット0が主ボタン、ビット1が副ボタンを表し、ドラッグを伴わず指定回数クリックすると認識されます。また、設定されたマウスボタンのイベント遅延も自動的に有効になります。NSClickGestureRecognizer、buttonMask

デプロイメントターゲットと、所有するコンポーネントに応じて統合方法を選びます。

  • macOS 26以降の場合、 NSGestureRecognizerRepresentableを使うと、.gesture(...)経由でAppKitの認識器をSwiftUI階層に直接統合できます。SwiftUIがアクションターゲットをインストールします。representableのアクションハンドラーを実装し、座標変換にはコンテキストのコンバーターを使います。認識器に関連付けられたビューのジオメトリは、SwiftUIビューと異なることがあるとAppleは注意しています。NSGestureRecognizerRepresentable
  • SwiftUI内でAppKitビューをホストする場合、 NSViewRepresentableがビューの生成、更新、破棄を管理します。コールバックにはコーディネーターを使い、表現されたビューのレイアウトはSwiftUIに任せます。このAPIはmacOS 10.15以降で利用できます。NSViewRepresentable

どちらのアダプターを追加する場合も、イベントの境界を保ってください。AppKitの説明によると、認識器は取り付けられたビューとその子孫に対して動作し、ビューより先に関連イベントを受け取り、その配送を遅らせることがあります。また、ドラッグ&ドロップやポップアップメニューなど、別の仕組みへイベント処理を引き継ぐことは明示的にサポートされていません。クリック認識器を万能なイベント中継器として扱わず、これらの操作は対応する仕組みを使って設計してください。NSGestureRecognizer

完成したMacの操作で確認すべきことは?

この例をXcodeでビルドし、より大きなビュー階層に適用する前に、観察可能な動作を確認します。

  • カスタムボタンのテキストと空白部分をクリックします。有効化ごとに円が1つ追加され、個数が1回更新されることを確認します。
  • アクティブなウィンドウでCommand–Shift–Aを押します。ボタンと同じ、位置を決めて追加する操作が実行されることを確認します。
  • キャンバス上の異なる位置を、端の近くも含めてクリックします。コードに記載された余白を保ちながら、円がローカル座標に従って配置されることを確認します。
  • 円を6個まで追加します。両方の配置経路で追加が停止し、Clear circlesは引き続き利用できることを確認します。
  • キャンバスをクリアします。再び配置できることを確認します。
  • VoiceOverでボタンを確認し、現在のMacの設定でキーボードナビゲーションを試します。スタイルを適用しても、ボタンのアクションが分かりやすいことを確認します。

カスタムMacアクションサーフェスでは、まず操作の意味から考えます。名前付きコマンドにはButtonを使い、見た目はラベルとスタイルでカスタマイズし、位置に依存する配置にはキャンバスのジェスチャを使います。それらの経路は状態を変える1つの関数につなぎ、AppKitの追加設定が具体的な要件を満たす場合にのみ導入します。

出典