Cpapter 5: Activities, Navigation, and App Structure
Activities, Navigation, and App Structure! This is a big one — we’re moving from single-screen “hello world” stuff to real multi-screen apps, like how Instagram switches from home feed to profile to settings.
In 2026, modern Android apps use single-activity architecture + Jetpack Navigation Compose (not the old fragment-based one). One main Activity hosts a NavHost that swaps composable screens. No more multiple Activities for every screen — it’s cleaner, faster, and easier to manage state.
We’ll cover:
- Activity lifecycle (still super important, even in single-activity apps)
- Setting up Navigation Component with Compose
- Passing data safely between screens
- Hands-on: A multi-screen Tip Calculator app (input bill → calculate tip → show result on second screen)
Let’s go step-by-step like we’re coding together on your machine in Airoli.
1. Activity Lifecycle (The Life Story of Your App’s Main Screen)
Even with Compose and single-activity, your MainActivity goes through a full lifecycle. Understanding this prevents bugs like losing data on rotation or when user switches apps.
Key states & callbacks (same as always — no big changes in Android 16):
- Created → onCreate(savedInstanceState: Bundle?)
- First birth (or rebirth after rotation/process death).
- Do one-time setup: setContent { … }, initialize ViewModels.
- Restore state if savedInstanceState != null.
- Call super.onCreate() first!
- Started → onStart()
- Becoming visible (but not interactive yet).
- Resumed → onResume()
- Foreground & interactive! Start animations, location, etc.
- User can tap now.
- Paused → onPause()
- Partially obscured (dialog, incoming call, multi-window).
- Pause heavy stuff (camera, sensors). Quick — no heavy work!
- Stopped → onStop()
- Not visible at all (another app full-screen).
- Release resources, save data if needed.
- Destroyed → onDestroy()
- Killed (back pressed, system kill, rotation).
- Clean up final resources.
- Restart → onRestart() (rarely used — between Stopped → Started)
Common scenarios:
- Screen rotation → Destroy old → Create new (use ViewModel to survive).
- Process death (low memory) → Recreate from saved state.
- Back press → popBackStack() in NavController (handled by Navigation lib usually).
Best practice in Compose:
- Don’t put logic in Activity callbacks — use ViewModel + Lifecycle-aware stuff.
- For UI state: Use rememberSaveable or ViewModel.
- Save persistent data in onStop() or repository.
Analogy: Think of Activity as a person:
- Born (onCreate) → Grow up (onStart) → Active life (onResume) → Take a nap (onPause) → Sleep (onStop) → Wake up (onRestart) → Die (onDestroy).
2. Navigation Component with Multiple Screens (Jetpack Navigation Compose)
We use androidx.navigation:navigation-compose (latest stable ~2.9.x in 2026).
Why single-activity?
- Faster (no Activity creation overhead)
- Easier state sharing (one ViewModel scope)
- Better for adaptive UIs (foldables, tablets)
Setup steps:
-
Add dependency (in app/build.gradle.kts):
Kotlin01234567val nav_version = "2.9.6" // or latest from docsimplementation("androidx.navigation:navigation-compose:$nav_version") -
Create a sealed class or @Serializable for routes (type-safe!): Use Kotlinx Serialization (add implementation(“org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1”) and plugin).
In a new file Navigation.kt:
Kotlin0123456789101112import kotlinx.serialization.Serializable@Serializabledata object Home@Serializabledata class TipResult(val billAmount: Double, val tipPercent: Int, val tipAmount: Double, val total: Double) -
Remember NavController in your root composable.
-
Set up NavHost in MainActivity.kt → setContent:
Kotlin01234567891011121314151617181920212223242526272829303132class MainActivity : ComponentActivity() {override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContent {HelloWeblianceTheme {Surface(modifier = Modifier.fillMaxSize()) {val navController = rememberNavController()NavHost(navController = navController, startDestination = Home) {composable<Home> {HomeScreen(navController)}composable<TipResult> { backStackEntry ->val args = backStackEntry.toRoute<TipResult>()ResultScreen(bill = args.billAmount,tipPercent = args.tipPercent,tip = args.tipAmount,total = args.total,onBack = { navController.popBackStack() })}}}}}}}- rememberNavController() → Survives recomposition.
- composable<Destination> → Type-safe route.
- toRoute<>() → Gets arguments safely.
3. Passing Data Between Screens
Best way in 2026: Pass minimal data (IDs or primitives) via route args. Fetch heavy data from ViewModel/Repository.
- Simple types: String, Int, Double — fine in route.
- Complex: Use @Serializable data class (like TipResult above).
- Avoid passing large objects or Parcelable directly — use IDs + shared ViewModel or repository.
In code:
- Navigate: navController.navigate(TipResult(100.0, 15, 15.0, 115.0))
- Receive: backStackEntry.toRoute<TipResult>()
Pro tip: For shared state across screens, use a shared ViewModel scoped to the NavGraph.
4. Hands-on: Multi-Screen Tip Calculator App
Let’s build it! (Extend your existing project)
Screen 1: Home/Input
- Bill amount TextField
- Tip % Slider (0-30%)
- Calculate button → Navigate to result
Screen 2: Result
- Shows bill, tip %, tip amount, total
- Back button
Step-by-step code:
- HomeScreen.kt (new file or in MainActivity):
Kotlin012345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152@Composablefun HomeScreen(navController: NavController) {var billText by remember { mutableStateOf("") }var tipPercent by remember { mutableIntStateOf(15) }var isRounded by remember { mutableStateOf(true) }Column(modifier = Modifier.fillMaxSize().padding(24.dp),horizontalAlignment = Alignment.CenterHorizontally,verticalArrangement = Arrangement.spacedBy(16.dp)) {Text("Tip Calculator", style = MaterialTheme.typography.headlineMedium)OutlinedTextField(value = billText,onValueChange = { billText = it },label = { Text("Bill Amount (₹)") },keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal))Text("Tip Percentage: $tipPercent%")Slider(value = tipPercent.toFloat(),onValueChange = { tipPercent = it.toInt() },valueRange = 0f..30f,steps = 29)Button(onClick = {val bill = billText.toDoubleOrNull() ?: 0.0val tip = bill * (tipPercent / 100.0)val finalTip = if (isRounded) tip.roundToInt().toDouble() else tipval total = bill + finalTipnavController.navigate(TipResult(bill, tipPercent, finalTip, total))}) {Text("Calculate Tip")}Row(verticalAlignment = Alignment.CenterVertically) {Checkbox(checked = isRounded, onCheckedChange = { isRounded = it })Text("Round tip to nearest ₹")}}}
- ResultScreen.kt:
Kotlin0123456789101112131415161718192021222324252627282930313233343536@Composablefun ResultScreen(bill: Double,tipPercent: Int,tip: Double,total: Double,onBack: () -> Unit) {Column(modifier = Modifier.fillMaxSize().padding(24.dp),horizontalAlignment = Alignment.CenterHorizontally,verticalArrangement = Arrangement.Center) {Text("Tip Breakdown", style = MaterialTheme.typography.headlineMedium)Spacer(Modifier.height(32.dp))Text("Bill: ₹${"%.2f".format(bill)}")Text("Tip %: $tipPercent%")Text("Tip Amount: ₹${"%.2f".format(tip)}")Text("Total to Pay: ₹${"%.2f".format(total)}", fontWeight = FontWeight.Bold, fontSize = 24.sp)Spacer(Modifier.height(48.dp))Button(onClick = onBack) {Text("Go Back")}}}
- Run it!
- Enter bill (e.g., 500), slide tip to 20%, calculate → See result screen.
- Rotate screen → State survives thanks to remember + Navigation.
- Press back → Returns to input (NavController handles it).
Enhancements to try:
- Add validation (if bill empty → show error).
- Use shared ViewModel for bill data.
- Add icons (use Icon composable).
You’ve built a real multi-screen app! No XML fragments, pure Compose + type-safe navigation.
Questions? Error on toRoute? Want to add a third screen (history)? Or explain ViewModel integration deeper? Tell me — next chapter: State management & interactivity. You’re rocking this! 🚀💪
