Overview
Chapter uses a centralized navigation system built on SwiftUI’s NavigationStack with custom routing logic to handle deep links, push notifications, and programmatic navigation from anywhere in the app—including sheets and fullScreenCovers.
NavigationRouter Architecture
Core Router
The NavigationRouter (NavigationRouter.swift:21) is a singleton ObservableObject that manages all navigation state:
@MainActor
final class NavigationRouter : ObservableObject {
static let shared = NavigationRouter ()
// Main navigation path
@Published var navigationPath = NavigationPath ()
// Tab selection
@Published var selectedTab: TabSelectionUniPrepStage = . hub
// Tab bar visibility
@Published var showTabBar: Bool = true
// Pending navigation after modal dismiss
@Published private ( set ) var pendingNavigation: AppDestination ?
// Modal state tracking
@Published var isSheetPresented: Bool = false
@Published var isFullScreenCoverPresented: Bool = false
}
Navigation from Modals
The router handles a common SwiftUI challenge: navigating from sheets/fullScreenCovers. When navigation is triggered from a modal, the router:
Stores the destination as pendingNavigation
Dismisses the modal via registered closure
Executes the navigation after dismissal
func navigate ( to destination : AppDestination) {
// If we're in a modal context, dismiss and navigate
if isSheetPresented || isFullScreenCoverPresented {
pendingNavigation = destination
navPathCountBeforeModalNavigation = navigationPath. count
shouldReopenModal = true
// Dismiss the modal
dismissCurrentModal ? ()
} else {
// Direct navigation
navigationPath. append (destination)
}
}
Modal Re-opening Support
The router can reopen modals after navigating back, preserving the user’s context:
func checkForModalReopen () {
// If we navigated from a modal and popped back to the original count
if shouldReopenModal && navigationPath. count <= navPathCountBeforeModalNavigation {
shouldReopenModal = false
Task { @MainActor in
try ? await Task. sleep ( nanoseconds : 150_000_000 )
reopenModal ? ()
}
}
}
AppDestination Enum
All navigation destinations are defined in a single enum (DeepLinkManager.swift:14):
enum AppDestination : Hashable , Equatable {
case courseDetail (UUID)
case userProfile (GroupUser)
case groupProfile (GroupChat)
case chat (UUID)
case university ( Int )
case accommodationDetail (UUID)
case compare (CompareNavOptions)
case leaderboards (LeaderboardOptions)
case careerPaths
case inbox
// ... 100+ destinations
}
URL Generation
Each destination can generate a shareable deep link URL:
var url: URL ? {
switch self {
case . courseDetail ( let courseID) :
return URL ( string : "chapter://course/ \( courseID. uuidString ) " )
case . userProfile ( let user) :
return URL ( string : "chapter://profile/ \( user. id . uuidString ) " )
case . chat ( let groupID) :
return URL ( string : "chapter://chat/ \( groupID. uuidString ) " )
// ...
}
}
Deep Link Manager
The DeepLinkManager (DeepLinkManager.swift:493) handles incoming URLs and push notifications:
class DeepLinkManager : ObservableObject {
@Published var pendingDestination: AppDestination ?
static let shared = DeepLinkManager ()
func handle ( url : URL) {
guard let destination = parseURL (url) else { return }
pendingDestination = destination
}
func handle ( notification : UNNotificationContent) {
guard let destination = parseNotification (notification) else { return }
pendingDestination = destination
}
}
URL Parsing
Deep links follow the pattern chapter://[host]/[id]:
private func parseURL ( _ url : URL) -> AppDestination ? {
guard url.scheme == "chapter" ,
let host = url.host,
url.pathComponents. count >= 2 else { return nil }
let id = url. pathComponents [ 1 ]
switch host {
case "profile" :
if let user = fetchUser ( by : id) {
return . userProfile (user)
}
case "group" :
if let group = fetchGroup ( by : id) {
return . groupProfile (group)
}
case "course" :
if let uuid = UUID ( uuidString : id) {
return . courseDetail (uuid)
}
}
return nil
}
Master Tab View Integration
The MasterTabView (MasterTabView.swift:11) sets up the navigation stack and destination routing:
NavigationStack ( path : $navigationRouter. navigationPath ) {
tabContent
. environment (\. navigate , navigate)
. navigationDestination ( for : AppDestination. self ) { destination in
destinationView ( for : destination)
. environment (\. navigate , navigate)
. task {
withAnimation {
showTabBar = false
}
}
}
}
. onReceive (appContainer. deepLinkManager .$pendingDestination) { destination in
if let destination = destination {
navigate ( to : destination)
appContainer. deepLinkManager . pendingDestination = nil
}
}
Destination View Builder
A large switch statement maps destinations to SwiftUI views (MasterTabView.swift:643):
@ViewBuilder
private func destinationView ( for destination : AppDestination) -> some View {
switch destination {
case . userProfile ( let user) :
LazyView ( UserV2ProfileView ( user : user))
. navigationBarBackButtonHidden ()
case . courseDetail ( let courseID) :
LazyView ( CourseHingeProfile (
courseID : courseID,
compatibilityScore : nil ,
distance : nil
))
. navigationBarBackButtonHidden ()
case . chat ( let groupID) :
LazyView ( ChatView (
id : groupID,
fullScreen : true ,
background : LinearGradient ( colors : [colorScheme. getColor ()]),
backgroundOpacity : 1
))
// ... 100+ cases
}
}
Views are wrapped in LazyView to prevent premature initialization and improve navigation performance.
Environment-Based Navigation
The app provides navigation actions via SwiftUI environment values:
struct NavigationEnvironmentKey : EnvironmentKey {
static let defaultValue: (AppDestination) -> Void = { _ in }
}
extension EnvironmentValues {
var navigate: (AppDestination) -> Void {
get { self [NavigationEnvironmentKey. self ] }
set { self [NavigationEnvironmentKey. self ] = newValue }
}
}
Usage in views:
@Environment (\. navigate ) var navigate
Button ( "View Course" ) {
navigate (. courseDetail (courseId))
}
Tab Switching
Tabs are managed through environment injection:
extension EnvironmentValues {
var switchTab: (TabSelectionUniPrepStage) -> Void {
get { self [TabSwitcherKey. self ] }
set { self [TabSwitcherKey. self ] = newValue }
}
}
Tab enum definition:
enum TabSelectionUniPrepStage : String , CaseIterable {
case hub
case search
case map
case lists
case calendar
case feed
case connect
case arcade
}
Router-Aware Modal Modifiers
Custom view modifiers ensure modals work correctly with the router (NavigationRouter.swift:192):
struct RouterAwareSheet < SheetContent : View >: ViewModifier {
@EnvironmentObject private var router: NavigationRouter
@Binding var isPresented: Bool
let sheetContent: () -> SheetContent
func body ( content : Content) -> some View {
content
. sheet ( isPresented : $isPresented) {
router. isSheetPresented = false
router. executePendingNavigation ()
} content : {
sheetContent ()
. environmentObject (router)
. onAppear {
router. isSheetPresented = true
router. registerModalHandlers (
dismiss : { isPresented = false },
reopen : { isPresented = true }
)
}
}
}
}
extension View {
func routerSheet < Content : View >(
isPresented : Binding< Bool >,
@ ViewBuilder content : @escaping () -> Content
) -> some View {
modifier ( RouterAwareSheet ( isPresented : isPresented, sheetContent : content))
}
}
Game-First Mode Navigation
When in game mode, tab switches translate to zone navigation:
func switchToTab ( _ tab : TabSelectionUniPrepStage) {
// In game-first mode, translate tab switches to game zone navigation
if GameManager.shared.isGameFirstMode {
withAnimation (. easeInOut ( duration : 0.2 )) {
selectedTab = tab
}
return
}
withAnimation (. easeInOut ( duration : 0.2 )) {
selectedTab = tab
}
}
Best Practices
Always use AppDestination enum
Never hardcode navigation strings. Always add new destinations to the AppDestination enum to maintain type safety.
Handle modal navigation correctly
Use routerSheet and routerFullScreenCover modifiers instead of native .sheet() and .fullScreenCover() when navigation from the modal is needed.
Implement URL parsing for deep links
When adding a new destination, implement both the url computed property and parsing logic in DeepLinkManager.parseURL().
Use LazyView for destinations
Wrap destination views in LazyView to prevent premature initialization and improve performance.
Architecture Overview Learn about the overall app architecture
Data Models Understand the data structures passed between views