Create Android style navigation drawer in SwiftUI
Android’s Jetpack Compose has a nifty navigation drawer component that is useful for adding navigation to different sections of an app. iOS doesn’t have any equivalent UI element, either in UIKit or in SwiftUI. However, the view is pretty easy to create in SwiftUI - and that is exactly what we will be doing over the course of two posts. This is going to be our end goal:
A breakdown of the features our DrawerView will support -
- We should be able to open/close the drawer programatically
- Main view should gradually fade as drawer view slides in
- We should be able to use drag gestures to open/close the drawer
The public API of DrawerView looks like this -
@State var isOpen = false
DrawerView(isOpen: $isOpen) {
// Main content view
} drawer: {
// Drawer content view
}
The isOpen
boolean binding controls whether the drawer is open or closed.
Let’s go ahead and create the DrawerView.swift
file which satisfies this API -
import SwiftUI
// 1
struct DrawerView<MainContent: View, DrawerContent: View>: View {
// 2
@Binding var isOpen: Bool
private let main: () -> MainContent
private let drawer: () -> DrawerContent
init(isOpen: Binding<Bool>,
@ViewBuilder main: @escaping () -> MainContent,
@ViewBuilder drawer: @escaping () -> DrawerContent) {
self._isOpen = isOpen
self.main = main
self.drawer = drawer
}
var body: some View {
main()
}
}
- Main content and drawer content can be different types of views. So DrawerView needs two different generic types to represent them.
- The boolean binding is not used for now, but we will use it later to close the drawer when the overlay we add to main view is tapped.
Lay things out
We need to make sure that main view occupies the full size of the screen while drawer view occupies full height and some fraction of main view’s width. In SwiftUI, the way to read the size of any view is by using GeometryReader
. GeometryReader constructor takes a single closure and passes an instance of GeometryProxy
to this closure. Using this proxy, we can get (among other things) the size of the view containing the GeometryReader1.
struct DrawerView<MainContent: View, DrawerContent: View>: View {
// 1
private let overlap: CGFloat = 0.7
....
....
var body: some View {
GeometryReader { proxy in
// 2
let drawerWidth = proxy.size.width * overlap
// 3
ZStack(alignment: .topLeading) {
// 4
main().frame(maxWidth: .infinity, maxHeight: .infinity)
// 5
drawer().frame(minWidth: drawerWidth, idealWidth: drawerWidth,
maxWidth: drawerWidth, maxHeight: .infinity)
}
}
}
}
Preview (tap to view)
struct ContentView: View {
@State var isOpen = false
var body: some View {
DrawerView(isOpen: $isOpen) {
Color.red
Button("Show drawer") {
withAnimation {
isOpen.toggle()
}
}
} drawer: {
Color.green
Button("Hide drawer") {
withAnimation {
isOpen.toggle()
}
}
}
}
}
- The
overlap
CGFloat property is a fraction between between 0 to 1. It governs how wide the drawer should be compared to the main content. As an example, overlap value of 0.7 means drawer view’s width will be 70% of main view’s width. - Within our GeometryReader closure, we calculate the width of drawer using the width of the proxy and overlap fraction.
- We embed both main and drawer views inside a ZStack. Drawer comes after main since we want drawer to show on top of main view.
- We embed main view in an infinitely sized frame. Practically, this means the frame will extend to occupy same size as it’s first non layout neutral2 parent. Since both ZStack and GeometryReader are layout neutral, the frame will end up occupying the size of the device screen.
- We embed drawer in a frame with infinite height, but width is constrained to what we calculated in step 2.
Show/hide drawer programatically
Note that in ContentView, we are toggling the isOpen
boolean on button taps. Tapping the buttons won’t do anything yet. To fix this, we need to modify the position of drawer along X axis in response to the boolean value. This can be accomplished by adding an offset
modifier to drawer view.
var body: some View {
GeometryReader { proxy in
let drawerWidth = proxy.size.width * overlap
ZStack(alignment: .topLeading) {
main().frame(maxWidth: .infinity, maxHeight: .infinity)
drawer()
.frame(minWidth: drawerWidth, idealWidth: drawerWidth,
maxWidth: drawerWidth, maxHeight: .infinity)
.offset(x: isOpen ? 0 : -drawerWidth, y: 0)
}
}
}
Preview (tap to view)
struct ContentView: View {
@State var isOpen = false
var body: some View {
DrawerView(isOpen: $isOpen) {
Color.red
Button("Show drawer") {
withAnimation {
isOpen.toggle()
}
}
} drawer: {
Color.green
Button("Hide drawer") {
withAnimation {
isOpen.toggle()
}
}
}
}
}
When isOpen
is true, we set drawer X-axis offset to 0. This will show the drawer in it’s original position i.e overlapping the main view. When isOpen
is false, we set the offset equal to negative of drawer width. This will effectively “hide” the drawer by moving it off screen. We don’t need to change Y-axis offset.
Tapping the buttons in ContentView
should now toggle the drawer visibility with a nice animation. We don’t need to write any animation code besides wrapping the changes to isOpen
in a withAnimation
block. SwiftUI is smart enough to figure out what properties need to change based on this boolean and smoothly animate between their start and end values (in this case - the X-axis offsets). Pretty cool, huh?
Fade main view gradually
For putting more focus on the drawer when it is open, we can fade the main view progressively as drawer opens. We also need to make sure that -
- Main view’s content is disabled from user interaction while drawer is open
- Tapping the main view content anywhere closes the drawer
Both of these features can be added easily thanks to the power of modifiers. SwiftUI is smart enough to infer that if a modifier is not going to affect the view tree, it can effectively be “removed” from the view. To understand what this means, try running this piece of code on a simulator -
Color.red.opacity(1)
.onTapGesture {
print("Tapped")
}
Whole screen will be red and tapping anywhere will print a message in console. Now change opacity to 0. This time, no message will be printed since setting opacity to 0 effectively hides the view and so SwiftUI also removes the tap gesture associated with it.3
Using this technique, we can add an overlay for the main view -
struct DrawerView<MainContent: View, DrawerContent: View>: View {
...
// 1
private let overlayColor = Color.gray
private let overlayOpacity = 0.7
...
var body: some View {
GeometryReader { proxy in
let drawerWidth = proxy.size.width * overlap
ZStack(alignment: .topLeading) {
main()
.frame(maxWidth: .infinity, maxHeight: .infinity)
// 3
.overlay(mainOverlay)
drawer()
.frame(minWidth: drawerWidth, idealWidth: drawerWidth,
maxWidth: drawerWidth, maxHeight: .infinity)
.offset(x: isOpen ? 0 : -drawerWidth, y: 0)
}
}
}
// 2
private var mainOverlay: some View {
overlayColor.opacity(isOpen ? overlayOpacity : 0.0)
.onTapGesture {
withAnimation {
isOpen.toggle()
}
}
}
}
Preview (tap to view)
struct ContentView: View {
@State var isOpen = false
var body: some View {
DrawerView(isOpen: $isOpen) {
Color.red
Button("Show drawer") {
withAnimation {
isOpen.toggle()
}
}
} drawer: {
Color.green
Button("Hide drawer") {
withAnimation {
isOpen.toggle()
}
}
}
}
}
- We define the color of the overlay and it’s opacity when drawer is fully open. The opacity will be 0 when drawer is closed.
- We define a view for overlay whose opacity depends on
isOpen
boolean and add a tap gesture to this view which toggles the boolean. - We add this view as an overlay to our main view.
Thanks to the overlay, the underlying main view content becomes non-interactive while the drawer is open and interactive again when drawer is closed.
Adding support for drag gestures
Our DrawerView still lacks one critical feature - drag support. To avoid making this post too long, we will focus on adding this support in another post which can be found here. Source code for this post can be found here.
To know more about GeometryReader, I recommend checking this article - https://swiftui-lab.com/geometryreader-to-the-rescue/ ↩︎
To know more about layout neutrality in SwiftUI, I recommend checking this article - https://www.hackingwithswift.com/books/ios-swiftui/how-layout-works-in-swiftui ↩︎
I couldn’t find any official documentation for this behaviour. The closest thing is “Inert Modifiers” as talked about in WWDC21’s “Demystify SwiftUI” talk (37:30 onwards). ↩︎