A SwiftUI Sidebar

Many apps use a sidebar, or menu, that appears when the user taps a control, typically in the upper left corner of the main screen. The menu slides in from the left and remains until dismissed somehow. This article shows you a very SwiftUI way of making a sidebar.

Let’s begin by making a Sidebar component that wraps some content with a NavigationView. Putting the content inside a NavigationView gives the sidebar a title area and you can put other controls there if you like using the .toolbar modifier (which I did not use for this example).

struct Sidebar<Content : View> : View {
    var title: LocalizedStringKey
    var content : Content
    
    init(title: LocalizedStringKey, @ViewBuilder content: () -> Content) {
        self.title = title
        self.content = content()
    }
    
    var body: some View {
        NavigationView {
            content
                .navigationTitle(title)
        }
        .navigationViewStyle(StackNavigationViewStyle())
    }
}

The Sidebar view uses @ViewBuilder and takes a title to display along with the content to wrap. You can use it like this:

Sidebar(title: "Main Menu") {
    List {
        Button("Home") { ... }
        Button("Actions") { ... }
    }
}

Let’s give the Sidebar an actual menu of items to display. Create a new SwiftUI View file called MenuItems.swift like this:

struct MenuItems: View {
    
    enum MenuCode {
        case home
        case search
        case folder
        case edit
        case delete
    }
    
    var action: (MenuCode) -> Void
    
    var body: some View {
        List {
            Divider()
                
            Button(action: {self.action(.home)}) {
                Label("Home", systemImage: "house")
            }
            Button(action: {self.action(.search)}) {
                Label("Search", systemImage: "magnifyingglass")
            }
            Button(action: {self.action(.folder)}) {
                Label("Folder", systemImage: "folder")
            }
            Button(action: {self.action(.edit)}) {
                Label("Edit", systemImage: "pencil")
            }
                
            Divider()
                
            Button(action: {self.action(.delete)}) {
                Label("Delete", systemImage: "trash")
            }
        }
        .listStyle(SidebarListStyle())
    }
}

The menu is a list of buttons and tapping one calls the MenuItems action callback. You’ll see how to use this in a little bit.

Now you’ve got the sidebar itself squared away and can look at the bigger picture. What we want is to have the content of the app with a sidebar sliding over it to reveal the menu and sliding away once a menu item has been picked. Typically, when a sidebar is viewed, it does not cover the entire screen and the visible portion “below” the sidebar should not be enabled.

Basically you want this structure when the sidebar appears:

ZStack {
   // 1 - your main app content
    MainView()
        .zIndex(0)
   // 2 - a shield against using the main content
    Color.black
        .zIndex(1)
        .opacity(0.25)
        .edgesIgnoringSafeArea(.all)
   // 3 - SideBar with its menu
    SideBar(title: "Main Menu") {
        MenuItems() { menuItem in
            // do something with the menuItem
        }
    }
    .zIndex(2)
    .frame(width: 300)
    .transition(.asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .leading)))
}
  1. The main content for your app.

  2. The “shield” is just a translucent color that sits above your main content and covers the entire screen

  3. The Sidebar itself with its content and action handler. It also has a transition for when it is added and removed.

Notice that each item in the ZStack has been given an explicit zIndex. This is important when applying transitions, which is added to the Sidebar.

The code above, if used, would show the main content, the translucent black shield, and the Sidebar. You want some control over whether or not the Sidebar should appear. We can do that with a Bool, an IF statement, and a Button to control the state of the Bool.

Above the code in the last code block, insert this:

@State var presentSidebar = false

func toggleSidebar() {
    withAnimation {
        self.presentSidebar.toggle()
    }
}

When the toggleSidebar function is called, it changes the state of the presentSidebar but does so within an animation block. Doing this will trigger the transition applied to the Sidebar and, as a bonus, animate any other changes on this screen. This includes the appearance of the color shield. Here is that code block once again, but with the presentSidebar incorporated:

ZStack {
   // 1 - your main app content
    MainView()
        .zIndex(0)
   // 2 - a shield against using the main content
    if presentSideBar {
        Color.black
            .zIndex(1)
            .opacity(0.25)
            .edgesIgnoringSafeArea(.all)
   // 3 - SideBar with its menu
        SideBar(title: "Main Menu") {
            MenuItems() { menuItem in
                // do something with the menuItem
                self.toggleSidebar()
            }
        }
        .zIndex(2)
        .frame(width: 300)
        .transition(.asymmetric(
            insertion: .move(edge: .leading),
            removal: .move(edge: .leading))
        )
    }
}

Now all you need is a button to toggle the value of presentSidebar. You sidebar would slide in and when a menu item was picked, its action handler would flip the presentSidebar toggle and cause the sidebar to slide away.

Before adding this button - which would just be temporary to show how this works - let’s make this block of code into a more generic container.

Create a new SwiftUI file called MainViewWithSidebar.swift and put all of this into it:

struct MainViewWithSidebar <Sidebar, Content> : View where Sidebar : View, Content : View {
    @Binding var presentSidebar: Bool
    var sidebar : Sidebar
    var content : Content
    
    // 1 - using @ViewBuilder again
    init(presentSidebar: Binding<Bool>,
         sidebar: Sidebar,
         @ViewBuilder content: () -> Content)
    {
        self._presentSidebar = presentSidebar
        self.sidebar = sidebar
        self.content = content()
    }
    
    // use animation to change the presentSidebar value; this way
    // the menu will slide closed.
    
    private func toggleSidebar() {
        withAnimation {
            self.presentSidebar.toggle()
        }
    }

    var body: some View {
        ZStack(alignment: .leading) {
            // 2 - the main content of the app
            self.content
            .zIndex(0)
            .blur(radius: self.presentSidebar ? 6.0 : 0)
       
            // 3 - show or hide the translucent shield
            if presentSidebar {
                Color.black
                    .zIndex(1)
                    .opacity(0.25)
                    .edgesIgnoringSafeArea(.all)
                    .onTapGesture {
                        self.toggleSidebar()
                    }
            }
            
            // 4 - show or hide the sidebar itself
            if presentSidebar {
                self.sidebar
                .zIndex(2)
                .frame(width: 300)
                .transition(.asymmetric(
                            insertion: .move(edge: .leading), 
                            removal: .move(edge: .leading))
                )
            }
        }
    }
}

This looks pretty much like the code above it, except now it is its own View and uses @ViewBuilder to take the content of the main view as well as the Sidebar (which you’ll see how this is used below).

  1. As with the Sidebar container, this container also uses @ViewBuilder so you can use any View you like as content.

  2. self.content is where MainView() was before. In addition to the zIndex, there is also a blur that depends on the presentSidebar boolean. Blurring the main content while the sidebar is visible is a nice touch and, using animation, SwiftUI will fade the blur effect.

  3. This is the Color shield. Here, a onTapGesture has been added as a way to make the sidebar disappear if the user taps outside of the sidebar.

  4. The sidebar presentation itself.

So how would you use this MainViewWithSidebar component? Like this:

// 1 - boolean to track the visibility of the sidebar
@State showSidebar = false
// 2 - a way to communicate the sidebar menu choice to the MainView
@State var selectedMenuItem = MenuItems.MenuCode.home

// 3 - a tidy way to present the sidebar
private var sidebarView: some View {
    Sidebar(title: "Main Menu") {
      MenuItems() { itemNumber in
        self.selectedMenuItem = itemNumber
        self.toggleSidebar()
      }
    }
}

var body: some View {
  // 4 - the container
  MainViewWithSidebar(presentSidebar: self.$showSidebar, sidebar: self.sidebarView) {
    NavigationView {
      MainView(menuCode: self.selectedMenuItem)
        // 5 - using .toolbar to add some controls
          .toolbar {
            ToolbarItem(placement: .navigationBarLeading) {
              Button(action: {self.toggleSidebar()}) {
                Label("Menu", systemImage: "sidebar.left")
              }
            }
          }
    }
  }
}
  1. This is the boolean we’ll toggle to get the sidebar to appear. It gets passed as a binding to MainViewWithSidebar.

  2. This state variable gets set from the MenuItems component and is then passed down to the MainView and used however it wants to use it.

  3. This sidebarView encapsulates the actual Sidebar container built at the beginning of this actual and wraps the MenuItems.

  4. Now you see how to use the MainViewWithSidebar container. Pass in the binding for the boolean to show/hide the sidebar, and pass in the sidebar itself: sidebarView.

  5. Finally, add .toolbar and place a button at the top to control the appearance of the sidebar. The toolbar applies to the NavigationView wrapping the MainView.

So there you have it, a more or less, generic way to present a sidebar menu. Using @ViewBuilder allows you to put any View as the sidebar as well as abstracting the mechanism of presenting the sidebar from the app itself. All you need to do is create your sidebar content and your application’s content, whatever that may be.

Previous
Previous

Widgets with SwiftUI

Next
Next

A Multi-Date Picker for SwiftUI