New in SwiftUI for iOS 15: PrimaryAction for Menu

DevTechie Inc
Apr 6, 2022
Let’s build a slide-out menu — like the navigation drawer

Photo by Sigmund on Unsplash

Menu control was introduced with iOS 14 and with the launch of iOS 15, SwiftUI brought primary action feature to Menus.

Primary action can be used to perform a default action when the user taps on the menu.

When a primary action is present, menu presentation becomes a secondary gesture. So in order to launch the menu user will have to long press.

To understand menus and primary action better, we will build a slide-out menu, which will look like this:

Let’s start with basic setup

Code above will divide page into two sections, left section will be used for menu and right section will serve as content area.

Next we will add State property to show/hide menu section:

@State private var showMenu = false
We will also add Button in content area to toggle showMenu property. We will put this button inside HStack to push it toward the left side of content area.

HStack {
    Button {
        showMenu.toggle()
    } label: {
        Image(systemName: "arrow.left.square.fill")
            .rotationEffect(showMenu ? Angle.degrees(-90) : Angle.degrees(0))
    }
    .padding(.top, 50)Spacer()
}
Notice the rotationEffect modifier in code above. We are using showMenu property to toggle Angle for the Image view.

We’ll use showMenu property to control frame and opacity of menu section as well. Let’s add the following two modifiers to the menu section replacing the existing frame modifier:

.frame(maxWidth: showMenu ? 100 : 0, maxHeight: .infinity)
.opacity(showMenu ? 1.0 : 0.0)
Let’s set the font for the content section to largeTitle as well in the font modifier.

.font(.largeTitle)
Complete code:

The menu looks good but it's kinda jumpy. Let’s fix that with animation modifier. Add the following modifier at the outermost HStack:

.animation(.easeInOut, value: showMenu)
Now that we have our menu working, let's add some menu items to this — a VStack under Color in menu section:

At this point we have a menu that shows us options but selecting them doesn’t do anything. Let’s fix that.

We will add a State property under showMenu State property.

@State private var selectedAction = ""
In the content area, under the Text DevTechie, we will add another Text to display the selected option:

Text("Selected option: \(selectedAction)")
.font(.caption)
.opacity(selectedAction.isEmpty ? 0.0 : 1.0)
Notice opacity modifier’s use here, we are going to display this selected Text only when selectedAction property has some value.

Now for three menu actions, we will create three functions. These functions will also update selectedAction state property.

func chat() { selectedAction = "Chat" }
func message() { selectedAction = "Message" }
func phone() { selectedAction = "Phone" }
We will also change our menu button’s action from {} to function name:

Button(action: chat) { Label("Chat", systemImage: "mic.circle") }Button(action: message) { Label("Message", systemImage: "message") }Button(action: phone) { Label("Call", systemImage: "phone") }
Complete code will look like this:

So far we have seen Menu with Label views but Menu can contain another menu as well as view.

Let’s add another menu item to the page.

Menu(content: {
    Button(action: chat) {
        Label("Group Chat", systemImage: "person.3")
    }
    Button(action: message) {
        Label("Share Play", systemImage: "shareplay")
    }Menu {
        Button(action: chat) {
            Label("Call Now", systemImage: "phone.and.waveform")
        }        Menu {
            Button(action: chat) {
                Label("Remind Me", systemImage: "clock.arrow.circlepath")
            }Button(action: chat) {
                Label("Schedule", systemImage: "calendar.badge.clock")
            }
        } label: {
            Label("Call Later", systemImage: "alarm")
        }
    } label: {
        Label("Group Call", systemImage: "person.2.wave.2")
    }}, label: {
    Image(systemName: "person")
})
    .padding()
    .contentShape(Rectangle())
    .background(.ultraThinMaterial, in: Circle())
We are going to reuse chat, message, and call functions with this second menu as well.

Notice, the nested Menu with label “Call Later” is nested inside “Group Call” menu option.

Complete code will look like this:

Primary Action in iOS 15
iOS 15 brought a new option to add a default action for Menu. This means our menu can have a primary action and when the user taps on the menu, we will execute that action. Users will have to long-press in order to see other Menu options. Let’s add this to our Menu which also has a icon.

Menu(content: {
    Button(action: chat) {
        Label("Chat", systemImage: "mic.circle")
    }
    Button(action: message) {
        Label("Message", systemImage: "message")
    }
    Button(action: phone) {
        Label("Call", systemImage: "phone")
    }
}, label: {
    Image(systemName: "gear")
},  primaryAction: {
    phone()
})
Complete code:

struct MenuExample2: View {
    
    // Menu toggle
    @State private var showMenu = false
    @State private var selectedAction = ""
    
    var body: some View {
        HStack(spacing: 0) {
            // Menu
            ZStack {
                Color.black.opacity(0.5)
                
                VStack(spacing: 20) {
                    
                    Text("Hello user")
                        .font(.body)
                        .bold()
                        .padding(.top, 100)
                    
                    Rectangle().fill(Color.gray).frame(height: 1)
                        .shadow(radius: 1)
                    
                    
                    Menu(content: {
                        Button(action: chat) {
                            Label("Chat", systemImage: "mic.circle")
                        }
                        Button(action: message) {
                            Label("Message", systemImage: "message")
                        }
                        Button(action: phone) {
                            Label("Call", systemImage: "phone")
                        }
                    }, label: {
                        Image(systemName: "gear")
                    },  primaryAction: {
                        phone()
                    })
                        .padding()
                        .contentShape(Rectangle())
                        .background(.ultraThinMaterial, in: Circle())
                    
                    Menu(content: {
                        Button(action: chat) {
                            Label("Group Chat", systemImage: "person.3")
                        }
                        Button(action: message) {
                            Label("Share Play", systemImage: "shareplay")
                        }
                        
                        Menu {
                            Button(action: chat) {
                                Label("Call Now", systemImage: "phone.and.waveform")
                            }
                            Menu {
                                Button(action: chat) {
                                    Label("Remind Me", systemImage: "clock.arrow.circlepath")
                                }
                                
                                Button(action: chat) {
                                    Label("Schedule", systemImage: "calendar.badge.clock")
                                }
                            } label: {
                                Label("Call Later", systemImage: "alarm")
                            }
                        } label: {
                            Label("Group Call", systemImage: "person.2.wave.2")
                        }}, label: {
                        Image(systemName: "person")
                    })
                        .padding()
                        .contentShape(Rectangle())
                        .background(.ultraThinMaterial, in: Circle())
                    
                    Spacer()
                }
            }
            .frame(maxWidth: showMenu ? 100 : 0, maxHeight: .infinity)
            .opacity(showMenu ? 1.0 : 0.0)
            
            // content area
            VStack {
                HStack {
                    Button {
                        showMenu.toggle()
                    } label: {
                        Image(systemName: "arrow.left.square.fill")
                            .rotationEffect(showMenu ? Angle.degrees(-90) : Angle.degrees(0))
                    }
                    .padding(.top, 50)
                    
                    
                    Spacer()
                }
                
                ZStack {
                    Color.orange
                    
                    VStack {
                        Text("DevTechie")
                        
                        Text("Selected option: \(selectedAction)")
                            .font(.caption)
                            .opacity(selectedAction.isEmpty ? 0.0 : 1.0)
                    }
                    
                }
            }
            .font(.largeTitle)
        }
        .background(Rectangle().fill(Color.orange))
        .edgesIgnoringSafeArea(.all)
        .foregroundColor(.white)
        // animation modifier
        .animation(.easeInOut, value: showMenu)
    }
    
    func chat() {
        selectedAction = "Chat"
    }
    func message() {
        selectedAction = "Message"
    }
    func phone() {
        selectedAction = "Phone"
    }
}

With that, we have reached the end of this article. Thank you for reading. Don’t forget to subscribe our newsletter.