Type-safe, protocol-driven navigation framework for SwiftUI apps
BigTime is a reusable Swift Package that provides a robust navigation system for SwiftUI applications. It supports push navigation, sheet presentations, full-screen covers, and tab-based navigation with independent stacks per tab.
- âś… Type-safe routing using protocol-based enums
- âś… Push navigation with NavigationStack
- âś… Sheet presentations with customizable detents and drag indicators
- âś… Hierarchical sheets - sheets can present child sheets with parent-child tracking
- âś… Full-screen covers for immersive experiences
- âś… Tab-based navigation with isolated stacks per tab
- âś… Universal overlay with Router-level and TabRouter-level support
- âś… Screen view tracking with optional callbacks
- âś… Dismiss handlers for post-navigation actions
- âś… Built-in logging using OSLog
- âś… Swift 6.0 with full concurrency support
Add BigTime to your project via Xcode:
- File → Add Package Dependencies...
- Enter the repository URL
- Select the version/branch
- Add to your target
Or add it to your Package.swift:
dependencies: [
.package(url: "https://github.com/br3akzero/BigTime.git", from: "1.0.0")
],
targets: [
.target(
name: "YourApp",
dependencies: ["BigTime"]
)
]Create a Route enum that conforms to Routable:
import BigTime
import SwiftUI
enum Route: Routable {
case home
case profile
case settings
case detail(id: String)
}
// MARK: - Hashable
extension Route: Hashable {
static func == (lhs: Route, rhs: Route) -> Bool {
switch (lhs, rhs) {
case (.home, .home), (.profile, .profile), (.settings, .settings):
return true
case (.detail(let lID), .detail(let rID)):
return lID == rID
default:
return false
}
}
func hash(into hasher: inout Hasher) {
switch self {
case .home:
hasher.combine("home")
case .profile:
hasher.combine("profile")
case .settings:
hasher.combine("settings")
case .detail(let id):
hasher.combine("detail")
hasher.combine(id)
}
}
}
// MARK: - Identifiable
extension Route: Identifiable {
var id: UUID { UUID() }
}
// MARK: - CustomStringConvertible
extension Route: CustomStringConvertible {
var description: String {
switch self {
case .home: return "Home"
case .profile: return "Profile"
case .settings: return "Settings"
case .detail(let id): return "Detail(\(id))"
}
}
}
// MARK: - View
extension Route: View {
var body: some View {
switch self {
case .home:
HomeScreen()
case .profile:
ProfileScreen()
case .settings:
SettingsScreen()
case .detail(let id):
DetailScreen(id: id)
}
}
}import BigTime
import SwiftUI
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
RouterView<Route>(root: .home)
}
}
}import BigTime
import SwiftUI
struct HomeScreen: View {
@Environment(Router<Route>.self) private var router
var body: some View {
VStack {
Button("Go to Profile") {
router.push(.profile)
}
Button("Show Settings Sheet") {
router.sheet(.settings)
}
Button("Show Detail Full Screen") {
router.fullScreenCover(.detail(id: "123"))
}
}
}
}import BigTime
import SwiftUI
enum TabRoute: TabRoutable {
case home
case search
case profile
var rootRoute: Route {
switch self {
case .home: return .home
case .search: return .search
case .profile: return .profile
}
}
var title: String {
switch self {
case .home: return "Home"
case .search: return "Search"
case .profile: return "Profile"
}
}
var icon: String {
switch self {
case .home: return "house.fill"
case .search: return "magnifyingglass"
case .profile: return "person.circle"
}
}
}
// MARK: - Hashable, Identifiable, CaseIterable
extension TabRoute: Hashable, Identifiable, CaseIterable {
var id: String { title }
static var allCases: [TabRoute] {
[.home, .search, .profile]
}
}
// MARK: - CustomStringConvertible
extension TabRoute: CustomStringConvertible {
var description: String { title }
}import BigTime
import SwiftUI
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
TabRouterView<TabRoute>()
}
}
}struct HomeScreen: View {
@Environment(TabRouter<TabRoute>.self) private var tabRouter
@Environment(Router<Route>.self) private var router
var body: some View {
VStack {
Button("Switch to Search Tab") {
tabRouter.switchTab(to: .search)
}
Button("Push Detail in Current Tab") {
router.push(.detail(id: "abc"))
}
}
}
}Track screen views for analytics:
RouterView(root: .home) { screenName in
// Log to your analytics service
Analytics.track(screen: screenName)
}Execute code after modal dismissal:
router.sheet(.settings) {
// Refresh data after settings are dismissed
Task { await loadUserData() }
}Control sheet presentation sizes:
router.sheet(
.settings,
detents: [.medium, .large],
dragIndicator: .visible
)Provide a custom subsystem for logging:
let router = Router(
root: .home,
subsystem: "com.myapp.navigation"
)Present sheets from within sheets with automatic parent-child tracking. Simply use sheet() everywhere - the framework automatically detects if you're already in a sheet and creates a hierarchical presentation:
struct SettingsSheet: View {
@Environment(Router<Route>.self) private var router
var body: some View {
VStack {
Button("Show Privacy Settings") {
// Just use sheet() - it automatically becomes a child sheet
router.sheet(.privacySettings)
}
Button("Show Appearance Settings") {
router.sheet(
.appearanceSettings,
detents: [.medium],
dragIndicator: .visible
)
}
}
}
}
// Present the parent sheet
router.sheet(.settings)
// From within the settings sheet, present another sheet
// It automatically becomes a child sheet
router.sheet(.privacySettings)
// Dismiss the child sheet (returns to parent)
router.dismissSheet()
// Dismiss all sheets in the hierarchy at once
router.dismissAllSheets()Key Points:
- Use
sheet()everywhere - it automatically detects hierarchical presentation - If called when no sheet is present, it creates a new root sheet
- If called from within an existing sheet, it creates a child sheet
- Each child maintains its own detents, drag indicators, and dismiss handlers
dismissSheet()dismisses the topmost sheet and returns to its parentdismissAllSheets()dismisses the entire sheet hierarchy- All dismiss handlers are called in reverse order (child to parent)
Present persistent views that float above navigation content but below modals. Common use cases include mini-players, floating action buttons, or persistent banners.
Two overlay scopes:
- Router-level overlay - For standalone
RouterViewusage. Tied to a specific router. - TabRouter-level overlay - For
TabRouterViewusage. Persists across tab switches.
// Router-level overlay (dismissed when switching tabs)
router.universalOverlay(.miniPlayer(station))
router.dismissUniversalOverlay()
// TabRouter-level overlay (persists across tabs)
tabRouter.universalOverlay(.miniPlayer(station))
tabRouter.dismissUniversalOverlay()Mutual exclusion: Only one overlay can be active at a time. Presenting a TabRouter overlay automatically dismisses any Router overlay, and vice versa.
Layer order (bottom to top):
- NavigationStack / TabView (base content)
- Universal Overlay
- Sheets
- Full Screen Cover
Protocol that your Route enum must conform to:
Hashable- For NavigationStack pathIdentifiable- For SwiftUI list/forEachCustomStringConvertible- For loggingView- To render the route@MainActor- Must be used on the main actor only
Protocol that your TabRoute enum must conform to:
Hashable- For tab selectionIdentifiable- For SwiftUI tabsCustomStringConvertible- For loggingCaseIterable- To enumerate all tabsassociatedtype RouteType: Routable- The route type for this tab@MainActor- Must be used on the main actor only
Observable router managing navigation state:
Properties:
routes: [Route]- Navigation stackrootRoute: Route- Base routesheetStack: [SheetPresentation<Route>]- Stack of presented sheets (supports hierarchy)sheetRoute: Route?- Current sheet (computed from sheetStack)fullScreenCoverRoute: Route?- Current cover
Methods:
push(_ route: Route)- Push onto stackpop()- Pop from stackpopToRoot()- Clear stackswitchRoot(_ root: Route)- Change root routesheet(_ route: Route, detents:dragIndicator:onDismiss:)- Present sheet (auto-detects hierarchical presentation)fullScreenCover(_ route: Route, onDismiss:)- Present coverdismissSheet()- Dismiss topmost sheet (returns to parent if hierarchy exists)dismissAllSheets()- Dismiss all sheets in the hierarchydismissFullScreenCover()- Dismiss cover
Represents a single sheet in the presentation hierarchy:
Properties:
route: Route- The route being presenteddetents: Set<PresentationDetent>?- Presentation detentsdragIndicator: Visibility?- Drag indicator visibilityonDismiss: (() -> Void)?- Dismiss callback
Observable router managing tab navigation:
Properties:
selectedTab: TabRoute- Current tabrouters: [TabRoute: Router<TabRoute.RouteType>]- Per-tab routerscurrentRouter: Router<TabRoute.RouteType>- Router for selected tabuniversalOverlayRoute: TabRoute.RouteType?- Current overlay (persists across tabs)hasUniversalOverlay: Bool- Check if overlay is presented
Methods:
router(for tab: TabRoute)- Get router for specific tabswitchTab(to tab: TabRoute)- Switch to tabuniversalOverlay(_:animation:)- Present persistent overlaydismissUniversalOverlay(animation:)- Dismiss overlay
SwiftUI view managing navigation:
Initializers:
init(router: Router<Route>, onScreenView:)- Use existing routerinit(root: Route, subsystem:onScreenView:)- Create new router
SwiftUI view managing tab navigation:
Initializers:
init(tabRouter: TabRouter<TabRoute>, onScreenView:)- Use existing tab routerinit(selectedTab:subsystem:onScreenView:)- Create new tab router
- iOS 18.0+ / macOS 15.0+ / watchOS 11.0+ / tvOS 18.0+ / visionOS 2.0+
- Swift 6.0+
- Xcode 16.0+
MIT License - see LICENSE file for details.
Contributions are welcome! Please:
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
Created by @br3akzero
Built with using Swift 6.0 and SwiftUI