Adding context menus to rows in SwiftUI's Table on macOS

Published on 26 October 2022

When I was working on the tables of certificates, devices, bundle IDs and provisioning profiles in AppDab, I stumbled upon a missing feature in SwiftUI on macOS: It is not possible to add a context menu to the full row in a Table.

One could expect, that the right thing to do, was to just add the .contextMenu to the Table, but sadly it doesn't do anything. It is possible to add the context menu to the content of the column, but doing so, the context menu is only triggered when right-clicking on the Text in the column and not the full cell.

import SwiftUI

struct BundleIdsListView: View {
    @State var items: [BundleIdViewModel]

    var body: some View {
        Table(items) {
            TableColumn("Name", value: \.name) {
                Text($0.name)
                    .contextMenu(
                        ContextMenu(menuItems: {
                            Button("Rename", action: { ... })
                            Button("Delete", action: { ... })
                        })
                    )
            }
		 }
    }
}

The "solution"

After a lot of researching and experiments I found a thread on Apple Developer Forums that lead me to something. It is not pretty and doesn't work as well as other AppKit apps, but the user can right-click on the whole width of the row in the table.

So make it work this way, we need to make the Text in the column fill all of the available space and add a .contentShape like this:

import SwiftUI

struct BundleIdsListView: View {
    @State var items: [BundleIdViewModel]

    var body: some View {
        Table(items) {
            TableColumn("Name", value: \.name) {
                Text($0.name)
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
                    .contentShape(Rectangle())
                    .contextMenu(
                        ContextMenu(menuItems: {
                            Button("Rename", action: { ... })
                            Button("Delete", action: { ... })
                        })
                    )
            }
        }
    }
}

Simplification

But when we have multiple columns we will now have the workaround and the context menu duplicated for every column. To simplify this, I added a view modifier which takes the ContextMenu and applies it along with the workaround.

import SwiftUI

public extension View {
    func tableColumnContextMenu<MenuItems>(_ contextMenu: ContextMenu<MenuItems>?) -> some View where MenuItems: View {
        self
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
            .contentShape(Rectangle())
            .contextMenu(contextMenu)
    }
}

Conclusion

With the view modifier it is possible to just create the context menu in a function and pass it along. This is the solution that we have to live with until SwiftUI gets a better API for context menus in Tables.

import SwiftUI

struct BundleIdsListView: View {
    @State var items: [BundleIdViewModel]

    var body: some View {
        Table(items) {
            TableColumn("Name", value: \.name) {
                Text($0.name)
                    .tableColumnContextMenu(createContextMenu(for: $0))
            }
            TableColumn("Identifier", value: \.identifier) {
                Text($0.identifier)
                    .tableColumnContextMenu(createContextMenu(for: $0))
            }
            TableColumn("Type", value: \.platform) {
                Text($0.platform)
                    .tableColumnContextMenu(createContextMenu(for: $0))
            }
        }
    }
    
    private func createContextMenu(for bundleId: BundleIdViewModel) -> ContextMenu<TupleView<(Button, Button)>> {
        ContextMenu {
            Button("Rename", action: { ... })
            Button("Delete", action: { ... })
        }
    }
}

Tagged with: