Rupesh Chaudhari Rupesh is a programming enthusiast who is currently working with React Native to build beautiful hybrid mobile apps. He likes to solve competitive programming puzzles and enjoys gaming in his free time.

How to create a custom, collapsible sidebar in SwiftUI

8 min read 2462

Swift Logo Over a Clear Sky

Table of Contents

We use sidebars in mobile apps to provide users with top-level navigation for the most used and important screens of the application. In this article, you’ll learn how to create a custom sidebar using SwiftUI. Let’s get started!

Creating a new SwiftUI project

We’ll create a new SwiftUI project from scratch for this tutorial, but you can implement this in your existing projects as well.

To create a new SwiftUI project, open Xcode and click on Create a new Xcode project.

Xcode

You’ll be prompted to choose a template for your project. Select iOS > App and click Next.

App Selected

Now, enter the name of your project and the language and framework you want to use. Enter the project name in the Product Name field and select a relevant Team.

Because we will use Swift to create our iOS application, we will select SwiftUI in the interface dropdown.

SwiftUI Dropdown

We made a custom demo for .
No really. Click here to check it out.

After clicking Next, Xcode will ask which path to create the project on, so select your preferred location and click Create. Now, let’s create our custom sidebar for our SwiftUI app.

Designing the home screen in Swift

The app we are designing consists of a home screen and a sidebar on the same screen. It has various links for the users to go click on.

The final UI will look like this:

Final UI

Note: the complete code for this article can be found in this repository.

Let’s focus on building the home screen. We’ll have a list of images that are loaded through the network using AsyncImage.

Add the following code to ContentView.swift :

struct ContentView: View {

    var body: some View {
          NavigationView {
              List {
                  ForEach(0..<8) { _ in
                      AsyncImage(
                              url: URL(
                                string: "https://picsum.photos/600"
                                )) { image in
                          image
                              .resizable()
                              .scaledToFill()
                              .frame(height: 240)
                      } placeholder: {
                          ZStack {
                              RoundedRectangle(cornerRadius: 12)
                                  .fill(.gray.opacity(0.6))
                                  .frame(height: 240)
                              ProgressView()
                          }
                      }
                      .aspectRatio(3 / 2, contentMode: .fill)
                      .cornerRadius(12)
                      .padding(.vertical)
                      .shadow(radius: 4)
                    }
                  }
                  .listStyle(.inset)
                  .navigationTitle("Home")
                  .navigationBarTitleDisplayMode(.inline)
          }
    }
}

Notice we’ve wrapped our entire View, which is returned from the body variable of our ContentView in a NavigationView, then used a List view to create a list of images to display.

Inside this list, we loop through a range of 0 to 8 — (0..<8) — which will iterate eight times starting from 0 to 8.

Inside this loop, we iterate over an AsyncImage view, which is used to load and display images that are on the network. We then used a custom initializer method on the AsyncImage view to give a placeholder value, which will be displayed until the image is downloaded from the network. We’ve also added some modifiers to the views for styling purposes.

Then, we added several modifiers on the List view, listStyle to ListStyle.inset, for an inset style to list rather than the default iOS styled list. We also set navigationTitle("Home") for a navigation title to this screen, which is displayed on the navigation bar.

The last modifier is navigationBarTitleDisplayMode(.inline). It sets the navigation bar display mode to inline, i.e., it will always stay at the top to keep our UI consistent with our sidebar.

Here’s the output from the above code.

Output Home Images

Creating the custom sidebar view in Swift

Now that our home screen UI is complete, let’s build the sidebar view. To show and hide the sidebar, we‘ll need a @State variable, which will be the source of truth for our view to know if it should display or hide the sidebar.

Let’s add the @State var to our ContentView.

struct ContentView: View {

    // Add the below line
    @State private var isSidebarOpened = false

    var body: some View {

Now, let’s create the sidebar view by creating a new SwiftUI file. Go to File → New → File or press ⌘ + N to create a new file.

File Select

Select the type of file you want to create, select SwiftUI View, and click Next.

Select SwiftUI View

Xcode will ask you for the name of the file you want to create. Enter Sidebar, select your project group in the Group dropdown, and click Create.

Now that we have a blank SwiftUI view, let’s start designing our sidebar.

We will add a @Binding variable to our Sidebar view so that we can update the value of our @State variable, isSidebarOpened, from the Sidebar view itself.

Add the below code to Sidebar.swift:

struct Sidebar: View {
    @Binding var isSidebarVisible: Bool

    var body: some View {
        if isSidebarVisible {
            Text("Sidebar visible")
                .bold()
                .font(.largeTitle)
                .background(.purple)
        }
    }
}

Here, we added a @Binding variable, isSidebarVisible, which will come from the variable isSidebarOpened from the ContentView. We also added a conditional statement saying that if the value of isSidebarVisible is true, then we’ll show the Text view, which contains text saying “Sidebar visible.”

We need to make some changes to our ContentView file. First, wrap NavigationView in a ZStack so that the Sidebar view gets the complete screen bound area. Then, add a Button to our navigation bar to toggle the value of the isSidebarOpened @State variable. We will add the Sidebar view in View and pass the binding value to it.

Make these changes in ContentView.swift:

struct ContentView: View {
    @State private var isSideBarOpened = false

    var body: some View {
        ZStack {
            NavigationView {
                List {
                    ForEach(0..<8) { _ in
                        AsyncImage(
                          url: URL(
                            string: "https://picsum.photos/600"
                          )) { image in
                            image
                                .resizable()
                                .scaledToFill()
                                .frame(height: 240)
                            } placeholder: {
                                ZStack {
                                    RoundedRectangle(cornerRadius: 12)
                                        .fill(.gray.opacity(0.6))
                                        .frame(height: 240)
                                    ProgressView()
                                }
                            }
                        .aspectRatio(3 / 2, contentMode: .fill)
                        .cornerRadius(12)
                        .padding(.vertical)
                        .shadow(radius: 4)
                    }
                    }
                    .toolbar {
                        Button {
                            isSideBarOpened.toggle()
                        } label: {
                            Label("Toggle SideBar",
                          systemImage: "line.3.horizontal.circle.fill")
                        }
                    }
                    .listStyle(.inset)
                    .navigationTitle("Home")
                    .navigationBarTitleDisplayMode(.inline)
            }
            Sidebar(isSidebarVisible: $isSideBarOpened)
        }
    }
}

Here’s what our app currently looks like.

Select Top Left Button

We’re now able to toggle the visibility of our sidebar! Let’s create some UI for it so that it looks like a sidebar.

Let’s first add a dark background that appears after opening the sidebar. Add the following code to Sidebar.swift file:

var body: some View {
    ZStack {
        GeometryReader { _ in
            EmptyView()
        }
        .background(.black.opacity(0.6))
        .opacity(isSidebarVisible ? 1 : 0)
        .animation(.easeInOut.delay(0.2), value: isSidebarVisible)
        .onTapGesture {
            isSidebarVisible.toggle()
        }
    }
    .edgesIgnoringSafeArea(.all)
}

We wrapped our view in a ZStack and added an edgesIgnoringSafeArea modifier to it, then gave it a value of all. This will make our ZStack spread across the available screen, including the area under the notch and bottom corners of the iPhone.

We added a GeometryReader view inside the ZStack so that we can use all the available device screen space for our backdrop view, as well as added the following view modifiers to our GeometryReader view:

  • background: sets the background of the view. in our case, we have set it to black with an opacity of 60%
  • opacity: sets the opacity of the view itself, and we are computing its value through a ternary expression of 1 if the sidebar is visible and 0 if not
  • animation: animates the changes we perform on this view. In this case, we are animating the opacity of the view with an easeInOut animation with a delay of 0.2. We have also added a value label in animation that tells Swift we need to animate the view when this value changes (this change was introduced in iOS 15.0)
  • onTapGesture: when users taps on the backdrop, the sidebar should close

The backdrop for the sidebar will look like this:

Sidebar Backdrop

Let’s ensure our sidebar can slide. Make the highlighted changes below in Sidebar.swift file:

struct SideMenu: View {
    @Binding var isSidebarVisible: Bool
    var sideBarWidth = UIScreen.main.bounds.size.width * 0.7
    var bgColor: Color = 
          Color(.init(
                  red: 52 / 255,
                  green: 70 / 255,
                  blue: 182 / 255,
                  alpha: 1))

    var body: some View {
        ZStack {
            GeometryReader { _ in
                EmptyView()
            }
            .background(.black.opacity(0.6))
            .opacity(isSidebarVisible ? 1 : 0)
            .animation(.easeInOut.delay(0.2), value: isSidebarVisible)
            .onTapGesture {
                isSidebarVisible.toggle()
            }            
            content
        }
        .edgesIgnoringSafeArea(.all)
    }

    var content: some View {
        HStack(alignment: .top) {
            ZStack(alignment: .top) {
                bgColor
            }
            .frame(width: sideBarWidth)
            .offset(x: isSidebarVisible ? 0 : -sideBarWidth)
            .animation(.default, value: isSidebarVisible)

            Spacer()
        }
    }
}

We have introduced two new variables named sideBarWidth and bgColor. As the name suggests, sideBarWidth stores the width of the displayed sidebar. Notice that we have not given any type to this var. This is because Swift takes care of this by using type inference.

The second variable is bgColor, which is a simple Color initialized using RGB values.

To simplify the code in the body and make our code more readable, we have created a new variable named content that returns a View. The content view contains an HStack with two views: the actual sidebar (ZStack), which is displayed on the screen, and a spacer view that takes up maximum space in a view. This makes our blue sidebar move to the left.

In the ZStack, we have one var bgColor for now, which gives the view a background color. We have added important modifiers to the view, too. Using frame, we have set the width of the view to the value of the variable we previously declared, then we used offset, which makes the sidebar slide from left to right onto the screen.

Offset is used to move a particular view with respect to its X and Y coordinates. We move the sidebar negative to its width from the X-axis if the sidebar is not visible, and set it to 0 if the sidebar is visible.

Finally, for a seamless UI, we animate it using animation, give it a default value, and animate it against the isSidebarVisible var.

After making the above adjustments, our Swift sidebar UI will look like this.

Sidebar Popup

Now, add the below code to Sidebar.swift:

var secondaryColor: Color = 
              Color(.init(
                red: 100 / 255,
                green: 174 / 255,
                blue: 255 / 255,
                alpha: 1))

var content: some View {
    HStack(alignment: .top) {
        ZStack(alignment: .top) {
            bgColor
            MenuChevron
        }
        .frame(width: sideBarWidth)
        .offset(x: isSidebarVisible ? 0 : -sideBarWidth)
        .animation(.default, value: isSidebarVisible)

        Spacer()
    }
}

var MenuChevron: some View {
    ZStack {
        RoundedRectangle(cornerRadius: 18)
            .fill(bgColor)
            .frame(width: 60, height: 60)
            .rotationEffect(Angle(degrees: 45))
            .offset(x: isSidebarVisible ? -18 : -10)
            .onTapGesture {
                isSidebarVisible.toggle()
            }

        Image(systemName: "chevron.right")
            .foregroundColor(secondaryColor)
            .rotationEffect(
              isSidebarVisible ?
                Angle(degrees: 180) : Angle(degrees: 0))
            .offset(x: isSidebarVisible ? -4 : 8)
            .foregroundColor(.blue)
    }
    .offset(x: sideBarWidth / 2, y: 80)
    .animation(.default, value: isSidebarVisible)
}

We have created new variable MenuChevron, which contains a ZStack with two views, a RoundedRectangle and an Image.

Sidebar Click

Tada! We have a working sidebar. Let’s add some content to it!

Adding content to the Swift sidebar

The final step is to add content to the sidebar.

In our sidebar, there is a user profile with navigation links to different sections of the app. Let’s start with the user profile view.

To create the UI for the user profile section, add the following code to the Sidebar.swift file.

var content: some View {
    HStack(alignment: .top) {
        ZStack(alignment: .top) {
            bgColor
            MenuChevron

            VStack(alignment: .leading, spacing: 20) {
                userProfile
            }
            .padding(.top, 80)
            .padding(.horizontal, 40)
        }
        .frame(width: sideBarWidth)
        .offset(x: isSidebarVisible ? 0 : -sideBarWidth)
        .animation(.default, value: isSidebarVisible)

        Spacer()
    }
}

var userProfile: some View {
    VStack(alignment: .leading) {
        HStack {
            AsyncImage(
              url: URL(
                string: "https://picsum.photos/100")) { image in
                  image
                      .resizable()
                      .frame(width: 50, height: 50, alignment: .center)
                      .clipShape(Circle())
                      .overlay {
                          Circle().stroke(.blue, lineWidth: 2)
                      }
                  } placeholder: {
                      ProgressView()
                  }
                  .aspectRatio(3 / 2, contentMode: .fill)
                  .shadow(radius: 4)
                  .padding(.trailing, 18)

              VStack(alignment: .leading, spacing: 6) {
                  Text("John Doe")
                      .foregroundColor(.white)
                      .bold()
                      .font(.title3)
                  Text(verbatim: "[email protected]")
                      .foregroundColor(secondaryColor)
                      .font(.caption)
              }
          }
          .padding(.bottom, 20)
    }
}

Notice we’ve added a VStack in the sidebar, which has a userProfile view and contains the following:

  • A VStack with leading alignment
  • An HStack that contains two more views
  • Inside the HStack, we have the user profile image, which is loaded through the network using AsyncImage
  • Just after the profile image, we have another VStack that displays a user’s name and email. The email text is initialized with verbatim because Swift highlights links with the default accent color, so, to bypass this, we use verbatim

Let’s move on to the menu links section. To create the links, we first need to create a struct for a MenuItem that will contain all the information about the item.

struct MenuItem: Identifiable {
    var id: Int
    var icon: String
    var text: String
}

The MenuItem only has three properties: id, icon, and text. Let’s apply this in our UI.

import SwiftUI

var secondaryColor: Color = 
              Color(.init(
                red: 100 / 255,
                green: 174 / 255,
                blue: 255 / 255,
                alpha: 1))

struct MenuItem: Identifiable {
    var id: Int
    var icon: String
    var text: String
}

var userActions: [MenuItem] = [
    MenuItem(id: 4001, icon: "person.circle.fill", text: "My Account"),
    MenuItem(id: 4002, icon: "bag.fill", text: "My Orders"),
    MenuItem(id: 4003, icon: "gift.fill", text: "Wishlist"),
]

var profileActions: [MenuItem] = [
    MenuItem(id: 4004,
              icon: "wrench.and.screwdriver.fill",
              text: "Settings"),
    MenuItem(id: 4005,
              icon: "iphone.and.arrow.forward",
              text: "Logout"),
]

struct SideMenu: View {
    @Binding var isSidebarVisible: Bool
    var sideBarWidth = UIScreen.main.bounds.size.width * 0.7
    var bgColor: Color = Color(.init(
                                  red: 52 / 255,
                                  green: 70 / 255,
                                  blue: 182 / 255,
                                  alpha: 1))

    var body: some View {
        ZStack {
            GeometryReader { _ in
                EmptyView()
            }
            .background(.black.opacity(0.6))
            .opacity(isSidebarVisible ? 1 : 0)
            .animation(.easeInOut.delay(0.2), value: isSidebarVisible)
            .onTapGesture {
                isSidebarVisible.toggle()
            }

            content
        }
        .edgesIgnoringSafeArea(.all)
    }

    var content: some View {
        HStack(alignment: .top) {
            ZStack(alignment: .top) {
                menuColor
                MenuChevron

                VStack(alignment: .leading, spacing: 20) {
                    userProfile
                    Divider()
                    MenuLinks(items: userActions)
                    Divider()
                    MenuLinks(items: profileActions)
                }
                .padding(.top, 80)
                .padding(.horizontal, 40)
            }
            .frame(width: sideBarWidth)
            .offset(x: isSidebarVisible ? 0 : -sideBarWidth)
            .animation(.default, value: isSidebarVisible)

            Spacer()
        }
    }

    var MenuChevron: some View {
        ZStack {
            RoundedRectangle(cornerRadius: 18)
                .fill(bgColor)
                .frame(width: 60, height: 60)
                .rotationEffect(Angle(degrees: 45))
                .offset(x: isSidebarVisible ? -18 : -10)
                .onTapGesture {
                    isSidebarVisible.toggle()
                }

            Image(systemName: "chevron.right")
                .foregroundColor(secondaryColor)
                .rotationEffect(isSidebarVisible ? 
                    Angle(degrees: 180) : Angle(degrees: 0))
                .offset(x: isSidebarVisible ? -4 : 8)
                .foregroundColor(.blue)
        }
        .offset(x: sideBarWidth / 2, y: 80)
        .animation(.default, value: isSidebarVisible)
    }

    var userProfile: some View {
        VStack(alignment: .leading) {
            HStack {
                AsyncImage(
                  url: URL(
                      string: "https://picsum.photos/100")) { image in
                    image
                        .resizable()
                        .frame(width: 50,
                                height: 50,
                                alignment: .center)
                        .clipShape(Circle())
                        .overlay {
                            Circle().stroke(.blue, lineWidth: 2)
                        }
                } placeholder: {
                    ProgressView()
                }
                .aspectRatio(3 / 2, contentMode: .fill)
                .shadow(radius: 4)
                .padding(.trailing, 18)

                VStack(alignment: .leading, spacing: 6) {
                    Text("John Doe")
                        .foregroundColor(.white)
                        .bold()
                        .font(.title3)
                    Text(verbatim: "[email protected]")
                        .foregroundColor(secondaryColor)
                        .font(.caption)
                }
            }
            .padding(.bottom, 20)
        }
    }
}

struct MenuLinks: View {
    var items: [MenuItem]
    var body: some View {
        VStack(alignment: .leading, spacing: 30) {
            ForEach(items) { item in
                menuLink(icon: item.icon, text: item.text)
            }
        }
        .padding(.vertical, 14)
        .padding(.leading, 8)
    }
}

struct menuLink: View {
    var icon: String
    var text: String
    var body: some View {
        HStack {
            Image(systemName: icon)
                .resizable()
                .frame(width: 20, height: 20)
                .foregroundColor(secondaryColor)
                .padding(.trailing, 18)
            Text(text)
                .foregroundColor(.white)
                .font(.body)
        }
        .onTapGesture {
            print("Tapped on \(text)")
        }
    }
}

Here, we introduced two new views, MenuLinks and menuLink. MenuLinks has items, which are assigned a value when the MenuLinks view is initialized. The view iterates over each instance of items and returns a new view menuLink for each item.

menuLink has two variables that are initialized with the view creation: icon and text, which are wrapped in an HStack and displayed. We also capture tap gestures on the menuLink view. Currently, we are printing the item value to the console, but you can add a NavigationLink and navigate users to the inner screens.

That’s it! We now have a working sidebar.

Working Sidebar

Conclusion

In this tutorial, you saw how easy it is to create a custom sidebar in SwiftUI. There are, of course, many changes you can make to further customize your sidebar. Thank you for reading!

: Full visibility into your web apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

.
Rupesh Chaudhari Rupesh is a programming enthusiast who is currently working with React Native to build beautiful hybrid mobile apps. He likes to solve competitive programming puzzles and enjoys gaming in his free time.

Leave a Reply