For a custom, single-column collection in your Mac app, use SwiftUI’s List with a selection binding backed by a set of stable item IDs. Customize the rows while letting the native control handle range selection and Command-click selection.

This tutorial builds a collection of synthetic labels, from Tile 01 to Tile 12. The reader’s task is to select adjacent items, add or remove individual items, and keep the selection understandable when filtering or reversing the display order. A later section explains the AppKit path for a collection that needs a grid or another custom layout.

What should range and Command-click selection do?

Apple’s Mac User Guide describes the familiar interaction: click the first item and Shift-click the last to select adjacent items, including those between them. Command-click selects individual items that need not be adjacent; Command-clicking a selected item removes it from a multiple selection. These are the basic interactions to preserve in this collection. Select items on your Mac screen.

For the initial implementation, use a single ordered list. That gives “between” an unambiguous meaning: the rows between two endpoints in the current display order. A spatial collection introduces another decision—whether a range follows the data sequence or a region of the layout—which deserves separate testing.

Choose the control according to what the user selects:

Reader task Suitable starting point Selection representation
Select complete custom rows SwiftUI List Set of item IDs
Select complete tiles in a custom layout AppKit NSCollectionView Selection index paths, mapped to model IDs
Select characters within a label SwiftUI text selection Text selection supplied by the text view
Select ranges within a text document AppKit text system Character or text ranges

Apple documents List as a single-column container with optional selection, and NSCollectionView as an ordered collection with a customizable layout. Those are the supported starting points for item selection. List, NSCollectionView.

Build the complete SwiftUI example

Create a macOS app project in Xcode using SwiftUI. Set its deployment target to macOS 14 or later for this example: the two-parameter onChange closure used below requires macOS 14. Replace the generated app declaration with this code, leaving exactly one @main entry point in the target. onChange(of:initial:_:), App.

The following is a complete small app, including its synthetic data and window. It uses no external assets. It has been inspected against the documented APIs, but has not been compiled or run on macOS here.

import Foundation
import SwiftUI

struct Tile: Identifiable {
    let id: Int
    let title: String
}

@main
struct CollectionSelectionDemoApp: App {
    var body: some Scene {
        WindowGroup {
            TileCollectionView()
        }
    }
}

struct TileCollectionView: View {
    private let tiles: [Tile] = (1...12).map {
        Tile(id: $0, title: String(format: "Tile %02d", $0))
    }

    @State private var selectedIDs: Set<Int> = []
    @State private var query = ""
    @State private var reversed = false

    private var visibleTiles: [Tile] {
        let filtered = tiles.filter {
            query.isEmpty ||
            $0.title.localizedCaseInsensitiveContains(query)
        }
        return reversed ? Array(filtered.reversed()) : filtered
    }

    private var selectionDescription: String {
        let names = visibleTiles
            .filter { selectedIDs.contains($0.id) }
            .map(\.title)
        return names.isEmpty ? "None selected" : names.joined(separator: ", ")
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            TextField("Filter tiles", text: $query)
                .textFieldStyle(.roundedBorder)

            HStack {
                Button("Reverse order") {
                    reversed.toggle()
                }
                Button("Clear selection") {
                    selectedIDs.removeAll()
                }
                .disabled(selectedIDs.isEmpty)
            }

            List(visibleTiles, selection: $selectedIDs) { tile in
                HStack(spacing: 12) {
                    RoundedRectangle(cornerRadius: 4)
                        .fill(Color.gray.opacity(0.2))
                        .frame(width: 32, height: 32)
                    VStack(alignment: .leading) {
                        Text(tile.title)
                        Text("Sample item \(tile.id)")
                            .font(.caption)
                            .foregroundStyle(.secondary)
                    }
                    Spacer()
                }
                .padding(.vertical, 4)
                .tag(tile.id)
            }

            Text("\(selectedIDs.count) selected")
            Text(selectionDescription)
                .font(.caption)
        }
        .padding()
        .frame(minWidth: 440, minHeight: 480)
        .onChange(of: query) { _, _ in
            let visibleIDs = Set(visibleTiles.map(\.id))
            selectedIDs.formIntersection(visibleIDs)
        }
    }
}

The selection binding’s element type matches Tile.ID: both use Int. Apple documents that binding a list’s selection to a set of its data’s ID type enables multiple selection. On a Mac, multiple selection does not require entering edit mode. Supporting selection in lists.

The row remains customizable: it contains a neutral shape, a title, and a secondary label. There is no row-level click handler or separate toggle operation. Letting the list own selection avoids having two independent pieces of code update the selection for one interaction.

Keep item identity separate from display order

The example deliberately uses three separate concepts: an ordered array of items, a set of selected IDs, and a filtered presentation of that array. Swift defines arrays as ordered collections and sets as collections of unique values without a defined order. That makes a set appropriate for membership, while an array supplies the order used for presentation. Collection Types.

The integer IDs are fixed for this synthetic dataset. Reversing the array changes where an item appears without changing its identity. In a real model, assign each item a stable identifier when creating or loading it, and retain that identifier through later view updates. Avoid generating a new identity each time a row is evaluated.

The selection summary follows visibleTiles and checks membership in selectedIDs. This is an implementation choice derived from those collection properties: it produces a summary in display order rather than relying on the iteration order of a set.

Filtering needs an explicit product decision. This example adopts a visible-items-only policy: whenever the filter changes, the selection is intersected with the IDs still visible. Hidden items lose selection, and clearing the filter does not restore their previous selection. The labels and source array remain unchanged.

For example, begin with Tile 02, Tile 03, and Tile 04 selected. Enter 04 in Filter tiles. Under this example’s policy, only Tile 04 remains selected. Remove the filter, and the other two items reappear unselected. This is an illustrative walkthrough of the code, not a reported runtime result.

A different app may need selection to persist across filters. In that case, move selection ownership into the model, define how hidden selections appear in the count and summary, and test reconciliation with the displayed control. Removing the intersection alone does not establish a complete hidden-selection policy.

When should you use an AppKit collection view?

Use NSCollectionView when the collection needs tiles in multiple columns or a layout beyond a single list. Apple separates the collection’s data source, item view controllers, delegate, and layout object. This lets you customize appearance and positioning while retaining a collection control as the interaction owner. NSCollectionView.

Configure the AppKit implementation in this order:

  1. Create the collection view and assign a modern layout, such as NSCollectionViewFlowLayout.
  2. Provide its data source and register the item class or nib used to create cells.
  3. Set isSelectable to true and allowsMultipleSelection to true.
  4. Set allowsEmptySelection according to whether the task permits selecting nothing.
  5. Read selectionIndexPaths, then map each valid index path to the corresponding model ID.
  6. Render selection in the item view using its isSelected state.

Multiple selection is disabled by default. Enabling it is therefore an explicit setup step, not something implied by drawing selectable-looking tiles. The item’s isSelected property reports whether that item is selected. allowsMultipleSelection, isSelected.

Treat index paths as locations in the current data source. Apple documents that selectionIndexPaths updates during interactive selection and can also be assigned programmatically. Assigning an index path that does not identify a valid data-source item raises an exception. After reordering or filtering, reconstruct selection locations from stable IDs instead of reusing stale positions. selectionIndexPaths.

Preserve the documented boundary: allowsMultipleSelection enables selecting more than one item; its documentation does not specify a complete range-anchor algorithm for every custom layout or modifier combination. Verify Shift-click behavior in the actual layout and supported macOS versions. If the required task is a precise linear range, the single-column implementation above is the clearer starting point.

For a SwiftUI app that needs the AppKit control, embed it through NSViewRepresentable. Create the AppKit hierarchy in makeNSView, reconcile model changes in updateNSView, and use a coordinator to forward delegate events into SwiftUI state. SwiftUI controls the represented view’s frame and bounds. NSViewRepresentable.

Programmatic updates also need an explicit synchronization path. Apple states that both collectionView(_:shouldSelectItemsAt:) and collectionView(_:didSelectItemsAt:) are skipped when selection is set programmatically. Keep model updates in a shared operation rather than depending on those callbacks to run after every assignment. collectionView(_:shouldSelectItemsAt:), collectionView(_:didSelectItemsAt:).

Does text selection provide the same feature?

SwiftUI’s textSelection(.enabled) controls selection of text within views. Applying it to a container makes each contained text view selectable. It does not document a collection-item selection binding or a shared range-selection model across arbitrary cards. Use it when the task is selecting characters from a label. textSelection(_:).

For a text-document interface, AppKit exposes character selections through NSTextView.selectedRanges. Its ranges are ordered, nonoverlapping, and noncontiguous. TextKit’s NSTextSelection.textRanges likewise represents logically ordered text ranges and is available from macOS 12. These APIs describe text positions, so translating them into tile IDs would be an application-specific design. selectedRanges, textRanges.

Decide which gesture a row’s label should serve before enabling text selection inside it. For this tutorial’s task, clicking a label should participate in selecting the complete row, so the example leaves text selection unconfigured.

Verify the interaction with a small selection matrix

Run these checks in the app’s own window. The first four check the basic Mac selection conventions; the remaining checks exercise the example’s identity and filtering decisions. Apple’s documented interaction is the baseline, while the synthetic labels make the expected membership easy to inspect. Select items on your Mac screen.

Action Expected result
Click Tile 02 One selected item
Shift-click Tile 05 Tile 02 through Tile 05 selected
Command-click Tile 08 Tile 08 joins the selection
Command-click selected Tile 03 Tile 03 leaves the selection
Click Clear selection Empty selection and None selected summary
Select Tile 04, then reverse order The selected ID still identifies Tile 04
Select several rows, then filter to 04 Only a previously selected, still-visible item remains selected
Clear the filter Hidden rows return without restoring discarded selections

Also check keyboard navigation after clicking the list, scrolling selected rows out of view and back, and beginning a new range after filtering. For an AppKit grid, add checks that cross a row boundary. If the result differs from the intended task, reduce the interface to a plain native control first, then reintroduce custom row content and gesture handlers one at a time.

Keep the native control responsible for interaction, stable IDs responsible for membership, and the displayed array responsible for order. With those responsibilities explicit, custom row styling, filtering, and sorting can support the same understandable selection task.

Sources