LANARS

The Ultimate Guide to Creating a Side Menu with SwiftUI: Part 1

The Ultimate Guide to Creating a Side Menu with SwiftUI: Part 1
Time to read
23 min
Section
Share
Subscribe
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.

Introduction

Hey there! Today we're going to go through a journey from having nothing to having a nicely animated, UX-aware side menu, building it step-by-step, and sharing the best practices with you.

We will answer some of the questions, starting from really basics up to various hints and tricks that you may need in your product, by taking into account code quality and following SwiftUI API style.

This is what we're going to talk about in this series of articles by gradually increasing complexity of covered topics:

  • How to create a simple side menu (aka Drawer) using SwiftUI. 
  • How to mimic PresentationMode in your own components to let child view know their presenation state.
  • How to customize app appearance by using built-in SwiftUI tools such as ButtonStyle, LabelStyle.
  • How to pass and manipulate layout data for seamless animation, selection and transitions using Preferences and matchedGeometryEffect and more.
  • And finally we will learn custom transitions for a neat fade in animations.

Here is what we'll end up with:

Preconditions

Before we start, you may want to download initial project from our GitHub repo to follow code by hands. It's build on top of Swift Package Manager and assumes you have basic experience with it. Once downloaded, open "Example" project and we're ready to go. 

Drawer Basics

From time to time we need to show a menu or any other supplementary/navigation content that should be revealed from leading/trailing edge. And we don't want to repeat it over and over again, so our Drawer should be generic enough over it's content to be used in any place, so let's begin by opening a Drawer.swift and jump into the code right away:

public struct Drawer<Menu: View, Content: View>: View {
  @Binding private var isOpened: Bool
  private let menu: Menu
  private let content: Content

  // MARK: - Init
  public init(
    isOpened: Binding<Bool>,
    @ViewBuilder menu:  () -> Menu,
    @ViewBuilder content: () -> Content
  ) {
    _isOpened = isOpened
    self.menu = menu()
    self.content = content()
  }

  // MARK: - Body
  public var body: some View {
    ZStack(alignment: .leading) {
      content

      if isOpened {
        Color.clear
          .onTapGesture {
            if isOpened {
              isOpened.toggle()
            }
          }
        menu
          .transition(.move(edge: .leading))
      }
    }
    .animation(.spring(), value: isOpened)
  }
}


We're almost done with basics. It is surprising how simple and concise code is thanks to SwiftUI. You can add a following code to either a Preview or to a ContentView.swift in the Example app:

struct ContentView: View {

  @State private var isOpened = true

  var body: some View {
    Drawer(
      isOpened: $isOpened,
      menu: {
        ZStack {
          Color.gray
          Text("Menu")
        }
        .frame(width: 200)
      },
      content: {
        ZStack {
          Color.white
          Button {
            isOpened.toggle()
          } label: {
            Text("Open")
              .foregroundColor(Color.secondary)
          }
        }
      }
    )
  }
}

Final result
After running, you can quickly notice an issue with app not reacting to touches outside of a menu as well as the menu itself dissapearing whenever you click on a "Open" button. 


First of all, let's fix and understand a pretty simple issue: Color.clear is invisible for touches which is somehow expected. To resolve this, there are workarounds such as placing a solid color of 0.01 opacity, but we're going to use an intended view modifier — contentShape — that sets tappable area for a view hit testing. 

Also, let's break down the issue with accidental dissappearing on menu dismissal:

ZStack(alignment: .leading) {
  content
  if isOpened {
    ...
      menu
        .transition(.move(edge: .leading))
  }
}

The thing is that ZStack lays subviews out by a zIndex order. By default, it is inferred by order of declared views. Once we remove our menu from the ZStack there is zero reason to place it on top even being declared as a most latest one. But we can force the view to be layed out as top-most even upon removal by using zIndex view modifier. 

Here is our updated Drawer's body:

public var body: some View {
  ZStack(alignment: .leading) {
    content

    if isOpened {
      Color.clear
         .contentShape(Rectangle())
        .onTapGesture {
          if isOpened {
            isOpened.toggle()
          }
        }
      menu
        .transition(.move(edge: .leading))
        .zIndex(1)
    }
  }
  .animation(.spring(), value: isOpened)
}

Now, after building and running we can get exactly what we wanted to do:

PresentationMode

Ok, we've done really basic interactions so far. However, this is obviously not the state-of-art of a reusable component. For being generic enough, we need to establish communication with siblings to share information whether they are presented or not and — what's equally important — to let them dismiss themselves.

In the UIKit world, you could traverse parent-chiled hierarchy until desired view controller is found — just like navigationController behaves.

In SwiftUI world, this pattern is no longer applicable. To address this capability, Apple uses a PresentationMode that lets the presented child dismiss itself no matter how deep in the hierarchy it is — this one is managed by powerful environment values system.

Unfortunately, we can not instantiate our own instance of PresentationMode due to a hidden initializer, but we definetely should mimic what Apple did.

It's generally a great approach to stay on par with a built-in API, since you're automatically let other developers to lift up all their previous knowledge and experience about how similar API works. Needless to say, how such an approach improves local reasoning. 

The goal is to allow the following code within our menu or any descendant siblings down the hierarchy:

struct SiblingView: View {

  @Environment(\.drawerPresentationMode) var presentationMode

  var body: some View {
    Button("Dismiss") {
      presentationMode.wrappedValue.close()
    }
  }
}

Our presentation mode will look similar, yet a bit different to fit the following need: being able to understand/control Drawer's isOpened flag. Let's make a draft of an API we need:

 
struct DrawerPresentationMode {
  var isOpened: Bool
  mutating func open()
  mutating func close()
}

 

This looks pretty similar to what stardard PresentationMode offers, but how do we sync it with out source of truth isOpened that is managed by Drawer itself? There is a powerful capability within Binding itself: an ability to map from one type to another with little to no effort.

To implement it, let's figure out that DrawerPresentationMode is build around a plain Binding<Bool> but offers a bit fancier API: 

public struct DrawerPresentationMode {

  @Binding private var _isOpened: Bool

  init(isOpened: Binding<Bool>) {
    __isOpened = isOpened
  }

  public var isOpened: Bool {
    _isOpened
  }

  public mutating func open() {
    if !_isOpened {
      _isOpened = true
    }
  }

  public mutating func close() {
    if _isOpened {
      _isOpened = false
    }
  }
}

Now, once implementation is done, let's add mapping from Binding<Bool> to our DrawerPresentationMode:

extension Binding where Value == Bool {
  func mappedToDrawerPresentationMode() -> Binding<DrawerPresentationMode> {
    Binding<DrawerPresentationMode>(
      get: {
        DrawerPresentationMode(isOpened: self)
      },
      set: { newValue in
        self.wrappedValue = newValue.isOpened
      }
    )
  }
}

After getting familiar with how PresentationMode can be implemented, you can leverage nearly the same API as Apple did — this is exactly what we're striving for — to reuse all of the adopted knowledge for the Apple's toolchain.

Passing this binding to Drawer's siblings is straightforward by using EnvironmentValues:

 
extension DrawerPresentationMode {
  static var placeholder: DrawerPresentationMode {
    DrawerPresentationMode(isOpened: .constant(false))
  }
}
private struct DrawerPresentationModeKey: EnvironmentKey {
  static var defaultValue: Binding<DrawerPresentationMode> = .constant(.placeholder)
}

extension EnvironmentValues {
  public var drawerPresentationMode: Binding<DrawerPresentationMode> {
    get { self[DrawerPresentationModeKey.self] }
    set { self[DrawerPresentationModeKey.self] = newValue }
  }
}
 

What's left is to pass this data down the road in the Drawer's body: 

public var body: some View {
  ZStack(alignment: .leading) {
    content
    ...
    
    }
  }
  .animation(.spring(), value: isOpened)
  .environment(\.drawerPresentationMode, $isOpened.mappedToDrawerPresentationMode())
}

Now we have everything that we need to finally start implementing a menu. 

An attentive person could notice that there is no reason to make a wrapper around Binding<Bool> and that's totally correct. For our purpose, it is sufficient to pass binding directly. However, it is still worth to understand how things can be done under the hood.

You can download the final version of this section from our repo as well.

Building a Side Menu

Now it's time to start implementing the menu. Let's recap what needs to be done and split it down to a set of features:

Default Menu with dark color scheme   Compact Menu with dark color scheme

So far here is what we will implement step-by-step:

  1. List of MenuItem's with header with support of expanded and compact state in place.
  2. Selection of a specific item.
  3. Custom background transition during color scheme switching.

As a starting point you can use a following commit or continue from where we left off in the previous section.

Exanding/Collapsing a Menu

Before diving into the UI, it's worth to clarify how we can manage different states. Let's declare a MenuAppearance with a following content: 

enum MenuAppearance: String {
  case `default`, compact

  mutating func toggle() {
    switch self {
    case .default:
      self = .compact
    case .compact:
      self = .default
    }
  }
}

private struct MenuAppearanceEnvironmentKey: EnvironmentKey {
  static var defaultValue: MenuAppearance = .default
}

extension EnvironmentValues {
  var menuAppearance: MenuAppearance {
    get {
      self[MenuAppearanceEnvironmentKey.self]
    }
    set {
      self[MenuAppearanceEnvironmentKey.self] = newValue
    }
  }
}

extension View {
  func menuAppearance(_ appearance: MenuAppearance) -> some View {
    environment(\.menuAppearance, appearance)
  }
}

Since there are only two states we should take care of, enum matches our needs perfectly. Utility code after enum declaration allows us to bypass current menu state down the hierarchy just as we did before with Drawer.isOpened flag.
What's cool here is that due to SwiftUI layout model, the child can rely on a given menu appearance to adjust it's layout. This has a dramatic impact on code simplification: basically, there is no presentation detent involed to manage different layout sizes — SwiftUI does proper resizing for us. 

Label & LabelStyle

Now lets move on to basic building blocks. As stated above, we strive to keep our code as close as possible to the SwiftUI built-in API not only in terms of naming and semantics, but also to reuse existing components and conceptions.
To get a better undertanding of what I'm talking about, take a look here and try to find out common views:

It is obvious from a design, that we need to maintain some sort of a HStack with a constant spacing and a similar behaviour when the menu collapses to show only an icon.

In UIKit world, it would be not a disaster, yet a task to think about. But it is not, when it comes to SwiftUI. At a glance, these are two separate types of views: user header and a cell that can be selected. However, in SwiftUI both can be described by a single view called Label and it's accompanied LabelStyle that describes the way Icon an Title are going to be presented.

This is a very powerful idea, since in most of iOS apps there are plenty of abstractions that can be perfectly described as a pair of Title and an Icon. SwiftUI lifts this idea to the next level by letting us provide any appearance modifier through style. 

And since both Icon and Title are generic types, there is nothing surpising that VStack from user header and a menu item title from the cell are both represented as a Title. Thus, having this in mind we can scale this cocept even further in our applications by simplifying and reusing layour here and there. What a time to be alive! :) 

Enough talk, let's implement our MenuLabelStyle:

struct MenuLabelStyle: LabelStyle {

  @Environment(\.menuAppearance) var appearance

  func makeBody(configuration: Configuration) -> some View {
    HStack(spacing: 24) {
      configuration.icon
        .frame(width: 48, height: 48, alignment: .center)

      if appearance == .default {
        configuration.title
          .typographyStyle(.title)
          .transition(.move(edge: .trailing).combined(with: .opacity))
      }
    }
  }
}

extension LabelStyle where Self == MenuLabelStyle {
  static var menuLabel: Self {
    MenuLabelStyle()
  }
}

typographyStyle is just a handy wrapper to acomplish a single styling system. It can be found in our example code

As you can see, all we have here is a dead-simple layout that reacts to a menu appearance by hiding Title of the given configuration (see LabelStyleConfiguration for more info). 
The only noticable modifier here is transition of a title, that moves and fades our label in/out. This is a part of out animation. You can play around to see final effects. 

ButtonStyle

Once we've done with label layout, how about finishing with cells? These are not cells here — that's rather a VStack with buttons in it for each MenuItem that we have. And just like with LabelStyle, SwiftUI provides us ability to modify appearance of a Button by a custom ButtonStyle. This is a common approach where the composition shows all its advantages over the inheritance. We can literally extend and compose any style in any combination which is mind-blowing. 


You may find here and there the implementations of a custom button with Gesture Recognizers, that handles highlighting and selection, however it's clearly not an intended approach and a workaround in a nutshell.

ButtonStyle on the other hand, passes you not only the view you have to display, but also states whether the view is pressed or not. Button at the same time has the ability to manage highlighting behaviour properly by providing a iOS-wide behaviour — try to press and move your finger around any system button to see what I mean.

Long story short: ButtonStyle one more time tells us how SwiftUI wants us to extend and style views. 


Let me show you the implementation of a MenuButtonStyle:

struct MenuItemButtonStyle: ButtonStyle {

  var isSelected: Bool = false

  func makeBody(configuration: Configuration) -> some View {
    configuration
      .label
      .labelStyle(.menuLabel)
      .foregroundColor(configuration.isPressed || isSelected ? Color("color/selectionForeground") : Color("color/text"))
      .animation(.spring(), value: isSelected != configuration.isPressed)
      .frame(maxWidth: .infinity, minHeight: .contentHeight, alignment: .leading)
      .contentShape(Rectangle())
  }
}

Take a closer look at what we have here. Doesn't look that complex, yet few lines require some explanation:

- animation for isSelected != configuration.isPressed gives system look and feel to your customer button. Thus, whenever user makes a touch down event, in case there any visual feedback, you get a nice animation. Remember, visual feedback is imporant, since user has no clue about what's going on. 

 

- frame — this one is a tricky modifier. It lets the button be expandable to any width, otherwise it'll shrink to hug it's content perfectly. Later on, we're using it within VStack to get a fully width-sized buttons, even though their actual content size may be much smaller. Play around with this modifier to see what's gonna happen.

Managing frames is a wide topic and we highly recommend a Pro SwiftUI to get a hands-on knowledge about SwiftUI layout system.

 

- contentShapeReturns a view that uses the given shape for hit testing — Apple Documentation clearly says what's that. However, to clarify what exactly it does here we need to get back to the frame modifier above. Remember, Button will shrink itself to hug it's content perfectly. Thus, it has zero reasons to handle touches elsewhere, but within its icon and title. But our button should be interactive within full width of the VStack and by applying this modifier we explicitly force this behaviour.

 

- isSelected — last but not least. ButtonStyle and Button are not handling selected state and let you decide how to store this flag and how to present it to the user. Our list should keep selected MenuItem so we're passing this flag explicitly to our custom style. 

List

struct MenuItemList: View {

 ...

  // MARK: - Body

  var body: some View {
    VStack(alignment: .leading) {
      UserHeader(user: user)
        .onTapGesture {
          onUserSelection?()
        }

      Spacer()
        .frame(height: .headerBottomSpacing)

      itemsList
    }
    .animation(.spring(), value: selection)
  }

  @ViewBuilder
  private var itemsList: some View {
    VStack(alignment: .leading) {
      ForEach(items) { item in
        Button(
          action: {
            if selection == item {
              selection = nil
            } else {
              selection = item
            }
          },
          label: {
            Label(item: item)
          }
        )
        .buttonStyle(MenuItemButtonStyle(isSelected: item == selection))
      }
    }
  }
}

Some of the properties and utility code is skipped. It is recommended to browse GitHub repo to get a full code. 

Now it's time to update our ContentView and to build and run our code to see what we've got: 

struct ContentView: View {
  @State var selection: MenuItem? = .dashboard
  @State var appearance: MenuAppearance = .default

  var body: some View {
    MenuItemList(selection: $selection) {
      withAnimation(.spring()) {
        appearance.toggle()
      }
    }
    .fixedSize(horizontal: true, vertical: false)
    .menuAppearance(appearance)
  }
}

Here is our results after making final changes to our ContentView:






And with just a few additions to assemble things together:

While final version of the code above with background, etc, can be found here, it's time to move to the fun Part 2 — selection and custom transitions.