CPS251 Android Development by Scott Shaper

Organizing Navigation with Multiple Files

What We Will Cover

This lesson covers how to organize an Android app across multiple files and how to manage different kinds of data between screens. You will see a recommended package structure, how to separate navigation from UI and business logic, and how screen-specific state and shared state work together.

Why Separate Files?

Keeping all code in a single file makes an app hard to navigate, debug, and extend. Splitting code into multiple files by responsibility improves clarity and maintainability. Benefits include:

Recommended File Structure

Below is a typical structure for a Compose app that uses navigation and ViewModels. Routes and navigation logic live in one place; each screen has its own package and optional ViewModel.

app/
├── src/
│   ├── main/
│   │   ├── java/
│   │   │   └── com.example.myapp/
│   │   │       ├── MainActivity.kt              // App entry point
│   │   │       ├── navigation/                  // Navigation graph and routes
│   │   │       │   ├── AppNavigation.kt         // NavHost and composable destinations
│   │   │       │   └── NavigationRoutes.kt      // Route constants
│   │   │       ├── viewmodels/                  // State and business logic
│   │   │       │   ├── shared/
│   │   │       │   │   └── SharedViewModel.kt   // State shared across screens
│   │   │       │   └── screens/
│   │   │       │       ├── HomeViewModel.kt     // Home screen state
│   │   │       │       ├── ProfileViewModel.kt  // Profile screen state
│   │   │       │       └── SettingsViewModel.kt // Settings screen state
│   │   │       └── screens/                     // UI composables per screen
│   │   │           ├── home/
│   │   │           │   └── HomeScreen.kt
│   │   │           ├── profile/
│   │   │           │   └── ProfileScreen.kt
│   │   │           └── settings/
│   │   │               └── SettingsScreen.kt

Understanding the Structure

Required Gradle Dependencies

As discussed earlier in the navigation chapter, add the following dependencies to your project.

NOTE: The lifecycle-viewmodel-compose dependency is also required for ViewModels to work.

Update Version Catalog (gradle/libs.versions.toml)

[versions]
    # ... existing versions ...
    navigation-compose = "2.7.7"
    lifecycle-viewmodel-compose = "2.7.0"
    
    [libraries]
    # ... existing libraries ...
    androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation-compose" }
    androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle-viewmodel-compose" }

Update App Dependencies (app/build.gradle.kts)

dependencies {
    // ... existing dependencies ...
    
    // ViewModels and Navigation
    implementation(libs.androidx.lifecycle.viewmodel.compose)
    implementation(libs.androidx.navigation.compose)
}

Understanding the Dependencies

Understanding Different Types of Data Management

In a multi-screen app you need to decide where state lives and how it is passed. Three common approaches are: screen-specific ViewModels (state for one screen), shared ViewModels (state used by several screens), and navigation arguments (data passed when navigating to a screen).

Screen-Specific ViewModels

Use a ViewModel scoped to one screen when the state is only needed on that screen (e.g. form fields, loading flag, or data loaded for that screen only):

// viewmodels/screens/HomeViewModel.kt
class HomeViewModel : ViewModel() {
    var screenTitle by mutableStateOf("Home")
        private set
    
    fun updateTitle(newTitle: String) {
        screenTitle = newTitle
    }
}

// screens/home/HomeScreen.kt
@Composable
fun HomeScreen(
    homeViewModel: HomeViewModel = viewModel(),
    onProfileClick: (String) -> Unit,
    onSettingsClick: () -> Unit
) {
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(text = homeViewModel.screenTitle)
        Button(onClick = { onProfileClick("Scott Shaper") }) {
            Text("Go to Profile")
        }
        Button(onClick = onSettingsClick) {
            Text("Go to Settings")
        }
    }
}

Shared ViewModels

Use a shared ViewModel when multiple screens need to read or update the same state (e.g. current user, app-wide settings):

// viewmodels/shared/SharedViewModel.kt
class SharedViewModel : ViewModel() {
    var currentUser by mutableStateOf(null)
        private set
    
    fun updateUser(user: User) {
        currentUser = user
    }
    
    fun clearUser() {
        currentUser = null
    }
}

Navigation Parameters

Use navigation arguments when you need to pass a specific value to the destination screen (e.g. user id, item id). The value is read from the route or back stack entry when the screen is shown:

// When navigating from Home to Profile
onProfileClick("Scott Shaper")  // Passing a specific name

// The Profile screen receives this name
@Composable
fun ProfileScreen(
    userId: String = "Unknown",  // Receiving the passed name
    // ... other parameters
) {
    // Use the userId to load the right profile
    LaunchedEffect(userId) {
        profileViewModel.loadProfile(userId)
    }
}

When to Use Each Approach

Type When to Use Example
Screen-Specific ViewModels State used only on one screen Form state, screen-specific loading/data
Shared ViewModels State needed on multiple screens Current user, app settings, cart
Navigation Parameters Pass a value when navigating to a screen User id, product id, search query

Complete Example Implementation

The following example ties together the file structure, navigation, and the three data approaches: a Home screen, a Profile screen (with a navigation argument), and a Settings screen, using both screen-specific and shared ViewModels.

1. Define Routes (NavigationRoutes.kt)

Put all your route names in one place so the rest of the app can reuse them and you avoid typos.

// navigation/NavigationRoutes.kt
object NavigationRoutes {
    const val HOME = "home"
    const val PROFILE = "profile/{userId}"     // userId is a path argument
    const val SETTINGS = "settings"
}

What each part does:

2. Create ViewModels

We use one shared ViewModel (for data that multiple screens need) and one ViewModel per screen where we need screen-only state.

SharedViewModel — Stores the currently logged-in user. Any screen can read or update it.

// viewmodels/shared/SharedViewModel.kt
class SharedViewModel : ViewModel() {
    var currentUser by mutableStateOf(null)
        private set

    fun updateUser(user: User) {
        currentUser = user
    }

    fun clearUser() {
        currentUser = null
    }
}

HomeViewModel — State that only the Home screen cares about.

// viewmodels/screens/HomeViewModel.kt
class HomeViewModel : ViewModel() {
    var screenTitle by mutableStateOf("Home")
        private set
    var isLoading by mutableStateOf(false)
        private set

    fun updateTitle(newTitle: String) {
        screenTitle = newTitle
    }

    fun loadHomeData() {
        isLoading = true
        // Simulate loading data
        isLoading = false
    }
}

ProfileViewModel — State for the Profile screen. It loads profile data for a specific user.

// viewmodels/screens/ProfileViewModel.kt
class ProfileViewModel : ViewModel() {
    var profileData by mutableStateOf(null)
        private set
    var isLoading by mutableStateOf(false)
        private set

    fun loadProfile(userId: String) {
        isLoading = true
        // Simulate loading profile data for the specific user
        profileData = ProfileData(
            userId = userId,
            description = "Profile for $userId - This is a sample profile description."
        )
        isLoading = false
    }
}

3. Create Screen Composables

Each screen is a composable that receives the ViewModels and navigation callbacks it needs:

// screens/home/HomeScreen.kt
@Composable
fun HomeScreen(
    homeViewModel: HomeViewModel = viewModel(),
    sharedViewModel: SharedViewModel = viewModel(),
    onProfileClick: (String) -> Unit,
    onSettingsClick: () -> Unit
) {
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(text = homeViewModel.screenTitle)
        
        if (homeViewModel.isLoading) {
            CircularProgressIndicator()
        }
        
        // Show shared user information if available
        sharedViewModel.currentUser?.let { user ->
            Text("Welcome ${user.name}!")
            Text("Email: ${user.email}")
        }
        
        Button(onClick = { onProfileClick("Scott Shaper") }) {
            Text("Go to Profile")
        }
        Button(onClick = onSettingsClick) {
            Text("Go to Settings")
        }
    }
}

// screens/profile/ProfileScreen.kt
@Composable
fun ProfileScreen(
    userId: String = "Unknown",  // This receives the name passed from Home
    profileViewModel: ProfileViewModel = viewModel(),
    sharedViewModel: SharedViewModel = viewModel(),
    onHomeClick: () -> Unit,
    onSettingsClick: () -> Unit
) {
    // Load profile data for the specific user when the screen appears
    LaunchedEffect(userId) {
        profileViewModel.loadProfile(userId)
    }
    
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Profile Screen")
        
        if (profileViewModel.isLoading) {
            CircularProgressIndicator()
        } else {
            profileViewModel.profileData?.let { profile ->
                Text("User: ${profile.userId}")
                Text(profile.description)
            }
        }
        
        // Show shared user information
        sharedViewModel.currentUser?.let { user ->
            Text("Shared User: ${user.name}")
        }
        
        Button(onClick = onHomeClick) {
            Text("Go to Home")
        }
        Button(onClick = onSettingsClick) {
            Text("Go to Settings")
        }
    }
}

// screens/settings/SettingsScreen.kt
@Composable
fun SettingsScreen(
    sharedViewModel: SharedViewModel,
    onHomeClick: () -> Unit,
    onProfileClick: (String) -> Unit
) {
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Settings Screen")
        
        // Show current user information
        sharedViewModel.currentUser?.let { user ->
            Text("Current User: ${user.name}")
            Text("Email: ${user.email}")
        }
        
        // Button to login a sample user
        Button(
            onClick = {
                val sampleUser = User(
                    id = "1",
                    name = "John Doe",
                    email = "john.doe@example.com"
                )
                sharedViewModel.updateUser(sampleUser)
            }
        ) {
            Text("Login Sample User")
        }
        
        // Button to go to profile using shared user name
        Button(
            onClick = { 
                onProfileClick(sharedViewModel.currentUser?.name ?: "Unknown") 
            }
        ) {
            Text("Go to Profile")
        }
        
        Button(onClick = onHomeClick) {
            Text("Go to Home")
        }
    }
}

What each screen does (with key code):

4. Set Up Navigation (AppNavigation.kt)

AppNavigation defines the NavHost and wires each route to its screen, passing the shared ViewModel and extracting navigation arguments where needed:

@Composable
fun AppNavigation() {
    val navController = rememberNavController()
    val sharedViewModel: SharedViewModel = viewModel()
    
    NavHost(navController = navController, startDestination = NavigationRoutes.HOME) {
        // Home screen
        composable(NavigationRoutes.HOME) {
            HomeScreen(
                sharedViewModel = sharedViewModel,
                onProfileClick = { userName ->
                    // Navigate to profile with a specific user name
                    navController.navigate("profile/$userName")
                },
                onSettingsClick = {
                    navController.navigate(NavigationRoutes.SETTINGS)
                }
            )
        }
        
        // Profile screen with navigation parameter
        composable(
            route = NavigationRoutes.PROFILE,  // "profile/{userId}"
            arguments = listOf(
                navArgument("userId") { 
                    type = NavType.StringType  // The userId is a string
                }
            )
        ) { backStackEntry ->
            // Extract the userId from the navigation arguments
            val userId = backStackEntry.arguments?.getString("userId") ?: "Unknown"
            
            ProfileScreen(
                userId = userId,  // Pass the extracted userId to ProfileScreen
                sharedViewModel = sharedViewModel,
                onHomeClick = { navController.navigate(NavigationRoutes.HOME) },
                onSettingsClick = { navController.navigate(NavigationRoutes.SETTINGS) }
            )
        }
        
        // Settings screen
        composable(NavigationRoutes.SETTINGS) {
            SettingsScreen(
                sharedViewModel = sharedViewModel,
                onHomeClick = { navController.navigate(NavigationRoutes.HOME) },
                onProfileClick = { userName -> 
                    navController.navigate("profile/$userName") 
                }
            )
        }
    }
}

What each part does (with snippets):

5. Main Activity (MainActivity.kt)

The activity is where the app starts. It draws the Compose UI and puts the navigation graph at the root.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MaterialTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    AppNavigation()  // Start the navigation system
                }
            }
        }
    }
}

What this code does:

Understanding How Data Flows

Summary of how data moves in this example:

  1. Home → Profile:
    • HomeScreen calls onProfileClick("Scott Shaper").
    • Navigation builds the route "profile/Scott Shaper".
    • ProfileScreen receives userId = "Scott Shaper" from the back stack entry and loads profile data for that user.
  2. Home → Settings:
    • HomeScreen calls onSettingsClick(); no arguments are passed.
    • SettingsScreen uses SharedViewModel to display the current user if one is set.
  3. Settings → Profile:
    • SettingsScreen can call onProfileClick(sharedViewModel.currentUser?.name). If the current user is "John Doe", the route becomes "profile/John Doe".
    • ProfileScreen receives that userId and loads the corresponding profile.

How the Example Renders

You can try the full example by downloading the NavState project and opening it in Android Studio.

Home screen when the app first loads:

Home Screen

Profile screen when navigating from Home with the user name "Scott Shaper" passed as an argument:

Home to Profile

Settings screen when navigating from Home. The user name is not passed to Settings; only the Profile route receives the "Scott Shaper" argument. Settings does not need the user name unless it uses the shared ViewModel.

Home to Settings

Profile screen when navigating from Settings before any user is logged in. The profile shows "Unknown" because nothing has been set in the shared ViewModel yet.

Settings to Profile

Settings screen after the login button is clicked:

Settings to Login

Profile screen when navigating from Settings after login. The profile shows "John Doe" because Settings updated the shared ViewModel with the logged-in user, and the navigation callback passed that user name to the profile route.

Settings to Profile Login

Home screen after login. Home displays "John Doe" from the shared ViewModel. The same would appear whether you navigated to Home from Profile or from Settings.

Home to Settings Login

Profile screen after the reload button is clicked. The profile shows "ReloadedUser" and "John Doe": the profile screen updated its local profile data but did not change the shared ViewModel.

Profile Reload

Home screen again after the profile reload. Home still shows the same information as before because it reads from the shared ViewModel, which was not modified by the profile screen.

Home to Settings Login

Benefits of This Structure

Best Practices

Common Mistakes to Avoid

Tips for Success