Use a SwiftUI Button when your custom Mac surface performs a named action. Use a click gesture when the interaction needs gesture information, such as a canvas location. This guide builds a small shape editor to show where each belongs.

Apple explicitly recommends a button whenever an element functions as one. A button can use any view as its label, so a custom appearance does not require replacing its interaction behavior. SwiftUI supplies standard button behaviors, including accessibility labels and hints. Gestures

Which interaction does your surface need?

For a developer building a Mac app, the useful question is what information the interaction must deliver. A tile labeled Add circle expresses a command. Clicking an empty drawing surface to place a circle expresses a command plus a location. That additional input changes the appropriate implementation.

The following decision aid applies Apple’s documented distinctions to an ordinary desktop shape editor. These are implementation recommendations derived from the APIs, rather than rules about every possible interface.

Reader’s intended interaction Starting point Reason
Click a custom tile labeled Add circle Button with a custom label The surface initiates a named action.
Give that tile a distinctive background and pressed appearance ButtonStyle It customizes appearance while preserving standard button interaction.
Click a canvas to place a circle at that point SpatialTapGesture Its result includes the interaction location.
Recognize two clicks on a content view without needing coordinates onTapGesture(count: 2) The modifier accepts a required click count.
Configure specific mouse buttons in an AppKit interaction NSClickGestureRecognizer It exposes mouse-button and click-count configuration.

Apple documents a button as an action paired with a label, and distinguishes appearance customization through ButtonStyle from interaction customization through PrimitiveButtonStyle. Its gesture APIs separately document click counts and spatial results. Button, onTapGesture(count:perform:), SpatialTapGesture, NSClickGestureRecognizer

A custom action tile therefore starts as a button. Reach for a gesture when you can identify the additional input or recognition requirement that the surface needs.

How do you build a custom button without rebuilding its behavior?

Put the command in the button’s action closure and the artwork in its label. Use ButtonStyle for presentation. Apple documents that this protocol keeps the standard interaction behavior for the platform; PrimitiveButtonStyle is the separate extension point for intentionally changing interaction behavior. ButtonStyle

The example below contains two sibling surfaces: a custom Add circle button and a rectangular placement canvas. Both call the same mutation function. The button chooses a predictable slot; the canvas supplies a local point. This makes the difference visible without introducing files, external content, or another app.

Create a macOS App project using SwiftUI and set its deployment target to macOS 13 or later, the documented minimum for SpatialTapGesture. Replace the generated app and content-view definitions with the first file, and add the second file to the same target. Keep exactly one @main app declaration. The code has been inspected but has not been compiled or run in a macOS SDK environment. SpatialTapGesture

The complete application consists of these two files. First, 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))
    }
}

Second, 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
                    ))
            )
    }
}

The style reads configuration.isPressed to draw feedback; it never calls the action itself. Apple defines that property as the button’s current pressed state. This keeps rendering and action execution in separate places. isPressed

The six-circle limit is an illustrative application rule. Both entry points use addCircle(at:), which checks capacity before appending. The disabled appearance communicates the limit, while the shared guard keeps the mutation rule consistent. Apple documents disabled(_:) as controlling interaction with a view, including clicks and gestures. disabled(_:)

Why does the canvas use a spatial click gesture?

The canvas needs the point where the user clicked. The basic onTapGesture(count:perform:) overload supplies an action without a location parameter. SpatialTapGesture explicitly reports a location, and its initializer defaults to one interaction in the local coordinate space. onTapGesture(count:perform:), SpatialTapGesture, init(count:coordinateSpace:)

In this program, the gesture belongs to the fixed-size canvas whose local coordinates position the circles. The placement function clamps each center to leave a 12-point margin, matching the circle’s radius. Clicking near an edge therefore places the complete circle inside the canvas. Overlapping circles are allowed, and the count remains visible even when shapes overlap.

The button and canvas also have deliberately different keyboard behavior. The button’s shortcut performs the same action as activating that button, adding a circle in the next slot. It does not manufacture a pointer location. Apple documents keyboard shortcuts as invoking a control’s primary action and notes that duplicate shortcut assignments resolve to the first matching control found. Assign this shortcut once in the example window. keyboardShortcut(_:modifiers:)

The canvas’s accessibility label and count describe its contents; they do not provide a keyboard workflow for arbitrary coordinate placement. The button offers a non-pointer path for adding a circle in a predefined slot. If exact placement must also be available through keyboard controls, add an explicit position-editing interface rather than assuming a gesture or descriptive label supplies one.

How should hit testing match the custom surface?

Define the intended interaction area after establishing the label’s size. In the example, the button label has a 280-by-64 frame followed by a rounded rectangular contentShape. The canvas establishes its 320-by-200 frame and then declares a rectangular content shape. Apple defines contentShape(_:eoFill:) as the shape used for hit testing. contentShape(_:eoFill:)

This gives the visible surfaces explicit interaction boundaries. If you move the frame outside the button label during a redesign, inspect the result again: the example’s sizing and content shape belong together inside the label. Keep drawing, layout, and interaction geometry intentional instead of fixing an unresponsive margin by adding another action handler.

When a surface stops responding, inspect these conditions in order:

  1. Check geometry. Click the label’s text, graphic, and empty interior. Confirm the content shape describes the intended bounds.
  2. Check disabled ancestors. A disabled container overrides an enabled descendant, so a local .disabled(false) may not restore interaction. disabled(_:)
  3. Check decorative overlays. A view can be excluded from hit testing with .allowsHitTesting(false) when it is purely visual. Apply that to the decoration, not the interactive surface. allowsHitTesting(_:)
  4. Check competing handlers. Identify which view owns the action before changing gesture precedence.

How do you prevent competing handlers and ownership mistakes?

Use one handler for each intended activation path. In the example, the custom button owns its command and the canvas owns location-based placement. Neither sits inside a parent with another placement gesture. This structure makes a single click’s intended effect easy to inspect.

Avoid attaching a second tap handler to the button merely to reach the same mutation function. Likewise, do not add a parent gesture around several controls without deciding whether parent and child interactions should both occur. Apple’s simultaneousGesture example demonstrates that parent and child handlers can both run. highPriorityGesture instead gives the attached gesture higher precedence. These modifiers express interaction policy; they are not interchangeable repairs. simultaneousGesture(_:including:), highPriorityGesture(_:including:)

Keep application state with the view that owns it. Here, ShapeSurfaceView stores the synthetic circles, and the style only renders the supplied label. Apple recommends private state at the highest view that needs access, with values or bindings passed to descendants as appropriate. State

If a later AppKit adapter stores a callback, inspect its capture relationships separately. Swift closures can capture surrounding values; a class storing a closure that captures that same instance can create a strong reference cycle. Choose a capture list according to the actual object lifetime. Changing a button into a gesture does not resolve callback ownership. Closures

When is AppKit the appropriate next step?

Consider AppKit when your own surface needs its documented mouse-specific configuration. NSClickGestureRecognizer supports a required click count and a mouse-button mask. Apple specifies that bit zero represents the primary button and bit one the secondary button; recognition requires the configured clicks without dragging. It also automatically enables event delays for the configured mouse buttons. NSClickGestureRecognizer, buttonMask

Choose the integration path according to your deployment target and the component you own:

  • On macOS 26 or later, NSGestureRecognizerRepresentable integrates an AppKit recognizer directly into a SwiftUI hierarchy through .gesture(...). SwiftUI installs the action target; implement the representable’s action handler and use its context converter for coordinate conversion. Apple cautions that the recognizer’s associated view may have different geometry from the SwiftUI view. NSGestureRecognizerRepresentable
  • For an AppKit view hosted in SwiftUI, NSViewRepresentable manages creation, updates, and teardown of your view. Use its coordinator for callbacks, and let SwiftUI control the represented view’s layout. This API is available from macOS 10.15. NSViewRepresentable

Preserve the event boundary when adding either adapter. AppKit documents that a recognizer operates on its attached view and that view’s descendants, receives relevant events before the view, and may delay their delivery. It also explicitly does not support handing event tracking off to other mechanisms such as drag and drop or pop-up menus. Design those interactions through their supported mechanisms instead of treating a click recognizer as a universal event relay. NSGestureRecognizer

What should you verify in the finished Mac interaction?

Build the example in Xcode and check observable behavior before adapting it to a larger view hierarchy:

  • Click the custom button’s text and empty interior. Each activation should add one circle and update the count once.
  • Press Command–Shift–A in the active window. It should perform the button’s slot-based action.
  • Click different canvas positions, including near its edges. Circles should follow local coordinates with the documented-in-code margin.
  • Reach six circles. Both placement paths should stop adding, while Clear circles remains available.
  • Clear the canvas. Placement should become available again.
  • Inspect the button with VoiceOver and test keyboard navigation under your Mac’s current settings. Verify that its action remains understandable after styling.

For a custom Mac action surface, begin with the meaning of the interaction: a named command belongs in a Button, visual customization belongs in its label and style, and location-dependent placement belongs on the canvas gesture. Keep those paths connected through one state-changing function, then introduce AppKit only when its additional configuration answers a concrete requirement.

Sources