For a Mac item list, bind selection to a SwiftUI List and supply a primary action through its selection-aware context menu. A single click selects the row; a double click opens it. Keep selection state separate from presentation state.

This tutorial implements that interaction inside your own macOS app using three synthetic, in-memory items. It also explains the boundaries for custom SwiftUI surfaces and AppKit views, so you can choose an approach without making multiple layers compete for the same click.

Use the selectable container’s primary action

Apple explicitly documents single-click selection and double-click primary actions for selectable containers on macOS. The supported SwiftUI entry point is contextMenu(forSelectionType:menu:primaryAction:), available starting with macOS 13. Attach it to a selection-supporting container such as List or Table. Apple’s selection-aware context menu documentation.

There are three separate responsibilities: the model identifies each item, the list maintains selection through a binding, and your primary-action handler decides what opening means. In this example, opening displays an item-detail sheet. Selecting an item only changes the highlighted row and the selection label.

Choose your implementation according to the view that owns the interaction:

Your interface Appropriate starting point Responsibility you retain
A SwiftUI item list or table on macOS 13 or later Selection binding plus selection-aware primary action Resolve action IDs and present the corresponding content
A custom SwiftUI tile or shape Explicit gesture composition Selection appearance, activation policy, and other input paths
An AppKit table embedded in SwiftUI NSTableView selection and doubleAction Bridge selection and activation into SwiftUI state
A custom AppKit drawing surface Its own mouse-event handling Hit testing, item identity, and click-sequence policy

The first route directly matches the task here. The custom routes are useful when the interface genuinely needs a different surface; they also require more interaction behavior to be implemented. Apple documents the underlying alternatives in gesture composition, AppKit table double actions, and mouse-down handling.

Build a complete single-selection example

Create a macOS app project in Xcode with a SwiftUI interface and set its deployment target to macOS 13 or later. Replace the generated app and content-view declarations with the following single file. Keep only one @main app declaration in the target.

The program uses fixed integer IDs, three colored circles, and local state. It reads no files and opens no external resources. The toolbar-style Open button and the contextual Open command call the same function as double-click activation.

import SwiftUI

struct SampleItem: Identifiable {
    let id: Int
    let name: String
    let tint: Color
}

@main
struct ClickSelectionDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ItemBrowser()
        }
    }
}

struct ItemBrowser: View {
    private let items = [
        SampleItem(id: 1, name: "Circle A", tint: .blue),
        SampleItem(id: 2, name: "Circle B", tint: .orange),
        SampleItem(id: 3, name: "Circle C", tint: .purple)
    ]

    @State private var selectedID: SampleItem.ID? = nil
    @State private var openedItem: SampleItem? = nil
    @State private var message = "Select an item."

    private var selectedItem: SampleItem? {
        items.first { $0.id == selectedID }
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack {
                Text(selectedItem.map {
                    "Selected: \($0.name)"
                } ?? "No selection")
                Spacer()
                Button("Open") {
                    if let id = selectedID {
                        openItems([id])
                    }
                }
                .disabled(selectedItem == nil)
            }

            List(items, selection: $selectedID) { item in
                HStack(spacing: 10) {
                    Circle()
                        .fill(item.tint)
                        .frame(width: 18, height: 18)
                    Text(item.name)
                }
                .padding(.vertical, 4)
            }
            .contextMenu(forSelectionType: SampleItem.ID.self) { ids in
                if ids.count == 1 {
                    Button("Open") {
                        openItems(ids)
                    }
                }
            } primaryAction: { ids in
                openItems(ids)
            }

            Text(message)
                .font(.caption)
        }
        .padding()
        .frame(minWidth: 360, minHeight: 280)
        .sheet(item: $openedItem) { item in
            VStack(spacing: 16) {
                Circle()
                    .fill(item.tint)
                    .frame(width: 80, height: 80)
                Text(item.name)
                    .font(.title2)
                Button("Close") {
                    openedItem = nil
                }
            }
            .padding(32)
            .frame(minWidth: 260, minHeight: 220)
        }
    }

    private func openItems(_ ids: Set<SampleItem.ID>) {
        guard ids.count == 1, let id = ids.first else {
            message = "Choose exactly one item to open."
            return
        }
        guard let item = items.first(where: { $0.id == id }) else {
            message = "That item is no longer available."
            return
        }
        openedItem = item
        message = "Opened: \(item.name)"
    }
}

The declarations and references have been inspected, but this example has not been compiled or run here: the available environment has no Swift toolchain or macOS GUI. Build it in Xcode and perform the interaction checks below.

Keep identity, selection, and opening distinct

SampleItem conforms to Swift’s Identifiable protocol. Its fixed id represents the same item across view updates. Apple defines this protocol around stable identity; the display name is a separate property. In a larger app, preserve an item’s ID when its name or appearance changes. Apple’s Identifiable documentation.

The optional selectedID creates a single-selection list. A set of IDs would instead enable multiple selection. SwiftUI’s list documentation describes these two binding forms and the selection appearance provided by the container. Apple’s List documentation.

openedItem controls presentation independently. Setting it to an item presents the sheet; setting it to nil closes it. That follows the documented item-driven sheet API. Because selecting a row never assigns openedItem, selection alone cannot trigger the sheet in this implementation. Apple’s item-driven sheet documentation.

Resolve the IDs passed to the action

The context-menu API takes the container’s identifier type, and its action receives a set of IDs. The example therefore uses SampleItem.ID.self, matching the list selection. It resolves the callback’s argument rather than substituting a separately read selection value. Apple’s context-menu parameter documentation.

The handler deliberately accepts exactly one ID. An empty set produces a message, and an unresolved ID produces another message. If you later enable multiple selection, define the opening policy before changing this guard: present a group detail view, open separate windows, or require one selection. Taking an arbitrary first member would conceal an undefined policy.

Why two tap handlers need a different design decision

A custom tile may need its own gestures. SwiftUI’s onTapGesture(count:perform:) accepts a required number of clicks or taps. However, its documentation does not establish that placing two independent modifiers on a view creates an exclusive single-versus-double-click contract. Apple’s tap-action documentation.

First decide what selection means during a double click. For an ordinary item browser, selecting the item before opening it is useful and consistent with the container-based solution. A separate requirement—perform the single-click action only when the sequence never becomes a double click—requires arbitration.

For a custom surface, SwiftUI provides exclusively(before:): only one component gesture succeeds, with precedence given to the first. The following is a gesture-expression fragment, not a complete replacement for the list example: Apple’s exclusive-gesture documentation.

TapGesture(count: 2)
    .exclusively(before: TapGesture(count: 1))

This puts double-click recognition first. Attach the composed gesture to the custom item and handle its result according to your selection and opening policy. TapGesture documents recognition by the required tap count. Apple’s TapGesture documentation.

There is a timing consequence to that policy: deciding that a sequence is exclusively a single click requires ruling out a double click. That is a consequence of the interaction requirement, not a documented fixed SwiftUI delay. Avoid promising immediate single-click completion while also requiring that it never occur during a double-click sequence.

On a custom tile, also define the intended clickable area. contentShape(Rectangle()) can make a padded rectangular region participate in hit testing; it does not establish gesture precedence. Keep those two concerns separate when diagnosing missed clicks. Apple’s content-shape documentation.

Preserve event ownership when AppKit is involved

If an existing AppKit table owns the rows, use that table’s supported activation mechanism. NSTableView.doubleAction sends a selector to its target on a double click. Apple also documents clickedRow and clickedColumn for determining where activation occurred, including column headings. Validate that the location identifies an item before opening it. Apple’s doubleAction documentation.

For a custom AppKit surface, mouseDown(with:) receives a left-button-down event, and NSEvent.clickCount reports the click count associated with mouse-down or mouse-up events. Reading that property on an unrelated event is outside its documented contract and can raise an exception. Keep such interpretation inside the view handling the appropriate mouse event. Apple’s mouse-down documentation, Apple’s click-count documentation.

Use NSViewRepresentable when embedding that owned AppKit view in SwiftUI. Create it in makeNSView, synchronize current configuration in updateNSView, and use a coordinator where target-action or delegate communication needs a bridge. Apple documents creation, updating, coordination, and teardown as parts of this integration. Apple’s NSViewRepresentable documentation.

An overlay that receives mouse events can change which view handles the click. AppKit uses hit testing to determine the recipient of mouse-down events, so an event-catching overlay should not be treated as an invisible observer. For this task, keep recognition in the selectable container or the custom surface that actually owns the item interaction. Apple’s hit-testing documentation.

Verify selection and opening as separate outcomes

Use this sequence after building the example:

  1. Click Circle A once. Its row should become selected, the selection label should name it, and no sheet should appear.
  2. Double-click Circle B. Confirm that the detail sheet shows Circle B.
  3. Close the sheet and click Circle C once. Confirm that selection changes without opening another sheet.
  4. Activate the visible Open button. Confirm that it opens the selected item through the same handler.
  5. Invoke a row’s contextual Open command and verify the item it opens.
  6. Click and double-click unused list space. Confirm that it does not open an unrelated item.
  7. If you add embedded controls, dragging, or overlays, repeat the checks over those exact regions.

If selection unexpectedly opens details, inspect where presentation state is assigned. If activation opens the wrong item, inspect the callback IDs and the model lookup. If clicks miss part of a custom tile, inspect its layout and hit-testing shape before changing gesture precedence. These checks isolate state, action targeting, and event delivery instead of changing all three at once.

For a conventional Mac item list, keep the implementation centered on the selection binding and the container’s primary action. Add custom recognition only when the surface or interaction requires it, and make every opening path resolve the intended item through one explicit handler.

Sources