Slide Out Menu for iOS using SwiftUI

This week I’ve been working on prototyping a slide out menu, or as most people know it as the hamburger menu. This is a very useful navigation system for apps once the scale past 5 “systems” or tabs.

This system has to wrap the main content view inside of a wrapper view in order to handle the requirements of the system.

The requirements of the system are as follows

  • It must use a navigation view to host a toolbar button that will allow the user to open the side menu.
  • The side menu must contain a list, that the entire width of can be tapped on causing the content view to change to the newly assigned view, and closes the side menu.
  • The side menu must be able to open and close using swipe gestures.
  • When closed, swipe from left to right must open the side menu.
  • When open, swipe from right to left must close the side menu.
  • All other swipe gestures must be ignored.
  • Tapping outside of the slide out menu must also close the menu.

Before we get too into the details we need a couple of things in the system to handle data storage and management. First we need a Observable Object to store the content view inside of.

class NavigationCoordinator: ObservableObject {
fileprivate var screen: AnyView = AnyView(EmptyView())
func show<V: View>(_ view: V) {
screen = AnyView(view)
}
}

The next thing we need is an enum for the swipe gesture directions. Since we only care about left and right those are the only ones I’ve included.

enum DragDirection {
case left
case right
case none
}

Now to get onto the creating of the actual views, we have to have a Side Menu. This is effectively just the container for the Slide Out navigation and animation.

struct SideMenu: View {
	let width: CGFloat
	let isOpen: Bool
	let menuClose: () -> Void
	let navigationCoordinator: NavigationCoordinator
	
	var body: some View {
		ZStack {
			// Code here
			GeometryReader { _ in
				EmptyView()
			}
			.background(Color.gray.opacity(0.3))
			.opacity(self.isOpen ? 1.0 : 0.0)
			.animation(Animation.easeIn.delay(0.25))
			.onTapGesture {
				self.menuClose()
			}
			
			HStack {
				MenuContent(navigationCoordinator: navigationCoordinator, menuClose: menuClose)
				.frame(width: self.width)
				.background(Color.white)
				.offset(x: self.isOpen ? 0 : -self.width)
				.animation(.default)
				
				Spacer()
			}
		}
	}
}

Next up is the content of the Slide Out Menu. Basically it’s just a list with some changes to make it look right in this context and to trigger the navigation on tap.

struct MenuContent: View {
	
	let navigationCoordinator: NavigationCoordinator
	let menuClose: () -> Void
	
	
	var body: some View {
		List {
			
			ZStack {
				VStack(alignment: .leading) {
					HStack {
						Text("My Profile")
						Spacer()
					}
					.frame(maxWidth: .infinity)
					.contentShape(Rectangle())
				}
				.frame(maxWidth: .infinity)
				.contentShape(Rectangle())
			}
			.contentShape(Rectangle())
			.listRowBackground(Color.red)
			.frame(maxWidth: .infinity)
			.onTapGesture {
				print("My Profile")
				navigationCoordinator.show(Text("My Profile"))
				menuClose()
			}
			
			ZStack {
				VStack(alignment: .leading) {
					HStack {
						Text("Posts")
						Spacer()
					}
					.frame(maxWidth: .infinity)
					.contentShape(Rectangle())
				}
				.frame(maxWidth: .infinity)
				.contentShape(Rectangle())
			}
			.contentShape(Rectangle())
			.listRowBackground(Color.teal)
			.frame(maxWidth: .infinity)
			.onTapGesture {
				print("Posts")
				navigationCoordinator.show(Text("Posts"))
				menuClose()
			}
			
			ZStack {
				VStack(alignment: .leading) {
					HStack {
						Text("Logout")
						Spacer()
					}
					.frame(maxWidth: .infinity)
					.contentShape(Rectangle())
				}
				.frame(maxWidth: .infinity)
				.contentShape(Rectangle())
			}
			.contentShape(Rectangle())
			.listRowBackground(Color.indigo)
			.frame(maxWidth: .infinity)
			.onTapGesture {
				print("Logout")
				navigationCoordinator.show(Text("Logout"))
				menuClose()
			}
		} // end list
	}
}

Now onto the last big piece of this puzzle. This is the actual Content View, or in this case the main view of the application. This is where we handle the gestures and the navigation view.

struct ContentView: View {
	
	@State var coordinator: NavigationCoordinator = NavigationCoordinator()
	public init(@ViewBuilder content: () -> AnyView) {
		coordinator.show(content())
	}
	public init() {
		coordinator.show(EmptyView())
	}

	@State var menuOpen: Bool = false
	
	var body: some View {
		ZStack {
			NavigationView {
				HStack {
					coordinator.screen
				}
				.navigationTitle("Add Location")
				.navigationViewStyle(.automatic)
				.navigationBarTitleDisplayMode(.inline)
				.toolbar {
					ToolbarItem(placement: .navigationBarLeading) {
						Button(action: {
							openMenu()
						}) {
							Image(systemName: "line.3.horizontal")
						}
					}
				} // end toolbar
			}
			.navigationViewStyle(.stack)
			
			SideMenu(
				width: 270,
				isOpen: menuOpen,
				menuClose: openMenu,
				navigationCoordinator: coordinator)
		}
		.gesture(
			DragGesture(minimumDistance: 60)
			.onEnded { drag in
                // TODO: fix the gestures that are supposed to be ignored.
				let direction = drag.predictedEndLocation.x > drag.startLocation.x ? DragDirection.right : DragDirection.left
				switch direction {
				case .left:
					self.menuOpen = false
				case .right:
					self.menuOpen = true
				case .none:
					break
				}
			}
		) // end gesture
	}
	
	func openMenu() {
		self.menuOpen.toggle()
	}
}

Hope someone finds this useful and helps them build some awesome applications.