Project 4: Quiz App with Navigation Compose

Android Development with Kotlin: 21 Real Projects · Building Real Apps · 8 min read · Updated

Navigation Compose

  1. The app opens on the home route.
  2. Start navigates to the quiz route. The back stack now holds home and quiz.
  3. At the end the quiz sends the score as route arguments.
  4. popUpTo removes the quiz from the stack, so Back does not return to it.

Almost every real app has more than one screen. A shopping app has a list, a product page, a cart and a checkout. In Compose you do not create many Activities for this. You keep one Activity and use Navigation Compose, a library that swaps which composable is shown and keeps a back stack, so the system Back button works as users expect.

Concepts in this project

  • NavController: remembers where you are and moves between screens.
  • NavHost: the place on screen where the current destination is drawn. It lists all the routes.
  • Routes with arguments: a route such as result/{score}/{total} carries values to the next screen.
  • rememberSaveable: keeps the quiz position through rotation.
  • Back stack: after the result screen you pop back to home so Back does not return to the last question.

Create the project

  1. New Empty Activity project QuizApp, package com.example.quizapp.
  2. Add the Navigation Compose line to the dependencies and sync.
  3. Create Questions.kt and QuizApp.kt, and replace MainActivity.kt.

`app/build.gradle.kts (dependencies)`

// app/build.gradle.kts  -> dependencies { ... }
// A new "Empty Activity" project already has Compose, Material 3 and the activity library.
// Projects 2 to 4 need these extra lines:
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")   // viewModel() in Compose
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")     // collectAsStateWithLifecycle()
implementation("androidx.navigation:navigation-compose:2.8.5")           // project 4 only
implementation("androidx.compose.material:material-icons-core")          // Add and Delete icons (version comes from the Compose BOM)
QuizApp/app/src/main/java/com/example/quizapp/
  MainActivity.kt   starts the app
  Questions.kt      the quiz data
  QuizApp.kt        NavHost and the three screens

The code

`app/src/main/java/com/example/quizapp/Questions.kt`

package com.example.quiz

data class Question(val text: String, val options: List<String>, val answerIndex: Int)

val questions = listOf(
    Question("Which language is the main language for Android apps today?", listOf("Kotlin", "COBOL", "Swift"), 0),
    Question("What do you call a function that draws UI in Jetpack Compose?", listOf("A composable", "A servlet", "A layout file"), 0),
    Question("Which keyword makes a value that cannot be reassigned?", listOf("var", "val", "mut"), 1),
    Question("What does the safe-call operator ?. do?", listOf("Stops when the value is null instead of crashing", "Deletes a file", "Starts a coroutine"), 0),
    Question("Which tool turns your code into an installable Android app file?", listOf("Gradle", "Photoshop", "Excel"), 0),
)

`app/src/main/java/com/example/quizapp/QuizApp.kt`

package com.example.quiz

import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument

@Composable
fun QuizApp() {
    val nav = rememberNavController()
    NavHost(navController = nav, startDestination = "home") {
        composable("home") {
            HomeScreen(onStart = { nav.navigate("quiz") })
        }
        composable("quiz") {
            QuizScreen(onFinished = { score ->
                // Put the score in the route, and drop the quiz screen from the back stack.
                nav.navigate("result/$score/${questions.size}") { popUpTo("home") }
            })
        }
        composable(
            route = "result/{score}/{total}",
            arguments = listOf(
                navArgument("score") { type = NavType.IntType },
                navArgument("total") { type = NavType.IntType }
            )
        ) { entry ->
            val score = entry.arguments?.getInt("score") ?: 0
            val total = entry.arguments?.getInt("total") ?: 0
            ResultScreen(score, total, onAgain = { nav.navigate("quiz") { popUpTo("home") } })
        }
    }
}

@Composable
fun HomeScreen(onStart: () -> Unit) {
    Column(
        Modifier.fillMaxSize().systemBarsPadding().padding(24.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Android Quiz", style = MaterialTheme.typography.displaySmall)
        Spacer(Modifier.height(8.dp))
        Text("${questions.size} questions. Pick one answer for each.")
        Spacer(Modifier.height(24.dp))
        Button(onClick = onStart) { Text("Start") }
    }
}

@Composable
fun QuizScreen(onFinished: (Int) -> Unit) {
    // rememberSaveable survives screen rotation; plain remember does not.
    var index by rememberSaveable { mutableIntStateOf(0) }
    var score by rememberSaveable { mutableIntStateOf(0) }
    var picked by rememberSaveable { mutableStateOf<Int?>(null) }

    val q = questions[index]
    Column(Modifier.fillMaxSize().systemBarsPadding().padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
        LinearProgressIndicator(progress = { (index + 1) / questions.size.toFloat() }, modifier = Modifier.fillMaxWidth())
        Text("Question ${index + 1} of ${questions.size}")
        Text(q.text, style = MaterialTheme.typography.titleLarge)

        q.options.forEachIndexed { i, option ->
            val colors = when {
                picked == null -> ButtonDefaults.outlinedButtonColors()
                i == q.answerIndex -> ButtonDefaults.outlinedButtonColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
                i == picked -> ButtonDefaults.outlinedButtonColors(containerColor = MaterialTheme.colorScheme.errorContainer)
                else -> ButtonDefaults.outlinedButtonColors()
            }
            OutlinedButton(
                onClick = { if (picked == null) { picked = i; if (i == q.answerIndex) score++ } },
                colors = colors,
                modifier = Modifier.fillMaxWidth()
            ) { Text(option) }
        }

        Spacer(Modifier.weight(1f))
        Button(
            onClick = {
                if (index == questions.lastIndex) onFinished(score) else { index++; picked = null }
            },
            enabled = picked != null,
            modifier = Modifier.fillMaxWidth()
        ) { Text(if (index == questions.lastIndex) "See result" else "Next") }
    }
}

@Composable
fun ResultScreen(score: Int, total: Int, onAgain: () -> Unit) {
    Column(
        Modifier.fillMaxSize().systemBarsPadding().padding(24.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("You scored", style = MaterialTheme.typography.titleMedium)
        Text("$score / $total", style = MaterialTheme.typography.displayLarge)
        Spacer(Modifier.height(24.dp))
        Button(onClick = onAgain) { Text("Try again") }
    }
}

`app/src/main/java/com/example/quizapp/MainActivity.kt`

package com.example.quiz

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent { MaterialTheme { Surface { QuizApp() } } }
    }
}

How it works

  • NavHost starts at home. Pressing Start calls navigate("quiz").
  • The quiz screen keeps index, score and the chosen answer in rememberSaveable, so rotating the phone does not restart the quiz.
  • After the last question it navigates to result/{score}/{total} and removes the quiz from the back stack with popUpTo.
  • The result screen reads the two numbers from the route arguments and offers Play again, which returns to home.

Expected output

The home screen shows the quiz title and a Start button. Each question screen shows Question 1 of N, the question and four answer buttons. After an answer is tapped, the correct one turns green and a wrong choice turns red, and a Next button appears. After the last question the result screen says, for example, "You scored 4 out of 5". Pressing Back on the result screen leaves the app rather than reopening the last question.

Your task

  1. Build the app and play through once, then rotate the phone mid-quiz.
  2. Add three more questions to Questions.kt.
  3. Show a message on the result screen that depends on the score, such as a different line for a perfect score.
  4. Add a Settings screen reachable from home and test that Back returns to home.

How to check it worked

  • Rotation keeps the current question and score.
  • Back from the result screen leaves the quiz instead of re-entering it.
  • You can explain what a route and a back stack are.

Frequently asked questions

Why use one Activity?

Compose navigation switches composables inside one Activity. It is lighter, shares one ViewModel store easily and animates between screens more simply than many Activities.

How do I pass a big object between screens?

Pass only an ID in the route and load the object from a ViewModel or database on the next screen. Large objects in routes break easily.

What does popUpTo do?

It removes screens from the back stack while navigating. It stops the user from going Back into a screen that no longer makes sense, like a finished quiz.