The aim for this post to build a very simple List view. When you long-press a row you will see a classic iOS context menu. It will look a little like this:

Context-menu demo

Why Context Menus?

Context menus give users an easy way to discover secondary actions right where they’re looking. They:

  1. Keep the primary UI clean.
  2. Surface actions only when they’re relevant.
  3. Feel familiar thanks to the long-press gesture we already use across iOS.

What We’ll Build

We’ll create a simple list of fruit. A long-press on any row reveals three actions:

  • “Favorite” – toggles a star
  • “Delete” – removes the item
  • “Share” – brings up a share sheet

That’s enough functionality to highlight the most common context-menu patterns.

Project Setup

  1. Create a new iOS 16+ SwiftUI project.
  2. Delete the template ContentView.swift; we’ll replace it with our own.

The Model

struct Fruit: Identifiable {
    let id = UUID()
    var name: String
    var isFavorite: Bool = false
}

@MainActor
final class FruitStore: ObservableObject {
    @Published var fruits: [Fruit] = [
        .init(name: "🍎 Apple"),
        .init(name: "🍌 Banana"),
        .init(name: "🍑 Peach"),
        .init(name: "🍍 Pineapple")
    ]
    
    func delete(_ fruit: Fruit) {
        fruits.removeAll { $0.id == fruit.id }
    }
}

Nothing fancy—just an ObservableObject to hold an array of Fruit. The function delete removes a Fruit from the array.

Building the List

struct FruitList: View {
    @StateObject private var store = FruitStore()
    @State private var shareItem: Fruit?
    
    var body: some View {
        NavigationStack {
            List {
                ForEach(store.fruits) { fruit in
                    FruitRow(fruit: fruit)
                        // Here we add the context menu
                        .contextMenu {
                            contextMenu(for: fruit)
                        }
                }
            }
            .navigationTitle("Fruit Basket")
            .sheet(item: $shareItem) { fruit in
                ActivityView(items: [fruit.name])
            }
        }
    }
}
  • FruitRow is our reusable cell view (see below).
  • contextMenu attaches a menu to every row. This means any all FruitRow can be long-pressed to see a context menu.
  • We keep a @State property (shareItem) to drive a share sheet. This is only here as one of our context menu items is a share button.

The Row View

struct FruitRow: View {
    var fruit: Fruit
    
    var body: some View {
        HStack {
            Text(fruit.name)
                .font(.headline)
            Spacer()
            if fruit.isFavorite {
                Image(systemName: "star.fill")
                    .foregroundColor(.yellow)
                    .transition(.scale)
            }
        }
        .animation(.default, value: fruit.isFavorite)
    }
}

The tiny star uses a scale transition so it animates in nicely.

Crafting the Context Menu

extension FruitList {
    @ViewBuilder
    func contextMenu(for fruit: Fruit) -> some View {
        Button {
            toggleFavorite(fruit)
        } label: {
            Label("Favorite", systemImage: fruit.isFavorite ? "star.slash" : "star")
        }
        
        Button(role: .destructive) {
            store.delete(fruit)
        } label: {
            Label("Delete", systemImage: "trash")
        }
        
        Button {
            shareItem = fruit
        } label: {
            Label("Share", systemImage: "square.and.arrow.up")
        }
    }
    
    private func toggleFavorite(_ fruit: Fruit) {
        if let index = store.fruits.firstIndex(where: { $0.id == fruit.id }) {
            store.fruits[index].isFavorite.toggle()
        }
    }
}

Here we use a ViewBuilder so that we can extract the logic that builds the context menu. This makes the earlier call site easier to read, and separates the context menu logic.

ButtonRole

  1. .destructive role automatically tints the “Delete” button red.
  2. .cancel is another available option

Adding a Share Sheet

A share button in a context menu is fairly common. When tapped it presents a system share sheet.

UIKit still owns the system share sheet, so we wrap it in UIViewControllerRepresentable.

struct ActivityView: UIViewControllerRepresentable {
    let items: [Any]
    
    func makeUIViewController(context: Context) -> UIActivityViewController {
        UIActivityViewController(activityItems: items, applicationActivities: nil)
    }
    
    func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) { }
}

Bonus: Context Menus on Grid Items

The API is view-agnostic. Swap List for LazyVGrid, attach the same contextMenu, and you get identical behavior.

Grid with context menus

Conclusion

Context menus hide complexity until the user asks for it. In SwiftUI they’re a one-liner (.contextMenu) but, as we’ve seen, you can layer in:

  1. Role-based styling (.destructive)
  2. Animated state changes
  3. System integrations like the share sheet

Context menus… easy to implement and a delight to discover.

Happy coding! 🍏