Your app will not only run on the phone in your pocket. People open it on a small budget phone, a large phone in landscape, a tablet, a foldable that opens like a book, a Chromebook, or a window that is dragged to half the screen. If your layout is designed for one width, it will look stretched and empty on a tablet and cramped on a small screen.
An adaptive app changes its layout to fit the space it is given. In this lesson you will learn the tools Android gives you: window size classes, adaptive navigation (a bottom bar on phones, a side rail or drawer on large screens) and a list-detail layout that shows two panes side by side when there is room. You will apply them to the official Unit 4 examples: the Reply email app and the Sports practice app, and plan the My City project.
This lesson covers six items of Pathway 3, Unit 4 of Google's Android Basics with Compose course: Adaptive layouts, Intro to Reply app, Build an adaptive app with dynamic navigation, Build an app with an adaptive layout, Practice: Build Sports app and Project: Create a My City app.
By the end you will be able to:
- Explain why apps must adapt, and what window size classes are.
- Read the current window size class and branch on it.
- Choose between bottom navigation bar, navigation rail and navigation drawer.
- Use
NavigationSuiteScaffoldto switch the navigation type automatically. - Build a list-detail layout that shows one pane on a phone and two panes on a tablet.
- Test your layout on different emulators and with the resizable emulator.
Big idea: do not ask "is this a tablet?". Ask "how much room does my window have right now?". The window can change at any time.
- Navigation (lesson 26). You will move between destinations.
- ViewModel and UI state (lessons 24 and 25). Adaptive layouts depend on state that survives window changes.
- Lazy lists (lesson 21). List-detail screens use them.
- Rotation is a configuration change (lesson 23). Window size changes, such as unfolding a phone or dragging a split-screen divider, are configuration changes too, so your state must survive them.
A window size class puts the app window into a small set of buckets. You get one bucket for width and one for height.
| Class | Width | Height | Typical device |
|---|---|---|---|
| Compact | under 600 dp | under 480 dp | Phone in portrait (width), phone in landscape (height) |
| Medium | 600 to under 840 dp | 480 to under 900 dp | Small tablet, foldable, large phone in landscape |
| Expanded | 840 dp or more | 900 dp or more | Tablet, unfolded foldable, desktop window |
Newer versions of the library also define large (1200 dp and up) and extra-large (1600 dp and up) width classes for desktops and external displays. They are opt-in; check the current documentation before relying on them.
Two important facts:
- A size class describes the window, not the device. A tablet in split-screen can be compact. A phone in landscape can be medium wide.
- The class changes at run time. Rotation, folding, resizing, and multi-window all change it, so your UI must react.
What changes at each size?
COMPACT width (phone portrait)
+------------------+
| content |
| |
| |
+------------------+
| [Home][Inbox][Me]| <- bottom navigation bar
+------------------+
MEDIUM width (small tablet, foldable)
+---+--------------+
| R | |
| A | content | <- navigation rail on the side
| I | |
| L | |
+---+--------------+
EXPANDED width (tablet, desktop)
+--------+---------+-----------+
| Drawer | list | detail | <- permanent drawer + two panes
| Home | item 1 | Selected |
| Inbox | item 2 | item |
| Sent | item 3 | content |
+--------+---------+-----------+
The three navigation components
| Component | Best for | Notes |
|---|---|---|
| Navigation bar (bottom) | Compact width, 3 to 5 destinations | Thumb-friendly on phones |
| Navigation rail (side) | Medium width, 3 to 7 destinations | Vertical strip of icons beside the content |
| Navigation drawer | Expanded width, or many destinations | Permanent on large screens, modal (slide-in) on small ones |
Material guidelines say to avoid a bottom bar on wide screens because the buttons end up far from the thumbs and look lost in empty space.
Add the adaptive library. Look up the current stable version on the Compose Material 3 adaptive release page (https://developer.android.com/jetpack/androidx/releases/compose-material3-adaptive) rather than copying one from a tutorial, and add entries to your version catalog following the pattern from lesson 26:
[versions]
adaptive = "<latest stable>"
[libraries]
androidx-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive", version.ref = "adaptive" }
androidx-adaptive-layout = { group = "androidx.compose.material3.adaptive", name = "adaptive-layout", version.ref = "adaptive" }
androidx-adaptive-navigation = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation", version.ref = "adaptive" }
androidx-material3-adaptive-navigation-suite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite" }
The last entry belongs to the Compose Material 3 group, so with the Compose BOM in your project it needs no version. If your project has no BOM, give it a version from the Material 3 release page. Names and grouping of these artifacts have moved between releases, so if Sync fails, check the official pages for the current coordinates.
In app/build.gradle.kts:
dependencies {
implementation(libs.androidx.adaptive)
implementation(libs.androidx.adaptive.layout)
implementation(libs.androidx.adaptive.navigation)
implementation(libs.androidx.material3.adaptive.navigation.suite)
}
Branch on the window size
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.window.core.layout.WindowSizeClass
@Composable
fun AdaptiveGreeting() {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
val isExpandedWidth = windowSizeClass.isWidthAtLeastBreakpoint(
WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND
)
val isMediumWidth = windowSizeClass.isWidthAtLeastBreakpoint(
WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND
)
when {
isExpandedWidth -> ExpandedLayout()
isMediumWidth -> MediumLayout()
else -> CompactLayout()
}
}
isWidthAtLeastBreakpoint is the current way to compare. Older tutorials and the Unit 4 codelabs may show calculateWindowSizeClass(activity) with WindowWidthSizeClass.Compact/Medium/Expanded values from the material3-window-size-class artifact. That still exists in many projects, so you will recognise it, but prefer the adaptive-info API in new code because it works without an Activity reference and plays well with previews.
Pass the result down as a parameter, rather than reading it deep in the tree, to keep composables easy to preview and test:
@Composable
fun ReplyApp(
windowSize: WindowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
) {
// choose layout from windowSize
}
Reply is an email app with four mailboxes: Inbox, Sent, Drafts, Spam. You will build the shell that swaps the navigation component for you.
The data
package com.example.reply
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Drafts
import androidx.compose.material.icons.filled.Inbox
import androidx.compose.material.icons.filled.Report
import androidx.compose.material.icons.filled.Send
import androidx.compose.ui.graphics.vector.ImageVector
enum class Mailbox(val label: String, val icon: ImageVector) {
INBOX("Inbox", Icons.Filled.Inbox),
SENT("Sent", Icons.Filled.Send),
DRAFTS("Drafts", Icons.Filled.Drafts),
SPAM("Spam", Icons.Filled.Report)
}
data class Email(
val id: Int,
val sender: String,
val subject: String,
val body: String,
val mailbox: Mailbox
)
val sampleEmails = listOf(
Email(1, "Ada", "Meeting on Friday", "Can we move the meeting to 10am?", Mailbox.INBOX),
Email(2, "Tunde", "Invoice for materials", "Attached is the invoice for the paint.", Mailbox.INBOX),
Email(3, "Mum", "Sunday lunch", "Jollof and plantain. Do not be late.", Mailbox.INBOX),
Email(4, "You", "Re: Meeting on Friday", "10am works for me.", Mailbox.SENT),
Email(5, "You", "Project idea", "A bus tracking app for Ibadan...", Mailbox.DRAFTS),
Email(6, "Prize Team", "You have WON", "Send your bank details now.", Mailbox.SPAM)
)
(Icons.Filled.Inbox, Drafts, Report and Send are in the extended icon set, material-icons-extended. In many projects it is not included by default, and it is large, so release builds rely on R8 to remove unused icons. If you see unresolved references, add the extended icons dependency or draw your own icons from vector assets, as in lesson 21.)
The scaffold
package com.example.reply
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.window.core.layout.WindowSizeClass
@Composable
fun ReplyApp() {
var currentMailbox by rememberSaveable { mutableStateOf(Mailbox.INBOX) }
val adaptiveInfo = currentWindowAdaptiveInfo()
val layoutType = if (
adaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND)
) {
NavigationSuiteType.NavigationDrawer // permanent drawer on big screens
} else {
NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(adaptiveInfo) // bar or rail
}
NavigationSuiteScaffold(
layoutType = layoutType,
navigationSuiteItems = {
Mailbox.entries.forEach { mailbox ->
item(
icon = { Icon(mailbox.icon, contentDescription = mailbox.label) },
label = { Text(mailbox.label) },
selected = mailbox == currentMailbox,
onClick = { currentMailbox = mailbox }
)
}
}
) {
EmailListScreen(
emails = sampleEmails.filter { it.mailbox == currentMailbox },
title = currentMailbox.label
)
}
}
How it behaves:
- On a phone in portrait,
calculateFromAdaptiveInforeturns a navigation bar at the bottom. - On a medium window it returns a navigation rail on the side.
- On an expanded window we override with a permanent drawer.
currentMailboxis saved withrememberSaveable, so rotation or folding keeps the selected mailbox. (An enum is saved automatically because it isSerializable.)
Without the override, NavigationSuiteScaffold chooses bar or rail only. Dropping the layoutType argument completely gives you the default behaviour with zero extra code, which is often enough.
A simple list screen
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun EmailListScreen(emails: List<Email>, title: String, modifier: Modifier = Modifier) {
Column(modifier = modifier.fillMaxSize()) {
Text(title, style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(16.dp))
LazyColumn {
items(emails, key = { it.id }) { email ->
Column(Modifier.padding(16.dp)) {
Text(email.sender, style = MaterialTheme.typography.titleMedium)
Text(email.subject)
}
HorizontalDivider()
}
}
}
}
Run it on a phone emulator and then on a tablet emulator (Device Manager > Create Virtual Device > Tablet). The same code shows a bottom bar on one and a rail or drawer on the other.
Navigation chrome is half the story. The other half is the content layout. A mail app on a phone shows the list, and tapping an email opens the full message on a new screen. On a tablet, that wastes space: you want the list on the left and the open email on the right, at the same time. This is the list-detail pattern.
There are two ways to build it:
- By hand with a window size branch (what the Reply codelab does, and what you will do now, because it teaches the idea clearly).
- With the adaptive layout library (
ListDetailPaneScaffoldandNavigableListDetailPaneScaffold) which handles back navigation and one-pane/two-pane switching for you. It is the better choice for production apps, and is introduced at the end of this section.
By hand
State lives in a ViewModel so that it survives a window change:
package com.example.reply
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
data class ReplyUiState(
val mailbox: Mailbox = Mailbox.INBOX,
val selectedEmailId: Int? = null,
val isShowingDetailOnly: Boolean = false
)
class ReplyViewModel : ViewModel() {
private val _uiState = MutableStateFlow(ReplyUiState())
val uiState: StateFlow<ReplyUiState> = _uiState.asStateFlow()
fun selectMailbox(mailbox: Mailbox) =
_uiState.update { it.copy(mailbox = mailbox, selectedEmailId = null, isShowingDetailOnly = false) }
fun selectEmail(id: Int) =
_uiState.update { it.copy(selectedEmailId = id, isShowingDetailOnly = true) }
fun backToList() =
_uiState.update { it.copy(isShowingDetailOnly = false) }
}
isShowingDetailOnly only matters in compact windows, where one pane is shown at a time. In wide windows both panes show regardless.
The screen chooses by width:
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.clickable
@Composable
fun ReplyHomeContent(
uiState: ReplyUiState,
emails: List<Email>,
isWide: Boolean,
onEmailClick: (Int) -> Unit,
onBackToList: () -> Unit,
modifier: Modifier = Modifier
) {
val selected = emails.firstOrNull { it.id == uiState.selectedEmailId }
if (isWide) {
Row(modifier.fillMaxSize()) {
LazyColumn(Modifier.width(360.dp).fillMaxHeight()) {
items(emails, key = { it.id }) { email ->
Column(
Modifier
.clickable { onEmailClick(email.id) }
.padding(16.dp)
) {
Text(email.sender, style = MaterialTheme.typography.titleMedium)
Text(email.subject)
}
HorizontalDivider()
}
}
EmailDetail(selected, Modifier.weight(1f))
}
} else if (uiState.isShowingDetailOnly && selected != null) {
BackHandler(onBack = onBackToList)
EmailDetail(selected, modifier)
} else {
LazyColumn(modifier.fillMaxSize()) {
items(emails, key = { it.id }) { email ->
Column(
Modifier
.clickable { onEmailClick(email.id) }
.padding(16.dp)
) {
Text(email.sender, style = MaterialTheme.typography.titleMedium)
Text(email.subject)
}
HorizontalDivider()
}
}
}
}
@Composable
fun EmailDetail(email: Email?, modifier: Modifier = Modifier) {
Column(modifier.padding(24.dp)) {
if (email == null) {
Text("Select an email")
} else {
Text(email.subject, style = MaterialTheme.typography.headlineSmall)
Text("From ${email.sender}", modifier = Modifier.padding(vertical = 8.dp))
Text(email.body)
}
}
}
(Modifier.weight(1f) needs the Row scope, which is why EmailDetail receives the modifier from the Row. Add import androidx.compose.foundation.layout.padding and the Material imports from earlier.)
Notice the duplicated list item markup: you would extract an EmailRow composable in real code. We left it in line so each branch reads on its own.
Test it:
- On the phone emulator, tap an email. You see the message alone. Press Back to return to the list.
- On the tablet emulator, both appear at once.
- Use the resizable emulator (create a virtual device of type Resizable, which lets you switch between phone, foldable, tablet and desktop presets) and switch sizes while the app runs. The selected email must stay selected. If it does not, your state is in the wrong place.
The library way: ListDetailPaneScaffold
The adaptive library provides NavigableListDetailPaneScaffold, which takes a list pane and a detail pane and does the one-pane versus two-pane switch, the back behaviour and the animation for you:
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.navigation.NavigableListDetailPaneScaffold
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import kotlinx.coroutines.launch
import androidx.compose.runtime.rememberCoroutineScope
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun AdaptiveEmailScreen(emails: List<Email>) {
val navigator = rememberListDetailPaneScaffoldNavigator<Int>()
val scope = rememberCoroutineScope()
NavigableListDetailPaneScaffold(
navigator = navigator,
listPane = {
AnimatedPane {
LazyColumn {
items(emails, key = { it.id }) { email ->
Column(
Modifier
.clickable {
scope.launch {
navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, email.id)
}
}
.padding(16.dp)
) { Text(email.subject) }
}
}
}
},
detailPane = {
AnimatedPane {
val id = navigator.currentDestination?.contentKey
EmailDetail(emails.firstOrNull { it.id == id })
}
}
)
}
This API is marked experimental in several releases and its exact names have changed between versions, which is why the @OptIn is there. If your version differs, follow the current official guide (search "list-detail layout" in the adaptive apps documentation). The manual approach above works on any version, so you can build and ship with it while the library settles.
The Practice: Build Sports app exercise asks you to use everything above on a new app. Here is the specification to build, with your own data:
- A list of sports (for example Football, Basketball, Athletics, Table tennis, Ayo), each with a name, a one-line description and a longer description.
- Compact: a list screen; tapping a sport opens a detail screen; Back returns.
- Expanded: list on the left, detail on the right, no separate detail screen.
- A top app bar that shows the app name, or the selected sport name on the detail screen, with an Up arrow when appropriate.
- A toggle in the top bar to switch the list between list view and grid view (lesson 21).
Use ReplyViewModel's shape for your state: selectedSportId, isShowingDetailOnly, isListView. This is a good moment to try designing the state class before writing any composable.
The unit ends with a project: Create a My City app. Write the specification yourself:
- Pick a city or town you know (Ibadan, Lagos, Abuja, Kano, or your village).
- Define 4 to 5 categories such as Restaurants, Parks, Markets, Historic sites.
- Each category has at least 5 recommendations with a name, a short description and a picture or icon.
- Navigation: a start screen of categories; a list of places for the chosen category; a detail screen for one place.
- Adaptive: compact uses a bottom bar for categories and a list-detail flow. Expanded shows a rail or drawer plus two panes.
- The app uses one ViewModel, immutable UI state, previews for every screen, unit tests for the ViewModel and one UI test.
- Every image has a meaningful
contentDescription(lesson 47).
Resist the urge to start with the visuals. Sketch screens on paper for the compact and expanded layouts, write the state class, then code.
Exercise 1: Responsive column count
Write ResponsiveGrid that shows a LazyVerticalGrid with 1 column on compact width, 2 on medium and 3 on expanded, using the window size class. Then explain why GridCells.Adaptive(minSize = ...) might be a better answer.
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.window.core.layout.WindowSizeClass
@Composable
fun ResponsiveGrid(items: List<String>, modifier: Modifier = Modifier) {
val size = currentWindowAdaptiveInfo().windowSizeClass
val columns = when {
size.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) -> 3
size.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) -> 2
else -> 1
}
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
modifier = modifier,
contentPadding = PaddingValues(8.dp)
) {
items(items) { Card(Modifier.padding(4.dp)) { Text(it, Modifier.padding(16.dp)) } }
}
}
GridCells.Adaptive(minSize = 200.dp) is often better because it reacts to the actual width in dp continuously, not just to three buckets, and needs no window size code at all. Use window size classes when the structure changes (bar versus rail, one pane versus two) and flexible sizing for density.
Exercise 2: Preserve selection across folding
A teammate stores selectedEmailId with remember inside ReplyHomeContent. Describe what the user sees when they unfold the phone and how to fix it.
Unfolding changes the window size, which is a configuration change, so the Activity is recreated and remember state is lost: the selected email disappears and the user is returned to an empty detail pane. Fix it by holding selectedEmailId in the ViewModel's UI state (it survives configuration changes) or, for a purely local choice, rememberSaveable. The architecture rule from lessons 23 and 24 applies directly: state that matters must not live in a plain remember.
Create three emulators: a phone, the Resizable device and a tablet. Run Reply on each, and write down which navigation component appears at which width. Then resize the resizable emulator slowly and note the exact moment it changes.
Mistake #1: Checking "is it a tablet?". What happens: split-screen and foldables break your assumptions. The fix: use the window size class, which describes the available window.
Mistake #2: Hard-coded widths. The wrong code: Modifier.width(400.dp) for a panel. What happens: it overflows on small windows. The fix: use fillMaxWidth, weight, fractions or size-class branches.
Mistake #3: Forgetting to handle Back in the one-pane detail view. What happens: Back exits the app instead of returning to the list. The fix: a BackHandler that clears the detail-only flag.
Mistake #4: Locking orientation to avoid adaptive work. What happens: on large screens the setting may be ignored or letterboxed, and Google Play may flag poor large-screen quality. The fix: support resizing.
Mistake #5: Selection state lost on resize. The fix: keep it in a ViewModel or rememberSaveable.
Mistake #6: A bottom bar on a wide screen. What happens: the bar stretches across a huge window and its items are unreachable. The fix: rail or drawer from medium width up.
Mistake #7: Two sources of truth for the selected destination. What happens: the drawer shows one thing, the content another. The fix: one currentMailbox value drives both.
Mistake #8: Using experimental APIs blindly. What happens: an update renames a function and the build breaks. The fix: read the release notes, pin versions, and keep experimental API usage in one place.
Question 1. What does a window size class describe?
The space available to the app's window (width and height buckets: compact, medium, expanded), not the device type. It can change while the app runs.
Question 2. Which navigation component suits compact, medium and expanded widths?
A bottom navigation bar for compact, a navigation rail for medium, and a navigation drawer (often permanent) for expanded.
Question 3. What is a list-detail layout?
A layout that shows a list and the details of the selected item. On narrow windows they are separate screens; on wide windows they appear side by side.
Question 4. What does NavigationSuiteScaffold do for you?
It displays a navigation bar, rail or drawer depending on window size, using the destinations you declare once, and lays out your content beside or above it.
Question 5. Why must state survive window size changes?
Resizing, folding and rotation are configuration changes that recreate the Activity. State held only in remember is lost, so important state belongs in a ViewModel or rememberSaveable.
Go deeper: official resources
- Android Basics with Compose, Unit 4, Pathway 3: Adaptive layouts: the official pathway for Reply, Sports and My City.
- Adaptive navigation guide
- Window size classes
- Jetpack Compose adaptability codelab
Project goal: Build the My City project for Ibadan or your own city, in miniature: three categories, three places each, adaptive layout, tested ViewModel.
Requirements:
- Categories: Food, Landmarks, Markets (or your own). Each has three places with a name and description.
CityViewModelwith aCityUiState(category, selectedPlaceId, isShowingDetailOnly).NavigationSuiteScaffoldwith one item per category.- List-detail layout: one pane in compact, two panes in expanded.
- Back from the detail returns to the list.
- Two ViewModel unit tests: changing category clears the selected place; selecting a place sets detail-only.
Stretch goal: display a Card with an image for each place, with a contentDescription that describes it.
package com.example.mycity
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.ShoppingCart
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.window.core.layout.WindowSizeClass
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
enum class Category(val label: String, val icon: ImageVector) {
FOOD("Food", Icons.Filled.Home),
LANDMARKS("Landmarks", Icons.Filled.Place),
MARKETS("Markets", Icons.Filled.ShoppingCart)
}
data class CityPlace(val id: Int, val name: String, val description: String, val category: Category)
val places = listOf(
CityPlace(1, "Amala spot", "Amala with gbegiri and ewedu, a local favourite.", Category.FOOD),
CityPlace(2, "Suya corner", "Evening suya with spicy yaji.", Category.FOOD),
CityPlace(3, "Roasted plantain stand", "Boli with groundnut.", Category.FOOD),
CityPlace(4, "Cocoa House", "A well-known tall building in the city skyline.", Category.LANDMARKS),
CityPlace(5, "Mapo Hall", "A colonial-era hall on a hill overlooking the city.", Category.LANDMARKS),
CityPlace(6, "Bower's Tower", "A tower with wide views over the rooftops.", Category.LANDMARKS),
CityPlace(7, "Bodija Market", "A busy market for foodstuffs.", Category.MARKETS),
CityPlace(8, "Dugbe Market", "A trading centre in the city.", Category.MARKETS),
CityPlace(9, "Oje Market", "Known for traditional cloth.", Category.MARKETS)
)
data class CityUiState(
val category: Category = Category.FOOD,
val selectedPlaceId: Int? = null,
val isShowingDetailOnly: Boolean = false
)
class CityViewModel : ViewModel() {
private val _uiState = MutableStateFlow(CityUiState())
val uiState: StateFlow<CityUiState> = _uiState.asStateFlow()
fun selectCategory(category: Category) =
_uiState.update { CityUiState(category = category) }
fun selectPlace(id: Int) =
_uiState.update { it.copy(selectedPlaceId = id, isShowingDetailOnly = true) }
fun backToList() =
_uiState.update { it.copy(isShowingDetailOnly = false) }
}
@Composable
fun CityApp(viewModel: CityViewModel = viewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val isWide = currentWindowAdaptiveInfo().windowSizeClass
.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND)
NavigationSuiteScaffold(
navigationSuiteItems = {
Category.entries.forEach { category ->
item(
icon = { Icon(category.icon, contentDescription = category.label) },
label = { Text(category.label) },
selected = category == state.category,
onClick = { viewModel.selectCategory(category) }
)
}
}
) {
val list = places.filter { it.category == state.category }
val selected = list.firstOrNull { it.id == state.selectedPlaceId }
if (isWide) {
Row(Modifier.fillMaxSize()) {
PlaceList(list, viewModel::selectPlace, Modifier.width(320.dp).fillMaxHeight())
PlaceDetail(selected, Modifier.weight(1f))
}
} else if (state.isShowingDetailOnly && selected != null) {
BackHandler(onBack = viewModel::backToList)
PlaceDetail(selected, Modifier.fillMaxSize())
} else {
PlaceList(list, viewModel::selectPlace, Modifier.fillMaxSize())
}
}
}
@Composable
fun PlaceList(list: List<CityPlace>, onClick: (Int) -> Unit, modifier: Modifier = Modifier) {
LazyColumn(modifier) {
items(list, key = { it.id }) { place ->
Text(
place.name,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.clickable { onClick(place.id) }.padding(16.dp)
)
HorizontalDivider()
}
}
}
@Composable
fun PlaceDetail(place: CityPlace?, modifier: Modifier = Modifier) {
Column(modifier.padding(24.dp)) {
if (place == null) Text("Choose a place")
else {
Text(place.name, style = MaterialTheme.typography.headlineSmall)
Text(place.description, modifier = Modifier.padding(top = 8.dp))
}
}
}
package com.example.mycity
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class CityViewModelTest {
@Test
fun changingCategory_clearsSelectedPlace() {
val vm = CityViewModel()
vm.selectPlace(1)
vm.selectCategory(Category.MARKETS)
assertNull(vm.uiState.value.selectedPlaceId)
assertEquals(Category.MARKETS, vm.uiState.value.category)
}
@Test
fun selectingPlace_setsDetailOnly() {
val vm = CityViewModel()
vm.selectPlace(4)
assertTrue(vm.uiState.value.isShowingDetailOnly)
assertEquals(4, vm.uiState.value.selectedPlaceId)
}
}
Notes: the place descriptions are short, general statements; check facts about real landmarks and markets yourself before publishing an app about your city. This solution treats medium width and above as "wide" to keep two panes on small tablets; adjust the breakpoint to taste. Icons.Filled.Home, Place and ShoppingCart are in the core icon set, so no extended icon dependency is needed.
Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.
Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 27 project”.
On github.com click New repository, name it android-lesson-27-adaptive-layouts-and-navigation-drawers, set it to Public, leave Add a README unticked, and click Create repository.
Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.
Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉
Lesson 27 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: Window Size Classes
- Step-by-Step Build 1: Read the Window Size Class
- Step-by-Step Build 2: Adaptive Navigation with NavigationSuiteScaffold
- Step-by-Step Build 3: A List-Detail Layout
- Step-by-Step Build 4: The Sports App Practice
- Project Planning: My City
- Guided Practice Exercises
- Common Beginner Mistakes (and How to Fix Them)
- Knowledge Check