Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

60 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SideMenu

A highly customizable, gesture-driven side menu component for SwiftUI with full accessibility support.

iOS 17.0+ Swift 6.0+ MIT License

Features

  • Fluid Gesture - Velocity-based spring animations with rubber band effect at menu edges
  • Both Edges - Mount on the leading or trailing edge, or use DualSideMenuView for both at once
  • 3 Presentation Styles - Slide-in-over, slide-in-out, and slide-out (Threads-like) + custom layout
  • Gesture Control - Full-screen or edge-only drag activation with configurable sensitivity
  • Haptic Feedback - Configurable haptic feedback on open/close and rubber band limit
  • Visual Effects - Per-style blur, scale, and dim effects for natural transitions
  • Accessibility - Complete VoiceOver support with focus management and escape actions
  • Type Safe - Fully documented public API with Swift 6 concurrency support

Requirements

  • iOS 17.0+
  • Swift 6.0+
  • Xcode 16.0+

Installation

Swift Package Manager

Add SideMenu to your project via Xcode:

  1. File > Add Package Dependencies...
  2. Enter the repository URL: https://github.com/shima11/SideMenu
  3. Select the version you want to use

Or add it to your Package.swift file:

dependencies: [
    .package(url: "https://github.com/shima11/SideMenu", from: "0.1.0")
]

Quick Start

import SwiftUI
import SideMenu

struct ContentView: View {
    @State private var menuState = SideMenuState()

    var body: some View {
        SideMenuView(model: menuState) {
            // Side menu content
            List {
                Button("Home") { withAnimation { menuState.close() } }
                Button("Settings") { withAnimation { menuState.close() } }
            }
        } mainView: {
            NavigationStack {
                Text("Main Content")
                    .navigationTitle("Home")
                    .toolbar {
                        ToolbarItem(placement: .topBarLeading) {
                            Button("Menu", systemImage: "line.3.horizontal") {
                                withAnimation { menuState.toggle() }
                            }
                        }
                    }
            }
        }
    }
}

For a complete interactive example with all configuration options, check out the Demo app in the repository.

Menu Styles

slideInOver

Menu slides over the main content, which remains in place.

SideMenuConfiguration(
    menuStyle: .slideInOver(blur: 3, scale: 0.95, dimValue: 0.3)
)
Parameter Default Description
blur 2 Blur radius applied to main content
scale 1 Scale factor applied to main content
dimValue 0.2 Dim overlay opacity

slideInOut

Menu and main content slide together.

SideMenuConfiguration(
    menuStyle: .slideInOut(dimValue: 0.2)
)
Parameter Default Description
dimValue 0.2 Dim overlay opacity

slideOut

Main content slides out to reveal the menu underneath (like Meta Threads).

SideMenuConfiguration(
    menuStyle: .slideOut(scale: 0.9, dimValue: 0.2, backgroundColor: .systemBackground)
)
Parameter Default Description
scale 0.9 Scale factor for the menu
dimValue 0.2 Dim overlay opacity
backgroundColor nil Background color behind the menu during scale animation

custom

Fully custom layout with user-defined closures.

SideMenuConfiguration(
    menuStyle: .custom(
        dimValue: 0.2,
        sideMenuLayout: { context, menuView in
            // Custom menu layout
        },
        mainViewLayout: { context, mainView in
            // Custom main view layout
        }
    )
)

Configuration

SideMenuConfiguration

let config = SideMenuConfiguration(
    menuWidth: 0.7,
    menuStyle: .slideInOver(blur: 3, scale: 0.95, dimValue: 0.3),
    menuAnimation: .spring(duration: 0.4, bounce: 0.2),
    dragActivation: .edge(edgeWidth: 30),
    hapticStyle: .medium
)

SideMenuView(model: menuState, configuration: config) {
    MenuView()
} mainView: {
    MainContentView()
}
Property Type Default Description
menuWidth CGFloat 0.8 Width of menu as fraction of screen (0.0 to 1.0)
menuStyle MenuStyle .slideInOut() Presentation style
menuAnimation Animation .spring(duration: 0.4, bounce: 0.0) Animation curve for transitions
dragActivation MenuDragActivation .full() Drag activation area
hapticStyle FeedbackStyle? .medium Haptic feedback style (nil to disable)
edge MenuEdge .leading Which screen edge the menu appears from
velocityThreshold CGFloat 300 Minimum flick velocity (pt/s) to trigger open/close
rubberBandLimit CGFloat 40 Maximum rubber band displacement in points

Edge

Choose which screen edge the menu appears from. Drag direction, layout, and edge-only activation zone all flip automatically.

SideMenuConfiguration(edge: .trailing)
Value Description
.leading Menu appears from the leading edge (left in LTR, right in RTL). Default.
.trailing Menu appears from the trailing edge (right in LTR, left in RTL).

Drag Activation

Control how users can open the menu with gestures.

// Full-screen drag (default)
.full(startThreshold: 15, openCloseThreshold: 50, directionRatio: 1.5)

// Edge-only drag
.edge(edgeWidth: 24, startThreshold: 15, openCloseThreshold: 50, directionRatio: 1.5)
Parameter Default Description
edgeWidth 24 Width of edge drag area in points (edge only)
startThreshold 15 Minimum horizontal drag distance to start gesture
openCloseThreshold 50 Minimum drag distance to open/close
directionRatio 1.5 Required horizontal/vertical ratio (higher = stricter)

Programmatic Control

// Open the menu
withAnimation { menuState.open() }

// Close the menu
withAnimation { menuState.close() }

// Toggle the menu
withAnimation { menuState.toggle() }

// Check if menu is open
if menuState.isOpen { /* ... */ }

Dual Side Menu

DualSideMenuView mounts menus on both edges simultaneously with type-level mutual exclusion: at most one side can be open at a time, enforced by the state enum itself.

import SwiftUI
import SideMenu

struct ContentView: View {
    @State private var menuState = DualSideMenuState()

    var body: some View {
        DualSideMenuView(model: menuState) {
            // Leading menu
            LeadingMenu()
        } trailingMenu: {
            // Trailing menu
            TrailingMenu()
        } mainView: {
            NavigationStack {
                MainContent()
                    .toolbar {
                        ToolbarItem(placement: .topBarLeading) {
                            Button("Menu", systemImage: "line.3.horizontal") {
                                withAnimation { menuState.toggle(.leading) }
                            }
                        }
                        ToolbarItem(placement: .topBarTrailing) {
                            Button("Activity", systemImage: "bell") {
                                withAnimation { menuState.toggle(.trailing) }
                            }
                        }
                    }
            }
        }
    }
}

State

DualSideMenuState.currentState is case closed or case open(MenuEdge). Opening one side automatically replaces any other open side — both menus can never be open at the same time.

menuState.openLeading()       // .open(.leading)
menuState.openTrailing()      // .open(.trailing) — closes leading if it was open
menuState.toggle(.leading)    // toggle a specific side
menuState.close()             // .closed
menuState.isOpen              // any side open?
menuState.openEdge            // MenuEdge? — which side, if any

Configuration

DualSideMenuConfiguration mirrors SideMenuConfiguration but omits the edge field (both edges are always rendered).

Property Type Default Description
menuWidth CGFloat 0.8 Width of each menu as a fraction of screen (shared by both sides)
menuStyle MenuStyle .slideInOut() Presentation style. .custom is not supported and falls back to .slideInOver
menuAnimation Animation .spring(duration: 0.4, bounce: 0.0) Animation curve for transitions
dragActivation MenuDragActivation .full() Drag activation area. In .edge mode, leading edge opens leading and trailing edge opens trailing
hapticStyle FeedbackStyle? .medium Haptic feedback style (nil to disable)
velocityThreshold CGFloat 300 Minimum flick velocity (pt/s) to trigger open/close

Gesture Behavior

  • From .closed: drag right opens leading, drag left opens trailing. In .edge mode the start zone determines the side.
  • From .open(.leading): only the closing direction is accepted.
  • From .open(.trailing): only the closing direction is accepted.
  • Sides cannot be swapped within a single drag — release and re-drag to switch.

Accessibility

SideMenu includes comprehensive accessibility support:

  • VoiceOver - Proper focus management between menu and main content
  • Escape Action - VoiceOver users can close the menu with escape gesture
  • Modal Trait - Menu is announced as modal when open
  • Focus Management - Automatic focus transfer when menu opens/closes

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/AmazingFeature)
  3. Commit your changes (git commit -m 'Add some AmazingFeature')
  4. Push to the branch (git push origin feature/AmazingFeature)
  5. Open a Pull Request

License

This project is licensed under the MIT License - see the LICENSE file for details.

Credits

Implementation inspired by this Medium article.

About

A highly customizable, gesture-driven side menu component for SwiftUI with full accessibility support.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages