Skip to content

xzebra/ZSideMenu

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

41 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ZSideMenu

ZSideMenu

A gesture-driven side menu for SwiftUI with native toolbar and navigation support. Inspired by Claude iOS app.


Features

  • Smooth, interruptible swipe gestures to open and close
  • Full support for SwiftUI NavigationStack and .toolbar modifiers
  • Smart gesture conflict resolution (scroll views, segmented controls, nested navigation)
  • Automatic landscape pinning
  • Optional NavigationSplitView fallback on regular size classes (iPad)
  • Haptic feedback, rounded corners, shadows — matches iOS system aesthetics
  • Zero dependencies · Swift 6.0 · iOS 17+

Installation

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")
]

Quick Start

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()
                            }
                        }
                    }
            }
        }
    }
}

Native Toolbar Support

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.

Sidebar with toolbars

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: {
    // …
}

Main content with navigation bar

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") { /* … */ }
                }
            }
    }
}

Nested Navigation

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()
                }
            }
        }
    }
}

Configuration

Native sidebar on iPad

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.

Custom animation

ZSideMenuView(
    isOpen: $isMenuOpen,
    animation: .spring(duration: 0.35, bounce: 0.15)
) {
    // …
}

Disable landscape pinning

ZSideMenuView(
    isOpen: $isMenuOpen,
    pinSidebarInLandscape: false
) {
    // Sidebar will slide in/out even in landscape
}

Sidebar background

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 background

Main content background

The 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
    }
}

Full Example

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()
                    }
                }
            }
        }
    }
}

Requirements

  • iOS 17.0+ / macCatalyst 17.0+
  • Swift 6.0+
  • Xcode 16+

License

MIT

About

A gesture-driven side menu for SwiftUI with native toolbar and navigation support.

Topics

Resources

License

Stars

Watchers

Forks

Contributors

Languages