Mastering Swift and SwiftUI

Insights, Tips, and Tutorials for iOS Developers

SwiftUI Modifiers Deep Dive: contextMenu

Published on Sep 18, 2024

Get a quick glimpse of how Tiny Currency simplifies currency conversion with up to date rates, multi-currency support, and easy-to-use widgets. Perfect for on-the-go use, our app ensures you're always prepared, no matter where your travels take you.

📱 iOS 13.0+

On this deep dive, we are exploring the contextMenu SwiftUI modifier, which was added in iOS 13.0.

Apple's Documentation

Use this modifier to add a context menu to a view in your app’s user interface. Compose the menu by returning controls like Button, Toggle, and Picker from the menuItems closure. You can also use Menu to define submenus or Section to group items.

The following example creates a Text view that has a context menu with two buttons:

Text("Turtle Rock")
    .padding()
    .contextMenu {
        Button {
            // Add this item to a list of favorites.
        } label: {
            Label("Add to Favorites", systemImage: "heart")
        }
        Button {
            // Open Maps and center it on this item.
        } label: {
            Label("Show in Maps", systemImage: "mappin")
        }
    }

Usage

Context menus are great to expose some additional functionality in specific contexts, although the downside is that it is not as obvious to the user.

A good example here would be messaging apps. Long pressing on a message would bring up additional actions such as delete, edit, unsend etc.

Let's build a simple conversation list to show how context menus could work.

First, let's define a Message like so:

struct Message: Identifiable {
    enum Direction {
        case incoming, outgoing
    }

    let id = UUID()
    let text: String
    let direction: Direction
}

Now we can create a simple conversation list:

struct ConversationView: View {
    var messages: [Message] = [
        Message(text: "Incoming Message", direction: .incoming),
        Message(text: "Outgoing Message", direction: .outgoing)
    ]
    
    var body: some View {
        NavigationStack {
            ScrollView {
                ForEach(messages) { message in
                    MessageBubble(message: message)
                        .contextMenu {
                            Button("Delete", role: .destructive) { }
                        }
                }
            }
            .navigationTitle("Conversation")
        }
    }
}
Conversation Demo FullContext Demo Full

As you can see above, we have a conversation list and a context menu, but our context menu is taking up the entire space of the row.

The MessageBubble view handles all the spacing necessary to move the message to the leading or trailing side of the view depending on the message direction.

Luckily, SwiftUI gives us an easy way to fix this issue without having to change the MessageBubble too much.

First, let's take a look at what MessageBubble looks like:

struct MessageBubble: View {
    let message: Message
    
    var body: some View {
        HStack {
            if message.direction == .outgoing {
                Spacer()
            }
            Text(message.text)
                .foregroundStyle(foregroundStyle)
                .padding(.vertical, 8)
                .padding(.horizontal, 12)
                .background(backgroundStyle, in: backgroundShape)
                .containerRelativeFrame(
                    .horizontal,
                    count: 4,
                    span: 3,
                    spacing: 0,
                    alignment: message.direction == .outgoing ? .trailing : .leading
                )
            if message.direction == .incoming {
                Spacer()
            }
        }
        .padding(.horizontal)
    }
    
    private var foregroundStyle: AnyShapeStyle {
        switch message.direction {
        case .incoming:
            return AnyShapeStyle(.primary)
        case .outgoing:
            return AnyShapeStyle(.white)
        }
    }
    
    private var backgroundStyle: AnyShapeStyle {
        switch message.direction {
        case .incoming:
            return AnyShapeStyle(.background.quaternary)
        case .outgoing:
            return AnyShapeStyle(.blue)
        }
    }
    
    private var backgroundShape: some Shape {
        .rect(cornerRadii: RectangleCornerRadii(
            topLeading: 20,
            bottomLeading: message.direction == .incoming ? 4 : 20,
            bottomTrailing: message.direction == .outgoing ? 4 : 20,
            topTrailing: 20
        ))
    }
}

Ok, so now that we how MessageBubble works, we can see that there are spacers in an HStack used to space out the message appropriately.

To fix the contextMenu preview issues, there is another simple modifier: contentShape.

We can simply apply a contentShape modifier to the MessageBubble prior to the framing:

Text(message.text)
    .foregroundStyle(foregroundStyle)
    .padding(.vertical, 8)
    .padding(.horizontal, 12)
    .background(backgroundStyle, in: backgroundShape)
    // Add the contentShape here, and use the same shape as the background above
    .contentShape(.contextMenu, backgroundShape)
    .containerRelativeFrame(...)

Once we set the contentShape for the contextMenu, it now looks like this:

Content Left Demo FullContent Right Demo Full

Awesome, our context menu is now shaped according to the MessageBubble, and it looks great!

Try it out yourself.