Categories
Uncategorized

How to create a custom Tab Bar in SwiftUI

Welcome! In SwiftUI it’s super easy to add a simple tab bar to our app. However, we are limited to the default tab bar design that Apple provides us with. But the in-house tab bar can get boring and sometimes doesn’t offer the functionality we need for our app. In this tutorial, we will learn how to create our own fully customizable tab bar. We will not use a UITabBarController for this but implement the navigation logic only with SwiftUI and from scratch.

Our finished tab bar will look like this:


With the knowledge from this article you should be able to create almost any tab bar with SwiftUI!

Let’s get started! After opening Xcode 11 and creating a new SwiftUI Single View app, we can start preparing our custom tab bar. For the purpose of this tutorial, we can use the default ContentView.swift file.

Preparing our ContentView 👨‍💻

By using our tab bar, we will later be able to jump between two different views, a “Home” view and a “Settings” view. By default, the former should be displayed. This view should simply consist of a text reading “Home”. For this, we can adapt the default “Hello Word” Text accordingly.

struct ContentView: View {
    var body: some View {
       Text("Home")
    }
}

The tab bar should be displayed at the bottom of the screen. Therefore, we have to wrap our text view into a VStack. To make sure that the text is always centered, we add two spacers.

VStack {
            Spacer()
            Text("Home")
            Spacer()
        }

We are almost finished with the preparations. Finally, we just want to have the capability to know how high and wide the screen of the particular device the app runs on is. We need to know this in order to dynamically adjust the size of the tab bar. For this purpose, we wrap our VStack into a GeometryReader.

GeometryReader { geometry in
            VStack {
                Spacer()
                Text("Home")
                Spacer()
            }
        }

The GeometryReader reads out its parent view’s size dimensions. In our case, that’s the overall superview, the one that covers the entire screen.

We’re done with our preparations. Now it’s time to actually design our custom tab bar!

Designing our Tab Bar 🎨🖌

Our tab bar should contain three different icons that are arranged horizontally. Therefore, we insert a HStack into our VStack.

VStack {
    Spacer()
    Text("Home")
    Spacer()
    HStack {
                    
    }
}

The first icon is the “house” icon from the SF Symbols cataloge. The view around the icon should be a third as wide as the whole screen. We also apply some padding.

HStack {
    Image(systemName: "house")
        .resizable()
        .aspectRatio(contentMode: .fit)
        .padding(20)
        .frame(width: geometry.size.width/3, height: 75)
      }

Next, we can tell our HStack that it should always be a tenth of the height of the screen. We also create a white background with a smooth shadow effect.

HStack {
        //...
        }
            .frame(width: geometry.size.width, height: geometry.size.height/10)
            .background(Color.white.shadow(radius: 2))

Our tab bar slowly takes shape. However, it is (depending on the device the app runs on) slightly away from the bottom edge. This is because, by default, a SwiftUI view’s content stays inside the so-called safe area. This prevents our tab bar from reaching the lower edge of our screen. But we can explicitly tell our SwiftUI view to exceed the safe area’s lower boundary by adding the following modifier to the overall VStack:

GeometryReader { geometry in
            VStack {
                //...
                }
                    //...
            }.edgesIgnoringSafeArea(.bottom)
        }

Now our tab bar seamlessly attaches to the bottom of the device

Next, we can add the gear icon to our tab bar:

HStack {
        Image(systemName: "house")
             //...
        Image(systemName: "gear")
            .resizable()
            .aspectRatio(contentMode: .fit)
            .padding(20)
            .frame(width: geometry.size.width/3, height: 75)
        }

Between the home and the gear icon, we want to place a slightly shifted plus button. We create this by adding a ZStack between the two icons. The first view inside this ZStack should simply be a white circle:

ZStack {
      Circle()
          .foregroundColor(Color.white)
          .frame(width: 75, height: 75)
        }

On top of this circle, we stack our actual plus icon:

ZStack {
        Circle()
            .foregroundColor(Color.white)
            .frame(width: 75, height: 75)
        Image(systemName: "plus.circle.fill")
            .resizable()
            .aspectRatio(contentMode: .fit)
            .frame(width: 75, height: 75)
            .foregroundColor(.blue)
      }

To make sure that the whole plus button is placed slightly above the tab view, we apply the .offset modifier to our ZStack. Our ZStack should be shifted upwards by the half the height of the tab bar. Since our tab view is one tenth as high as the screen, we write:

ZStack {
          //...
        }
          .offset(y: -geometry.size.height/10/2)

Awesome, we are already done with designing our own, custom tab bar. It’s simple as that! Your app preview should now look like this:

Implementing the navigation logic ⛓

Now it’s time to write the code for the navigation logic. How to navigate between views independently in SwiftUI is shown in detail in this tutorial. Therefore, we’ll keep it brief in the following.

To be able to switch back and forth between different views, we need a kind of a “manager” that tells our ContentView which view it should display (either the “Home” or the “Settings” view). For this purpose, we create a new File-New-File and select Swift file. We call this file ViewRouter.

Next, we import the SwiftUI and the Combine framework and create a class named ViewRouter that adapts the ObservableObject protocol.

import Foundation
import SwiftUI
import Combine

class ViewRouter: ObservableObject {
    
   
    
}

Within our ViewRouter, we need a variable which we can use to keep all observers up to date, which view should currently be displayed. Thus, we declare a variable currentPage. By default, we want to display the “home” view.

class ViewRouter: ObservableObject {
    
    @Published var currentView = "home"
    
}

With the @Published property wrapper, we notify all observing views to update themselves whenever the currentView variable changes.

That’s it! We can now use a ViewRouter instance within our ContentView and observe it by initializing it as an @ObservedObject.

struct ContentView: View {
    
    @ObservedObject var viewRouter = ViewRouter()
    
    var body: some View {
        //...
    }
}

As said, make sure you read this tutorial, if you don’t really understand the navigation logic we are using!

Depending on the viewRouter’s currentView variable we want to show either our “Home” text view or another simple Text view reading “Settings”. Therefore, we replace our current Text within the two spacers with the following statement.

if self.viewRouter.currentView == "home" {
                    Text("Home")
                } else if self.viewRouter.currentView == "settings" {
                    Text("Settings")
                }

We can now navigate to the settings view by adding a tap gesture to the gear icon that assigns the viewRouter’s currentView variable to “settings”.

 Image(systemName: "gear")
        //...
        .onTapGesture {
            self.viewRouter.currentView = "settings"
         }

Due to the @Published property wrapper’s functionality, this causes our observing ContentView to rebuild itself with eventually showing us the “Settings” Text view!

Let’s add the corresponding tap gesture to our house icon:

Image(systemName: "house")
        //...
        .onTapGesture {
              self.viewRouter.currentView = "home"
        }

Awesome, we’re now able to jump between the different views by tapping the tab bar’s home and gear icon!


To indicate the user which view is currently being shown, we can conditionally highlight the corresponding tab bar icon. For example, when our home view is currently being shown, we want the home icon to be black, otherwise we want it to be gray. We can achieve this by adding the following modifier to it:

Image(systemName: "house")
        //...
        .foregroundColor(self.viewRouter.currentView == "home" ? .black : .gray)
        .onTapGesture {
            self.viewRouter.currentView = "home"
        }

Let’s do the same for our gear icon!

Image(systemName: "gear")
    //...
    .foregroundColor(self.viewRouter.currentView == "settings" ? .black : .gray)
    .onTapGesture {
        self.viewRouter.currentView = "settings"
    }

Awesome, our tab bar now indicates which view is currently being shown!

Creating the pop up menu ➕

Last but not least, we want to display a cool pop-up menu when the user taps the plus sign icon.

For keeping track of whether the menu should be displayed, we add an according @State property to our ContentView, right below our @ObservedObject variable.

@State var showPopUp = false

When this State is true, the menu should be shown on top of our tab bar (we will shift its position in a moment). Therefore, we wrap our HStack into a ZStack.

ZStack {
    HStack {
      //...
      }
    }

For the menu, you can add the following struct right below your ContentViews_Previews struct:

struct PlusMenu: View {
    var body: some View {
        HStack(spacing: 50) {
            ZStack {
                Circle()
                    .foregroundColor(Color.blue)
                    .frame(width: 70, height: 70)
                Image(systemName: "camera")
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .padding(20)
                    .frame(width: 70, height: 70)
                    .foregroundColor(.white)
            }
            ZStack {
                Circle()
                    .foregroundColor(Color.blue)
                    .frame(width: 70, height: 70)
                Image(systemName: "photo")
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .padding(20)
                    .frame(width: 70, height: 70)
                    .foregroundColor(.white)
            }
        }
    }
}

If you want to learn, how floating menus can be created in SwiftUI, take a look at this tutorial!

Depending on the showPopUp State, we can now initialise the PlusMenu on top of our tab bar.

ZStack {
    if self.showPopUp {
        PlusMenu()
    }
    HStack {
        //...
    }
}

We shift the PlusMenu above our tab bar by using the .offset modifier again.

PlusMenu()
    .offset(y: -geometry.size.height/6)

We want to toggle the State by tapping on the plus icon. For this purpose, we use a tap gesture again.

ZStack {
    //...
  }
      .offset(y: -geometry.size.height/10/2)
      .onTapGesture {
          self.showPopUp.toggle()
      }

When we now tap on the plus icon of our tab bar, the floating menu gets displayed! We can animate this by wrapping the toggle statement inside a withAnimation statement.

withAnimation {
    self.showPopUp.toggle()
}

To replace the default fade animation with a catchier one, we can add the .transition modifier to our PlusMenu’s view with choosing the .scale option:

var body: some View {
        HStack(spacing: 50) {
            //...
        }
            .transition(.scale)
    }

Tap the plus icon again, to see how it looks!

Hint: Maybe your live preview doesn’t display the animation properly. In this case, run your app in the regular simulator.

Additionally, we want to rotate our plus icon when tapping on it. To do this, add the following modifier to the Image view inside the according ZStack.

Image(systemName: "plus.circle.fill")
     //...
     .rotationEffect(Angle(degrees: self.showPopUp ? 90 : 0))

Depending on whether the plus menu is being shown, this modifier rotates the plus icon by 90 degrees.

Our whole plus menu animation should now look like this:

Conclusion 🎊

Awesome, we’re now finished with creating our own, custom tab bar in SwiftUI! We’ve learned how to design a tab bar’s UI, how to implement a proper navigation logic and how to animate a cool pop-up menu. With this knowledge, you should be able to create your own, custom tab bar!

We’ve uploaded the whole source code of this app to GitHub.

I hope you enjoyed this tutorial! If you want to learn more about SwiftUI, check out our other tutorials! Also make sure you follow us on Instagram and subscribe to our newsletter to not miss any updates, tutorials and tips about SwiftUI and more!

Categories
Uncategorized

Stretchy Header and Parallax Scrolling in SwiftUI

Welcome! In this tutorial, we are going to create a sticky/stretchy header in SwiftUI. At the same time, we are learning how to equip certain views with a parallax scrolling effect. We are using those features to create a nice looking blog post screen as you see them in several news apps.

This is what we are going to achieve in this tutorial:


If not already done, create a new Xcode 11 project and choose Single View app. Make sure you’ve selected SwiftUI as the interface mode and create a new project. For the following steps, you can use the default ContentView.swift file.

We’ll use some images for our project, which you can download here and here. You can also usa a portrait image like this one. Note that the format and dimensions of the header image can affect the parallax effect, which you maybe already noticed in the preview video above.

Import the images into your Assets.xcassets folder and make sure you name them correctly.

Setting up the basic content layout 👨‍🎨

Before we get started with implementing our sticky header, we are going to design the basic layout of our blog post screen.

The whole content in our ContentView should be scrollable. For this purpose, we replace the default “Hello World” Text view with a ScrollView.

struct ContentView: View {
var body: some View {
        ScrollView {
        }
    }
}

Inside the ScrollView, we will place the headline and the content texts of our blog post screen. All of those objects should be aligned vertically. Therefore, we insert a VStack into our ScrollView and choose .leading as the alignment mode.

ScrollView {
    VStack(alignment: .leading) {

    }
}

Above the headline of our article, we want to contribute the author by naming him and showing his picture. To do this, we insert a HStack into our VStack. We fill this HStack with a small, rounded image and another VStack containing the author’s name as a Text.

VStack(alignment: .leading) {
                HStack {
                    Image("journalist")
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                        .frame(width: 60, height: 60)
                        .clipped()
                        .cornerRadius(10)
VStack(alignment: .leading) {
                Text("Article by")
                            .font(.custom("AvenirNext-Regular", size: 15))
                            .foregroundColor(.gray)
Text("Johne Doe")
                            .font(.custom("AvenirNext-Demibold", size: 15))
                    }
                }
                  .padding(.top, 20)
            }

Hint: We are using “Avenir-Next” as a custom font family.To learn more about using custom fonts in SwiftUI, check out this instagram post.

This is what your app preview should show so far:

Below this HStack, we can now insert the headline and some meta information, for instance the date when the post was published and its reading length.

VStack(alignment: .leading) {
                HStack {
                    //...
                }
                    .padding(.top, 20)
                Text("Lorem ipsum dolor sit amet")
                    .font(.custom("AvenirNext-Bold", size: 30))
                    .lineLimit(nil)
                    .padding(.top, 10)
                Text("3 min read • 22. November 2019")
                    .font(.custom("AvenirNext-Regular", size: 15))
                    .foregroundColor(.gray)
                    .padding(.top, 10)
            }

Now we can insert the actual content into our VStack. For this purpose, we declare a constant outside of our ContentView that holds the post’s content as a multi-lined string.

let articleContent =

"""
Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.
At vero eos et accusam et justo duo dolores et ea rebum.
Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.
At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.
"""

We can now insert a Text by referring to that constant.

//...
                Text("3 min read • 22. November 2019")
                    .font(.custom("AvenirNext-Regular", size: 15))
                    .foregroundColor(.gray)
                    .padding(.top, 10)
                Text(articleContent)
                    .font(.custom("AvenirNext-Regular", size: 20))
                    .lineLimit(nil)
                    .padding(.top, 30)

Let’s define a smaller width for the whole VStack:

VStack(alignment: .leading) {
                //...
            }
                .frame(width: 350)

Great, we are already done with setting up the basic layout of our blog post section. This is how your app should look like so far:

Next, we are going to implement our header image.

Implementing the Parallax Scrolling Header ↕️

On top of the VStack in our ScrollView, we wanna add our article’s header image. For making it stretchy and for implementing the parallax effect when scrolling we need to keep track of to current ScrollView’s position. For this purpose we can use a GeometryReader.

ScrollView {
            GeometryReader { geometry in
                
            }
            VStack(alignment: .leading) {
                //...
            }
                .frame(width: 350)
        }

The GeometryReader not only reads out the dimensions of its parent views (the ScrollView) but also allows us to keep track of the current ScrollView’s position. Inside the GeometryReader we insert an image view. The image should be as high and as wide as the GeometryReader.

GeometryReader { geometry in
                Image("header")
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                    .frame(width: geometry.size.width, height: geometry.size.height)
                    .clipped()
            }

Thus, we have to provide the GeometryReader with a certain frame.

GeometryReader { geometry in
                Image("header")
                    //...
            }
                .frame(height: 400)

Hint: When we are just determining the height, the GeometryReader view will be as wide as possible.

In order for our header image to fill out the whole screen, we have to tell our ScrollView that it should reach to the top, meaning that it should exceed to so-called safe area. We can do this, by adding the following modifier to our ScrollView:

ScrollView {
            //...
        }
            .edgesIgnoringSafeArea(.top)

Awesome, this is what your preview should now look like

When scrolling down the header remains static, meaning that we’ve not created any parallax effect yet. Fortunately, doing this is pretty simple. We just need to tell our header image view to continuously offset its position while we are scrolling up and down. We can do this by adding the following modifier right before the .clipped modifier.

Image("header")
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                    .frame(width: geometry.size.width, height: geometry.size.height)
                    .offset(y: geometry.frame(in: .global).minY/9)
                    .clipped()

We are now determining the vertical offset of our header image by checking the currently (vertical) position of our ScrollView. We are accessing the current vertical position by referring to the minY value of our GeometryReader. By doing this, we make sure our parallax view “moves” accordingly while we are scrolling. By the way: You can divide the minY value by a certain factor to control the magnitude of the parallax effect. In our example, we are dividing by the value 7.⠀

When we now run our app in live mode and scroll down, we notice that the header is also “moving” continuously. A pretty neat parallax effect!

Making our header stretchy 🦒

To make our header image stretchy, we need to find a way to expand the height of it and pushing it to the top when scrolling “over the top” of our ScrollView.

The first thing we have to do is to recognize when the upper boundary of the ScrollView is exceeded. We can this out by using the minY value of our GeometryReader again. If the upper boundary of the ScrollView is exceeded, the vertical scroll position must be positive. In the initial state, the minY value is zero and as we scroll down it gets more and more negative.

We only want to show our existing header image with the parallax effect while scrolling downwards. So only if the minY of our GeometryReader is negative or zero .

GeometryReader { geometry in
                VStack {
                    if geometry.frame(in: .global).minY <= 0 {
                        Image("header")
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                        .frame(width: geometry.size.width, height: geometry.size.height)
                        .offset(y: geometry.frame(in: .global).minY/9)
                        .clipped()
                    }
                }
            }

Hint: We have to wrap the if-statement into a container view in order to work. In our example, we use a VStack, but you could also use any other container view types.

If where are scrolling “over the top”, meaning when the GeometryReader’s minY is positive, we want to show our image header as well but with some modifications, we need for making it stretchy.

if geometry.frame(in: .global).minY <= 0 {
                        //...
                    } else {
                        Image("header")
                            .resizable()
                            .aspectRatio(contentMode: .fill)
                            .frame(width: geometry.size.width, height: geometry.size.height)
                            .clipped()
                    }

The first modification we need is to make our header image the higher the further we scroll “over the top”. We can achieve this by adding the current minY value of our ScrollView’s GeometryReader to the height of our Image’s .frame modifier.

Image("header")
                            .resizable()
                            .aspectRatio(contentMode: .fill)
                            .frame(width: geometry.size.width, height: geometry.size.height + geometry.frame(in: .global).minY)
                            .clipped()

If we now run our app in the live preview and start scrolling over the upper boundary of the ScrollView, our header begins stretching. However, it also reaches into our blog posts content.


Instead we want our header to be “glued” to the top of our screen. We can do this by offsetting it depending on the ScrollView’s position like this:

Image("header")
                            .resizable()
                            .aspectRatio(contentMode: .fill)
                            .frame(width: geometry.size.width, height: geometry.size.height + geometry.frame(in: .global).minY)
                            .clipped()
                            .offset(y: -geometry.frame(in: .global).minY)

If we now run our app again, the header not only stretches while scrolling over the upper boundary but also stays on the very top. In addition, when the maximum size of the header’s image is reached, it begins zooming in. This is due to the .fill option we have chosen for our Image’s .aspectRatio modifier.

Conclusion 🎊

Awesome, we just learned how to apply a scrolling parallax effect to our SwiftUI app. By working with the GeometryReader’s minY value, that we used for reading out the current vertical scroll position, we also saw how to add a stretchy header to our app.

The complete source code for the app can be found here.

I hope you enjoyed this tutorial! If you want to learn more about SwiftUI, check out our other tutorials! Also make sure you follow us on Instagram and subscribe to our newsletter to not miss any updates, tutorials and tips about SwiftUI and more!

Categories
Uncategorized

Floating action button with an animated menu in SwiftUI

Welcome to a new SwiftUI tutorial! Today we will learn how to create a floating action button with an animated floating Menu.

This is what our app will look like at the end:

If you want to skip the tutorial, you can simply view the relevant source code here.

First, we create a new SwiftUI Single View App in Xcode. Then we add a new File-New-File to our project and create a new SwiftUI View. We call this FloatingMenu. In this view, we will implement the floating action button with its corresponding menu. We will then use the finished menu inside our actual ContentView.

Creating the Floating Button ☁️

For the floating button, we insert a corresponding system icon into our FloatingMenu‘s body.

struct FloatingMenu: View {
    var body: some View {
        Image(systemName: "plus.circle.fill")
    }
}

We enlarge the image and give the icon a purple color.

Image(systemName: "plus.circle.fill")
            .resizable()
            .frame(width: 80, height: 80)
            .foregroundColor(Color(red: 153/255, green: 102/255, blue: 255/255))

To give the icon a certain plasticity, we would like to add a slight drop shadow to it. To do this we use the .shadow modifier and create a slightly shifted, gray shadow with a small radius.

Image(systemName: "plus.circle.fill")
            .resizable()
            .frame(width: 80, height: 80)
            .foregroundColor(Color(red: 153/255, green: 102/255, blue: 255/255))
            .shadow(color: .gray, radius: 0.2, x: 1, y: 1)

With a slight drop shadow, we create some plasticity to our button

Finally, we wrap the image into a button. For now, we use a dummy print statement as the button’s action.

Button(action: {
            print("Show Menu")
        }) {
            Image(systemName: "plus.circle.fill")
                //...
        }

Adding the Floating Menu Items ✍️

When the user taps the button, we want to display the menu items above the floating action button. To do this, we wrap the button into a VStack.

VStack {
            Button(action: {
                print("Show Menu")
            }) {
                //...
            }
        }

Each menu item consists of a circle containing a certain icon. To stack these two elements on top of each other we use a ZStack inside the VStack.

ZStack {
                Circle()
                    .foregroundColor(Color(red: 153/255, green: 102/255, blue: 255/255))
                    .frame(width: 55, height: 55)
                Image(systemName: "camera.fill")
                    .imageScale(.large)
                    .foregroundColor(.white)
            }

The menu item itself should have a drop shadow as well, so we reuse the .shadow modifier for the ZStack itself.

ZStack {
                //...
            }
                .shadow(color: .gray, radius: 0.2, x: 1, y: 1)

We can now CMD-Click on the ZStack and select “Extract as Subview”. We call this view MenuItem. Since each MenuItem should have a different icon, we add a property to the MenuItem struct that we use for the icon image.

struct MenuItem: View {
    
    var icon: String
    
    var body: some View {
        ZStack {
            Circle()
                .foregroundColor(Color(red: 153/255, green: 102/255, blue: 255/255))
                .frame(width: 55, height: 55)
            Image(systemName: icon)
                .imageScale(.large)
                .foregroundColor(.white)
        }
        .shadow(color: .gray, radius: 0.2, x: 1, y: 1)
    }
}

We then initialize the icon from our FloatingMenu view.

VStack {
            MenuItem(icon: "camera.fill")
            //...
        }

Then, we add two more MenuItems with different icons. We push the whole menu down by using a Spacer.

VStack {
            Spacer()
            MenuItem(icon: "camera.fill")
            MenuItem(icon: "photo.on.rectangle")
            MenuItem(icon: "square.and.arrow.up.fill")
            //...
        }

The MenuItems should only be displayed if the user has tapped on the button. Thus, we create three different States in our FloatingMenu view for the three according MenuItems

struct FloatingMenu: View {
    
    @State var showMenuItem1 = false
    @State var showMenuItem2 = false
    @State var showMenuItem3 = false
    
    var body: some View {
        //...
    }
}

Only if these States are true, we want to display the MenuItems.

VStack {
            Spacer()
            if showMenuItem1 {
                MenuItem(icon: "camera.fill")
            }
            if showMenuItem2 {
                MenuItem(icon: "photo.on.rectangle")
            }
            if showMenuItem3 {
                MenuItem(icon: "square.and.arrow.up.fill")
            }
            //...
        }

We can now toggle the States from our action button. For this purpose, we create a function called showMenu.

struct FloatingMenu: View {
    
    //...
    
    var body: some View {
        //...
    }
    
    func showMenu() {
        showMenuItem3.toggle()
        showMenuItem2.toggle()
        showMenuItem1.toggle()
    }
}

We can now call this function from our button:

Button(action: {
                self.showMenu()
            }) {
                Image(systemName: "plus.circle.fill")
                    //...
            }

Execute the app in the preview simulator and click on the floating button. Great, our menu items show up! When we click on the button again, our menu disappears again.


However, we want the different MenuItems to appear one after the other rather than at the same time. They should also move in from the right side of the screen.

Animating the Items 🚀

To display the different MenuItems delayed, we have to toggle the corresponding states in our showMenu function with a delay as well.

To execute code with a delay, we use the DispatchQueue.main.asyncAfter method.

func showMenu() {
        showMenuItem3.toggle()
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: {
            self.showMenuItem2.toggle()
        })
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: {
            self.showMenuItem1.toggle()
        })
    }

If we now click on the action button, the showMenu1 state will be toggled immediately. After one-tenth of a second the showMenuItem2 state and after another tenth of a second the showMenuItem3 state gets toggled.


Now we want the MenuItems to move in from the right side. To animate the toggling of the MenuItems we have to use the withAnimation statement within our showMenu function.

func showMenu() {
        withAnimation {
            showMenuItem3.toggle()
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: {
            withAnimation {
                self.showMenuItem2.toggle()
            }
        })
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: {
            withAnimation {
                self.showMenuItem1.toggle()
            }
        })
    }

Now we can add a transition to our MenuItem view.

ZStack {
            //...
        }
        .shadow(color: .gray, radius: 0.2, x: 1, y: 1)
        .transition(.move(edge: .trailing))

Hint: The transition may not be displayed correctly in the preview simulator. We will change this in a moment by adding the FloatingMenu to our ContentView. Then we will be able to run our app in the normal simulator.

Inserting the Floating Menu into our ContentView 🖼

We would like to add the FloatingMenu to the bottom right of our ContentView. To make this possible, we first have to create a transparent view that covers the entire screen. For this purpose, we use a Rectangle object.

struct ContentView: View {
    var body: some View {
        Rectangle()
            .foregroundColor(.clear)
            .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

To stack the FloatingMenu on top of this transparent rectangle, we wrap the rectangle into a ZStack and use the .bottomTrailing alignment option.

ZStack(alignment: .bottomTrailing) {
            Rectangle()
                .foregroundColor(.clear)
                .frame(maxWidth: .infinity, maxHeight: .infinity)
        }

Now we can insert our FloatingMenu. We also apply some padding to it

ZStack(alignment: .bottomTrailing) {
            Rectangle()
                .foregroundColor(.clear)
                .frame(maxWidth: .infinity, maxHeight: .infinity)
            FloatingMenu()
                .padding()
        }

If we now run the app in the regular simulator, we see our floating action button in the lower right corner. If we tap on it, the individual menu items move in from the right side of the screen!

Conclusion 🎊

Awesome! We learned how to create our own floating action button with an animated menu in SwiftUI. You can now use this knowledge to add your own floating menus to your SwiftUI apps.

The complete source code for the app can be found here.

I hope you enjoyed this tutorial! If you want to learn more about SwiftUI, check out our other tutorials! Also make sure you follow us on Instagram and subscribe to our newsletter to not miss any updates, tutorials and tips about SwiftUI and more!

Categories
Uncategorized

Voice Recorder app in SwiftUI – #2 Playing the audios

Welcome to a new SwiftUI tutorial. In this article, we will create our own dictation app. We will learn how to record audios, how to save audio files and how to play them.

In the last part of this tutorial, we dealt with how we record and save audio. In this part, we will learn how to playback the recorded audios. We will also enable the user to delete old recordings.

Preparing the AudioPlayer 🎵

Similar to what we already did with the AudioRecorder, we create our own ObservableObject for the playback functionality. For this purpose, we create a new Swift file called AudioPlayer.

In this file, we import the SwiftUI, Combine and the AVFoundation framework. Then we create a class called AudioPlayer which adapts the ObservableObject protocol.

import Foundation
import SwiftUI
import Combine
import AVFoundation

class AudioPlayer: ObservableObject {
    
}

Again, we need a PassthroughObject to notify observing views about changes, especially if an audio is being played or not.

class AudioPlayer: ObservableObject {
    
    let objectWillChange = PassthroughSubject<AudioPlayer, Never>()
    
}

Accordingly, we implement a variable isPlaying which we set to false by default. If the value of the variable gets changed, we inform observing views using our objectWillChange property.

var isPlaying = false {
        didSet {
            objectWillChange.send(self)
        }
    }

And for the playback functionality, we need an AVAudioPlayer instance from the AVFoundation framework.

var audioPlayer: AVAudioPlayer!

You can see that the structure of our AudioPlayer is very similar to the AudioRecorder.

Now we can insert the start and stop buttons into each RecordingRows.

Updating the RecordingRows ✍️

Each RecordingRow needs its own AudioPlayer for the respective audio recording. To do this, we initialise one separate AudioPlayer instance as an ObservedObject for each RecordingRow.

struct RecordingRow: View {
    
    var audioURL: URL
    
    @ObservedObject var audioPlayer = AudioPlayer()
    
    var body: some View {
        //...
    }
}

If the audioPlayer is not playing, we want to display a play button that allows the user to listen to the recording.

HStack {
            Text("\(audioURL.lastPathComponent)")
            Spacer()
            if audioPlayer.isPlaying == false {
                Button(action: {
                    print("Start playing audio")
                }) {
                    Image(systemName: "play.circle")
                        .imageScale(.large)
                }
            }
        }

If an audio is currently playing, we need to display a button to stop the playback.

            if audioPlayer.isPlaying == false {
                Button(action: {
                    print("Start playing audio")
                }) {
                    Image(systemName: "play.circle")
                        .imageScale(.large)
                }
            } else {
                Button(action: {
                    print("Stop playing audio")
                }) {
                    Image(systemName: "stop.fill")
                        .imageScale(.large)
                }
            }

When you run the app in the regular simulator it should look like this:

Now we can implement the functions for playing and stopping the audio in our AudioPlayer, which we will call from the buttons of the RecordingRows.

Setting up the playback functionality

In our AudioPlayer we start by adding a function called startPlayback. This function should accept a URL, i.e. a file path for the audio to be played.

func startPlayback (audio: URL) {
        
    }

Similar to the recordingSession from the last part of the tutorial, we start by initializing a playbackSession inside this function.

func startPlayback (audio: URL) {
        
        let playbackSession = AVAudioSession.sharedInstance()
        
    }

By default, sounds are played through the device’s earpiece. However, we want the audio to be played through the loudspeaker. To achieve this, we have to overwrite the output audio port accordingly.

do {
            try playbackSession.overrideOutputAudioPort(AVAudioSession.PortOverride.speaker)
        } catch {
            print("Playing over the device's speakers failed")
        }

Now we can start playing the audio with the help of the given file path and inform the observing views about this. If this does not work, we will output a corresponding error.

do {
            audioPlayer = try AVAudioPlayer(contentsOf: audio)
            audioPlayer.play()
            isPlaying = true
        } catch {
            print("Playback failed.")
        }

To stop the playback, we add the following function to our AudioPlayer:

func stopPlayback() {
        audioPlayer.stop()
        isPlaying = false
    }

We can now call the two functions from our RecordingRow’s start and stop buttons.

if audioPlayer.isPlaying == false {
                Button(action: {
                    self.audioPlayer.startPlayback(audio: self.audioURL)
                }) {
                    Image(systemName: "play.circle")
                        .imageScale(.large)
                }
            } else {
                Button(action: {
                    self.audioPlayer.stopPlayback()
                }) {
                    Image(systemName: "stop.fill")
                        .imageScale(.large)
                }
            }

Run the app and tap on the play button to listen to your recorded audio! You may have noticed that although an audio was played to the end, the stop button is still being displayed. This is because we have not yet updated our isPlaying variable accordingly.

To be notified when an audio has finished playing, we need the audioDidFinishPlaying function. This function is part of the AVAudioPlayerDelegate protocol that our AudioPlayer has yet to adapt. Hint: To adapt this delegate protocol, the AudioRecorder must also adapt the NSObject protocol.

class AudioPlayer: NSObject, ObservableObject, AVAudioPlayerDelegate {
    
    //...
    
}

When an audio is played, we need to set the AudioPlayer itself as the delegate of the AVAudioPlayer.

func startPlayback (audio: URL) {
        
        //...
        
        do {
            audioPlayer = try AVAudioPlayer(contentsOf: audio)
            audioPlayer.delegate = self
            audioPlayer.play()
            isPlaying = true
        } catch {
            print("Playback failed.")
        }
    }

Now we can add the audioDidFinishPlaying function to our AudioPlayer. If the audio was successfully played, we set the playing properties value back to false.

func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
        if flag {
            isPlaying = false
        }
    }

When we now run the app and play a recording, the AudioPlayer will call the audioDidFinishPlaying function as its own delegate after the audio has been finished playing. This will cause the playing attribute to be false again, which will eventually cause the particular RecordingRow to update itself and display the play button again.

Deleting recordings 🗑

Finally, we would like to allow the user to delete individual recordings. For this purpose, we add the default edit button to the navigation bar of our ContentView.

.navigationBarTitle("Voice recorder")
.navigationBarItems(trailing: EditButton())

This button enables the user to select individual RecordingRows from the RecordingList that he wants to delete. To do this, the Edit button expects us to implement a delete function. We have to add this function to our RecordingsList.

struct RecordingsList: View {
    
    //...
    
    var body: some View {
        //...
    }
    
    func delete(at offsets: IndexSet) {
        
        
    }
}

The offsets argument represents a set of indexes of recording rows that the user has chosen to delete. With these, we create an array of the file paths of the recordings to be deleted.

func delete(at offsets: IndexSet) {
        
        var urlsToDelete = [URL]()
        for index in offsets {
            urlsToDelete.append(audioRecorder.recordings[index].fileURL)
        }

    }

We can now add a function within our AudioRecorder that accepts an array of urls and deletes the corresponding files from the document folder. When the deletion is completed we update our recordings array using the fetchRecording function.

func deleteRecording(urlsToDelete: [URL]) {
        
        for url in urlsToDelete {
            print(url)
            do {
               try FileManager.default.removeItem(at: url)
            } catch {
                print("File could not be deleted!")
            }
        }
        
        fetchRecordings()
        
    }

We now call this function from the delete function of our RecordingsList.

func delete(at offsets: IndexSet) {
        
        var urlsToDelete = [URL]()
        for index in offsets {
            urlsToDelete.append(audioRecorder.recordings[index].fileURL)
        }
        audioRecorder.deleteRecording(urlsToDelete: urlsToDelete)
    }

Finally, we have to apply the delete functionality to every RecordingRow in the RecordingList by writing:

List {
            ForEach(audioRecorder.recordings, id: \.createdAt) { recording in
                RecordingRow(audioURL: recording.fileURL)
            }
                .onDelete(perform: delete)
        }

Run the app to see if it works. We can now either swipe a recording to the right or tap the edit button to delete it.

Conclusion 🎊

That’s it, We are finished with our own voice recorder app! We learned how to record and save audios and how to play and delete them.

You can look up the complete source code of the app here.

I hope you enjoyed this tutorial! If you want to learn more about SwiftUI, check out our other tutorials! Also make sure you follow us on Instagram and subscribe to our newsletter to not miss any updates, tutorials and tips about SwiftUI and more!

Categories
Uncategorized

Voice Recorder app in SwiftUI – #1 Implementing the Audio Recorder

Welcome to a new SwiftUI tutorial. In this article, we will create our own dictation app. We will learn how to record audios, how to save audio files and how to play them. In this part, we’ll implement the recorder itself and learn how to save and fetch the audio files. In the next one, we’ll include the playback functionality and learn how to delete particular audio files.

This is what the finished app will look like:

Preparing the audio recorder 🎤

After creating a new SwiftUI project, we start by preparing our audio recorder. We will create an ObservableObject for this, which we will use to record the audios and save them. So let’s create a new File-New-Swift file and select Swift File. We call this file AudioRecorder.

Besides the SwiftUI and Combine framework we also have to import the AVFoundation framework which for the recording functionality. We also create a class called AudioRecorder which adapts the ObservableObject protocol.

import Foundation
import SwiftUI
import Combine
import AVFoundation

class AudioRecorder: ObservableObject {

}

To notify observing views about changes, for example when the recording is started, we need a PassthroughObject.

class AudioRecorder: ObservableObject {
    
    let objectWillChange = PassthroughSubject<AudioRecorder, Never>()
    
}

Then we initialize an AVAudioRecorder instance within our AudioRecorder. With its help we will start the recording sessions later.

class AudioRecorder: ObservableObject {
//...
    
var audioRecorder: AVAudioRecorder!
}

Our AudioRecorder should pay attention to whether something is being recorded or not. For this purpose, we use a suitable variable. If this variable is changed, for example when the recording is finished, we update subscribing views using our objectWillChange property.

var recording = false {
        didSet {
            objectWillChange.send(self)
        }
    }

In order to use the app on physical devices, we will need the user’s permission to access his microphone.

We need to go to the info.plist file of our Xcode project and add a new entry with the key “Privacy – Microphone Usage Description”. The value to insert is the description that we present to the user in order to know why we need this permission, for example: “We need access to your microphone to conduct recording sessions.”

After setting up our AudioRecorder and asking for the necessary permissions, we can now start designing the interface for our app!

Designing the voice recorder 👨‍🎨

We will use the standard ContentView file to set up the interface for our app. The ContentView will need to access an AudioRecorder instance, so we declare a corresponding ObservedObject.

struct ContentView: View {
    
    @ObservedObject var audioRecorder: AudioRecorder
    
    var body: some View {
        //...
    }
}

We need to initialise an AudioRecorder instance for our previews struct …

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(audioRecorder: AudioRecorder())
    }
}

… as well as for the scene function of our scenedelegate.swift, which uses ContentView as the root view for the app launch.

let contentView = ContentView(audioRecorder: AudioRecorder())

Next, we replace the standard “Hello World” Text with a VStack.

var body: some View {
        VStack {
            
        }
    }

When the audioRecorder is not recording, we want to present a button for starting the record session. If this is not the case, i.e. if a recording is in progress, we would like to have a button for stopping the recording instead.

VStack {
            if audioRecorder.recording == false {
                Button(action: {print("Start recording")}) {
                    Image(systemName: "circle.fill")
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                        .frame(width: 100, height: 100)
                        .clipped()
                        .foregroundColor(.red)
                        .padding(.bottom, 40)
                }
            } else {
                Button(action: {print("Stop recording)")}) {
                    Image(systemName: "stop.fill")
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                        .frame(width: 100, height: 100)
                        .clipped()
                        .foregroundColor(.red)
                        .padding(.bottom, 40)
                }
            }
        }

Above the start/stop button, we would like to display the already created recordings in a list. Therefore, we create a new File-New-Swift file, select SwiftUI view and call it RecordingsList. In this view, we insert an empty list. Later we will fill this list with the already saved recordings. At this point, we can also create an ObservedObject for the RecordingsList for the AudioRecorder, since we will need it later on.

import SwiftUI

struct RecordingsList: View {
    
    @ObservedObject var audioRecorder: AudioRecorder
    
    var body: some View {
        List {
            Text("Empty list")
        }
    }
}

struct RecordingsList_Previews: PreviewProvider {
    static var previews: some View {
        RecordingsList(audioRecorder: AudioRecorder())
    }
}

We can now insert the RecordingsList into our ContentView above the start/stop button and use the AudioRecorder instance of the ContentView as the RecordingsList‘s audioRecorder.

VStack {
            RecordingsList(audioRecorder: audioRecorder)
            //...
        }

Finally, we embed our ContentView in a NavigationView and provide it with a navigation bar.

NavigationView {
            VStack {
                //...
            }
                .navigationBarTitle("Voice recorder")
        }

Your preview should now look like this:

Starting a record session ⏺

As mentioned before, we use our AudioRecorder to record, end and save audios.

Let’s begin with implementing a function to start the audio recording as soon as the user taps on the record button. We call this function startRecording.

class AudioRecorder: ObservableObject {
    
    //...
    
    func startRecording() {
        
    }
    
}

Within this function we first create a recording session using AVFoundation framework:

func startRecording() {
        let recordingSession = AVAudioSession.sharedInstance()
    }

Then we define the type for our recording session and activate it. If this fails, we’ll output a corresponding error.

do {
            try recordingSession.setCategory(.playAndRecord, mode: .default)
            try recordingSession.setActive(true)
        } catch {
            print("Failed to set up recording session")
        }

Then we specify the location where the recording should be saved.

let documentPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]

The file should be named after the date and time of the recording and have the .m4a format.

let audioFilename = documentPath.appendingPathComponent("\(Date().toString(dateFormat: "dd-MM-YY_'at'_HH:mm:ss")).m4a")

The to string function isn’t implemented yet. To change this, create a new Swift file called Extensions. Then create an extension for the Date class and add the following function:

extension Date
{
    func toString( dateFormat format  : String ) -> String
    {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = format
        return dateFormatter.string(from: self)
    }

}

After that, we will define some settings for our recording…

func startRecording() {
        //...
        
        let settings = [
            AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
            AVSampleRateKey: 12000,
            AVNumberOfChannelsKey: 1,
            AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
        ]
    }

..and start the recording with our audioRecorder property! Then we inform our ContentView that the recording is running so that it can update itself and display the stop button instead of the start button.

do {
            audioRecorder = try AVAudioRecorder(url: audioFilename, settings: settings)
            audioRecorder.record()

            recording = true
        } catch {
            print("Could not start recording")
        }

We can now call this function via the start button of our ContentView.

Button(action: {self.audioRecorder.startRecording()}) {
                        Image(systemName: "circle.fill")
                            //...
                    }

Next, we implement the function to end the recording session. We named this function stopRecording. In this, we simply stop the recording session of our AudioRecorder and inform all observing views about this by setting the recording variable to false again.

class AudioRecorder: ObservableObject {
    
    //...
    
    func stopRecording() {
        audioRecorder.stop()
        recording = false
    }
    
}

We call this function from our stop button:

Button(action: {self.audioRecorder.stopRecording()}) {
                        Image(systemName: "stop.fill")
                            //...
                    }

In fact, we can now run our app and click on the record button to start a recording session. If we click on stop, the session will be ended. However, so far we don’t see the new recording in our RecordingsList.

However, we can manually check if the recording is stored in the documents storage of our app. Notice: This is only possible if the app has been running on a physical device and the device is still connected! To do this we go to the toolbar of Xcode and select “Window” and then “Devices and simulators”. Now mark the device that you used for the recording. Then select the VoiceRecorder app and click on the gear symbol.

Then click on “Download Container”. Right-click on the downloaded file and select “Show Package Contents”. Under AppData-Documents, you should now be able to see and play the file you just created!

Next, we’ll see how we can display the saved files directly in our app!

Fetching the saved recordings 🎵⬇️

We need the following information for each recording: When was the recording made (in order to sort the recordings) and under which document path can we find the recording? For this purpose, we create a suitable data model.

Create a new File-New-Swift file, select Swift file, and name it RecordingDataModel.

In this file, we declare an appropriate struct with the according attributes:

struct Recording {
    let fileURL: URL
    let createdAt: Date
}

Back to our AudioRecorder. We can now create an array to hold the recordings.

 //...
    
    var audioRecorder: AVAudioRecorder!
    
    var recordings = [Recording]()
    
    var recording = false {
        didSet {
            objectWillChange.send(self)
        }
    }
    
    //...

Then we implement a function called fetchRecordings to access the stored recordings.

class AudioRecorder: ObservableObject {
    
    //...
    
    func stopRecording() {
        audioRecorder.stop()
        recording = false
    }
    
    func fetchRecordings() {
        
    }
    
}

Every time we fetch the recordings we have to empty our recordings array before to avoid that recordings are displayed multiple times. Then we access the documents folder where the audio files are located and cycle through all of them.

func fetchRecordings() {
        recordings.removeAll()
        
        let fileManager = FileManager.default
        let documentDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0]
        let directoryContents = try! fileManager.contentsOfDirectory(at: documentDirectory, includingPropertiesForKeys: nil)
        for audio in directoryContents {
            
        }
    }

In addition to the file path of the recording, we also need to know when it was created. For this purpose, we create a new Swift file and call it Helper. In this we add the following function:

func getCreationDate(for file: URL) -> Date {
    if let attributes = try? FileManager.default.attributesOfItem(atPath: file.path) as [FileAttributeKey: Any],
        let creationDate = attributes[FileAttributeKey.creationDate] as? Date {
        return creationDate
    } else {
        return Date()
    }
}

This function reads out the file at the given path and returns the date when it was created. If this fails, we simply return the current date.

In the fetchRecordings‘ for-in loop we can now use this function for the respective audio recording. We then create one Recording instance per audio file and add it to our recordings array.

for audio in directoryContents {
            let recording = Recording(fileURL: audio, createdAt: getCreationDate(for: audio))
            recordings.append(recording)
        }

Then we sort the recordings array by the creation date of its items and eventually update all observing views, especially our RecordingsList.

func fetchRecordings() {
        //...
        
        recordings.sort(by: { $0.createdAt.compare($1.createdAt) == .orderedAscending})
        
        objectWillChange.send(self)
    }

The fetchRecordings function should be called every time a new recording is completed.

func stopRecording() {
        audioRecorder.stop()
        recording = false
        
        fetchRecordings()
    }

But also when the app and therefore also the AudioRecorder is launched for the first time. For this, we overwrite the init function of the AudioRecorder accordingly. To make this work, our AudioRecorder must adopt the NSObject protocol.

class AudioRecorder: NSObject,ObservableObject {
    
    override init() {
        super.init()
        fetchRecordings()
    }
    
    //...
}

Displaying the recordings 👁

Our RecordingsList should display one row for each stored recording. Therefore, we add a RecordingRow view below our RecordingsList struct.

struct RecordingRow: View {
    
    var body: some View {
        
    }
}

Each row should be assigned to the path of the particular audio file. Within the HStack we then use this path without the file extension for a Text object which we push to the left side with the help of a Spacer.

struct RecordingRow: View {
    
    var audioURL: URL
    
    var body: some View {
        HStack {
            Text("\(audioURL.lastPathComponent)")
            Spacer()
        }
    }
}

In our RecordingsList, we add one RecordingView for each object in the recordings array of the audioRecorder.

var body: some View {
        List {
            ForEach(audioRecorder.recordings, id: \.createdAt) { recording in
                RecordingRow(audioURL: recording.fileURL)
            }
        }
    }

When we run our app now, we see all saved recordings in the list above the record button. When we complete a new recording, the fetchRecording function gets called, which clears the recording array and refills it including the newly saved recording.

Conclusion 🎊

Awesome! In this part, we learned how to record and save audios using an AVRecorder. We also saw how to read out already saved recordings and display them sorted by their creation dates.

In the next part, we will then add the possibility to playback the saved recordings. Additionally, we will learn how to delete recordings!

You can look up the current progress of the project here!

I hope you enjoyed this tutorial! If you want to learn more about SwiftUI, check out our other tutorials! Also make sure you follow us on Instagram and subscribe to our newsletter to not miss any updates, tutorials and tips about SwiftUI and more!

Categories
Uncategorized

How to create a side menu (hamburger menu) in SwiftUI

Welcome to a new SwiftUI tutorial! In this article, we will learn how to create a side menu with a smooth slide-out animation, also called hamburger menu, in SwiftUI. These kinds of menus are often used in Android apps, but can also be useful in iOS apps as an alternative or addition to tab bars.

Here’s what we are going to achieve in this tutorial:

Setting up the main view 👨‍💻

The first thing we do is to create the main view of our app. This view should only contain a button which we will use to toggle the side menu later. Therefore, add a Button with the corresponding Text to the ContentView.

Button(action: {
   print("Open the side menu")
}) {
       Text("Show Menu")
    }

Let’s outsource this by CMD-clicking on the Button and selecting “Extract as subview”. Name the outsourced view MainView.

struct ContentView: View {
    var body: some View {
        MainView()
    }
}

struct MainView: View {
    var body: some View {
        Button(action: {
            print("Open the side menu")
        }) {
            Text("Show Menu")
        }
    }
}

The MainView should fill the whole screen. In order to know the height and width of the overall super view, we wrap the MainView into a GeometryReader.

GeometryReader { geometry in
            MainView()
        }

We can now use the geometry property to adjust the frame of our MainView to fill the entire screen.

MainView()
  .frame(width: geometry.size.width, height: geometry.size.height)

Your preview should now look like this:

Designing the menu 👨‍🎨

Now we will design our side menu. Create a new File-New-File, create a SwiftUI view and call it MenuView. Our menu should contain four vertically arranged menu items. For this purpose, we use a VStack with .leading as the alignment mode.

struct MenuView: View {
    var body: some View {
        VStack(alignment: .leading) {
            
        }
    }
}

Each menu item consists of an icon and a corresponding text. Thus, we add an HStack to our VStack for our first menu item.

VStack(alignment: .leading) {
            HStack {
                
            }
        }

Now we use the “person” system icon, which we make gray and enlarged.

HStack {
    Image(systemName: "person")
        .foregroundColor(.gray)
        .imageScale(.large)
    }

Then, we add the corresponding text:

HStack {
    Image(systemName: "person")
        .foregroundColor(.gray)
        .imageScale(.large)
    Text("Profile")
        .foregroundColor(.gray)
        .font(.headline)
        }

The first menu item should have a fairly large distance to the top. To do this we use the .padding modifier for the HStack.

HStack {
          //...
            }
                .padding(.top, 100)

We repeat these steps for the other menu items, but with other icons, texts and paddings.

VStack(alignment: .leading) {
            HStack {
                Image(systemName: "person")
                    .foregroundColor(.gray)
                    .imageScale(.large)
                Text("Profile")
                    .foregroundColor(.gray)
                    .font(.headline)
            }
                .padding(.top, 100)
            HStack {
                Image(systemName: "envelope")
                    .foregroundColor(.gray)
                    .imageScale(.large)
                Text("Messages")
                    .foregroundColor(.gray)
                    .font(.headline)
            }
                .padding(.top, 30)
            HStack {
                Image(systemName: "gear")
                    .foregroundColor(.gray)
                    .imageScale(.large)
                Text("Settings")
                    .foregroundColor(.gray)
                    .font(.headline)
            }
                .padding(.top, 30)
        }

Finally, we push our entire menu up by adding a Spacer.

VStack(alignment: .leading) {
            HStack {
                //...
            }
                .padding(.top, 100)
            HStack {
                //...
            }
            .padding(.top, 30)
            HStack {
                //....
            }
            .padding(.top, 30)
            Spacer()
        }

To increase the distance between the menu items and the edges, we apply some overall padding to the entire VStack.

VStack(alignment: .leading) {
            //...
        }
            .padding()

We also want our menu to be as wide as possible and the individual items to be aligned to the left.

VStack(alignment: .leading) {
            //...
        }
            .padding()
            .frame(maxWidth: .infinity, alignment: .leading)

We finally give our menu a dark grey background and make sure that it also goes beyond the so-called Safe Area, meaning that it also fills out the upper and lower edges.

VStack(alignment: .leading) {
            //...
        }
            .padding()
            .frame(maxWidth: .infinity, alignment: .leading)
            .background(Color(red: 32/255, green: 32/255, blue: 32/255))
            .edgesIgnoringSafeArea(.all)

Great, we’re done creating our MenuView. The corresponding preview should now look like this:

Inserting the MenuView into our ContentView ➡️

To keep track of whether the MenuView should be displayed inside our ContentView or not, we declare an according State:

struct ContentView: View {
    
    @State var showMenu = false
    
    var body: some View {
        //...
    }
}

When this State is true, the MenuView should be shown on top of our MainView, aligned on the left side. Therefore, we wrap the MainView into a ZStack und insert the MenuView depending on the State’s value.

ZStack(alignment: .leading) {
                MainView()
                    .frame(width: geometry.size.width, height: geometry.size.height)
                if self.showMenu {
                    MenuView()
                }
            }

The menu should only cover the half of our screen, therefore we add the following .frame:

if self.showMenu {
    MenuView()
        .frame(width: geometry.size.width/2)
    }

We can now create a Binding from our MainView to our showMenu State …

struct MainView: View {
    
    @Binding var showMenu: Bool
    
    var body: some View {
        //...
    }
}

… and initialise it inside our ContentView:

MainView(showMenu: self.$showMenu)
    .frame(width: geometry.size.width, height: geometry.size.height)

We can now use our MainView’s button to toggle the showMenu State through it’s Binding which causes the ContentView to rebuild itself with eventually showing our MenuView

Button(action: {
            self.showMenu = true
        }) {
            Text("Show Menu")
        }

Additionally, we want to shift our MainView to the right when the side menu is opened. We also want to disable any functionality of our MainView until the menu is closed again.

MainView(showMenu: self.$showMenu)
    .frame(width: geometry.size.width, height: geometry.size.height)
    .offset(x: self.showMenu ? geometry.size.width/2 : 0)
    .disabled(self.showMenu ? true : false)

Run the app preview in live mode to try it out! Awesome, when we tap on the “Show Menu” button, our side menu gets displayed to us. At the same time, our MainView shifts to the right and the button it contains is grayed out.

So far, however, our menu gets still displayed without any kind of animation. Instead, we want to use a “slide-in” transition.

Therefore, we wrap the action of the button in our MainView into a withAnimation statement.

Button(action: {
            withAnimation {
               self.showMenu = true
            }
        }) {
            Text("Show Menu")
        }

Now we can attach a transition modifier to our MenuView and specify that the menu should move in from the left side.

MenuView()
    .frame(width: geometry.size.width/2)
    .transition(.move(edge: .leading))

If you now run the app again, you will see that the menu appears with a slide-in transition.

Hint: If the animation is not being executed in the live preview, run the app in the regular simulator.

Swipe to close the menu 👆

We want to be able to close the menu again by swiping from right to left. For this purpose, we use a so-called Drag Gesture. We create such a gesture by declaring it within our ContentView’s body and marking the remaining view’s content with the return keyword.

var body: some View {
        
        let drag = DragGesture()
        
         return GeometryReader { geometry in
            //...
        }
    }

When we have swiped far enough we want to close our menu by setting the showMenu State to false again. To do this we use the .onEnded modifier for our drag gesture.

let drag = DragGesture()
            .onEnded {
                
        }

In this, we check whether the user has exceeded a certain threshold value with his swiping gesture. If this is the case, we close the menu.

let drag = DragGesture()
            .onEnded {
                if $0.translation.width < -100 {
                    withAnimation {
                        self.showMenu = false
                    }
                }
            }

We can now simply attach the created gesture to our ContentView.

ZStack(alignment: .leading) {
                //...
            }
                .gesture(drag)

If we now run the app and have the menu opened, we can simply swipe to the left to let the menu collapse.

Implementing the Burger Button 🍔

Finally, we would also like to have the possibility to open and close the menu using a so-called hamburger icon. For this purpose, we wrap the GeometryReader of our ContentView into a NavigationView and add a navigation bar title to it.

return NavigationView {
            GeometryReader { geometry in
                //...
            }
                .navigationBarTitle("Side Menu", displayMode: .inline)
        }

We can now add an item to the left side of our navigation bar by using the .navigationBarItems modifier with the leading argument.

.navigationBarTitle("Side Menu", displayMode: .inline)
.navigationBarItems(leading: (
))

In this, we now add a suitable system icon and wrap it into a button with which we toggle the showMenu State.

.navigationBarItems(leading: (
                    Button(action: {
                        withAnimation {
                            self.showMenu.toggle()
                        }
                    }) {
                        Image(systemName: "line.horizontal.3")
                            .imageScale(.large)
                    }
                ))

If we execute the app now we can open and close our side menu with the MainView’s button and the swipe gesture as well as with the “hamburger” icon in the navigation bar.

Conclusion 🎊

That’s it! In this tutorial we learned how to add a so-called Hamburger/Side Menu to a SwiftUI app. We also saw how to let it move in with an appropriate transition and how to close it with a swipe gesture.

You can find the complete source code for the app here.

If you want to learn more about SwiftUI, check out our other tutorials! Also make sure you follow us on Instagram and subscribe to our newsletter to not miss any updates, tutorials and tips about SwiftUI and more!