Render long, scrolling lists efficiently with LazyColumn, and move between screens with Navigation Compose — defining a NavHost of routes and passing arguments, the backbone of a multi-screen app.
A plain Column renders all its children at once, which is fine for a few items but disastrous for a long list. LazyColumn (and LazyRow) only composes the items currently visible, recycling them as you scroll — the Compose equivalent of RecyclerView. Any scrolling list of data uses LazyColumn with an items block, which is one of the most common Compose patterns.
@Composable
fun MessageList(messages: List<Message>) {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(messages, key = { it.id }) { message -> // only visible items compose
MessageCard(message)
}
}
}
// a plain Column would build ALL rows up front (janky for long lists).
// LazyColumn composes only what's on screen + recycles as you scroll.
// the 'key' helps Compose track items across updates (like list keys in React).Navigation Compose models your app’s screens as a graph. A NavHost maps route strings to composable screens, and a NavController moves between them, managing the back stack. Instead of Activities-and-Intents for every screen, a single-Activity app navigates between composable destinations by route — the modern Android navigation approach.
@Composable
fun AppNav() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "home") {
composable("home") {
HomeScreen(onOpenProfile = { navController.navigate("profile") })
}
composable("profile") {
ProfileScreen(onBack = { navController.popBackStack() })
}
}
}
// one Activity, many composable destinations. navController.navigate("route")
// moves forward; popBackStack() goes back. The NavHost owns the back stack.Routes can include arguments — an item id, a username — encoded in the route path. You declare the argument in the route pattern, read it from the back stack entry in the destination, and navigate with the concrete value substituted in. This is how a list screen opens a detail screen for a specific item, the everyday multi-screen flow.
NavHost(navController, startDestination = "list") {
composable("list") {
ListScreen(onItemClick = { id -> navController.navigate("detail/$id") })
}
// declare an argument in the route pattern
composable("detail/{itemId}") { backStackEntry ->
val itemId = backStackEntry.arguments?.getString("itemId")
DetailScreen(itemId = itemId)
}
}
// list -> "detail/42" -> DetailScreen reads itemId = "42". Standard master/detail.Apps with top-level sections use a bottom navigation bar, where each tab is a destination and you preserve each tab’s state as the user switches. Navigation Compose supports this with a bottom bar driving the NavController, plus nested navigation graphs to group related screens. This structures larger apps into clear, independently navigable sections.
@Composable
fun MainScaffold() {
val nav = rememberNavController()
Scaffold(
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = /* current == home */ true,
onClick = { nav.navigate("home") { launchSingleTop = true } },
icon = { Icon(Icons.Default.Home, null) }, label = { Text("Home") }
)
// ... more tabs (search, profile)
}
}
) { padding ->
NavHost(nav, startDestination = "home", modifier = Modifier.padding(padding)) {
composable("home") { HomeScreen() }
// group related screens in a nested navigation graph if needed
}
}
}