A gesture-driven side menu for SwiftUI with native toolbar and navigation support. Inspired by Claude iOS app.
- Smooth, interruptible swipe gestures to open and close
- Full support for SwiftUI
NavigationStackand.toolbarmodifiers - Smart gesture conflict resolution (scroll views, segmented controls, nested navigation)
- Automatic landscape pinning
- Optional
NavigationSplitViewfallback on regular size classes (iPad) - Haptic feedback, rounded corners, shadows — matches iOS system aesthetics
- Zero dependencies · Swift 6.0 · iOS 17+
Add ZSideMenu via Swift Package Manager:
https://github.com/xzebra/ZSideMenu.git
Or in Package.swift:
dependencies: [
.package(url: "https://github.com/xzebra/ZSideMenu.git", from: "1.0.0")
]import ZSideMenu
struct ContentView: View {
@State private var isMenuOpen = false
var body: some View {
ZSideMenuView(isOpen: $isMenuOpen) {
// Sidebar
List {
Button("Home") { isMenuOpen = false }
Button("Search") { isMenuOpen = false }
}
.navigationTitle("Menu")
} mainContent: {
// Main content
NavigationStack {
Text("Hello, world!")
.navigationTitle("Home")
.toolbar {
ToolbarItem(placement: .navigation) {
Button("Menu", systemImage: "line.horizontal.3") {
isMenuOpen.toggle()
}
}
}
}
}
}
}ZSideMenu works seamlessly with SwiftUI's toolbar system. Place toolbars in both the sidebar and the main content — they render natively, exactly as you'd expect.
ZSideMenuView(isOpen: $isMenuOpen) {
NavigationStack {
List {
ForEach(MenuItem.allCases) { item in
Label(item.title, systemImage: item.icon)
}
}
.navigationTitle("Menu")
.toolbar {
ToolbarItemGroup(placement: .bottomBar) {
Button("Create", systemImage: "plus") { /* … */ }
Spacer()
Button("Settings", systemImage: "gear") { /* … */ }
}
}
}
} mainContent: {
// …
}ZSideMenuView(isOpen: $isMenuOpen) {
// sidebar …
} mainContent: {
NavigationStack {
List { /* … */ }
.navigationTitle("Home")
.toolbar {
ToolbarItem(placement: .navigation) {
Button("Menu", systemImage: "line.horizontal.3") {
isMenuOpen.toggle()
}
}
ToolbarItem(placement: .primaryAction) {
Button("Add", systemImage: "plus") { /* … */ }
}
}
}
}Both the sidebar and main content support full NavigationStack navigation with push/pop. The gesture system automatically detects when a view is pushed onto the navigation stack and disables the sidebar swipe, so the system back-swipe works as expected.
ZSideMenuView(isOpen: $isMenuOpen) {
// sidebar …
} mainContent: {
NavigationStack {
List(items) { item in
NavigationLink(item.title) {
DetailView(item: item)
// Back swipe works here — sidebar gesture stays out of the way
}
}
.navigationTitle("Items")
.toolbar {
ToolbarItem(placement: .navigation) {
Button("Menu", systemImage: "line.horizontal.3") {
isMenuOpen.toggle()
}
}
}
}
}When useNativeSidebar is enabled, ZSideMenu automatically switches to NavigationSplitView on regular-width size classes (iPad, Mac Catalyst), giving users the platform-native experience:
ZSideMenuView(
isOpen: $isMenuOpen,
useNativeSidebar: true
) {
// sidebar …
} mainContent: {
// main content …
}On compact size classes (iPhone), the custom swipe-driven menu is always used.
ZSideMenuView(
isOpen: $isMenuOpen,
animation: .spring(duration: 0.35, bounce: 0.15)
) {
// …
}ZSideMenuView(
isOpen: $isMenuOpen,
pinSidebarInLandscape: false
) {
// Sidebar will slide in/out even in landscape
}Apply a .background() modifier directly on ZSideMenuView. The sidebar's navigation background is automatically cleared, so your custom background shows through:
ZSideMenuView(isOpen: $isMenuOpen) {
SidebarView()
} mainContent: {
MainView()
}
.background(Color.gray) // sidebar backgroundThe main content has its own NavigationStack, so set the background inside that stack (e.g. on the root view or via .toolbarBackground):
ZSideMenuView(isOpen: $isMenuOpen) {
SidebarView()
} mainContent: {
NavigationStack {
List { /* … */ }
.scrollContentBackground(.hidden) // hide default List background
.background(Color.red) // main content background
}
}A complete example app is included in the Example/ directory. It demonstrates:
- Tab-based sidebar with selection state
- Toolbar items in both sidebar and main content
- Nested navigation with push/pop
- Custom toolbar title styling
import SwiftUI
import ZSideMenu
struct ContentView: View {
@State private var isMenuOpen = false
@State private var selectedTab: MenuItem = .home
var body: some View {
ZSideMenuView(isOpen: $isMenuOpen) {
SidebarView(isMenuOpen: $isMenuOpen, selectedTab: $selectedTab)
} mainContent: {
MainView(isMenuOpen: $isMenuOpen, selectedTab: $selectedTab)
}
}
}
struct SidebarView: View {
@Binding var isMenuOpen: Bool
@Binding var selectedTab: MenuItem
var body: some View {
NavigationStack {
List {
ForEach(MenuItem.allCases) { item in
Button {
selectedTab = item
isMenuOpen = false
} label: {
Label(item.title, systemImage: item.icon)
}
}
}
.navigationTitle("Menu")
.toolbar {
ToolbarItemGroup(placement: .bottomBar) {
Button("Create", systemImage: "plus") { }
Spacer()
Button("Settings", systemImage: "gear") { }
}
}
}
}
}
struct MainView: View {
@Binding var isMenuOpen: Bool
@Binding var selectedTab: MenuItem
var body: some View {
NavigationStack {
List {
Text("Content for \(selectedTab.title)")
}
.navigationTitle(selectedTab.title)
.toolbar {
ToolbarItem(placement: .navigation) {
Button("Menu", systemImage: "line.horizontal.3") {
isMenuOpen.toggle()
}
}
}
}
}
}- iOS 17.0+ / macCatalyst 17.0+
- Swift 6.0+
- Xcode 16+
MIT
