Back to Blog

Lists & Navigation in Kotlin Desktop: Compose Multiplatform | Lesson 04

Sandy LaneSandy Lane

Video: Lists & Navigation in Kotlin Desktop: Compose Multiplatform | Lesson 04 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Lists and Navigation: Build a Notes App with Compose Desktop

LazyColumn for big lists. sealed class Screen for navigation state. Two views, one app, no router library.

Today's app is a notes viewer. A scrollable list of 20 notes; click any to see its full detail; click back to return. Two screens, one shared piece of state describing which screen is showing.

The new pieces:

  • LazyColumn — Compose's virtualised vertical list. Renders only the rows currently on screen.
  • sealed class Screen — a typed representation of "which view is the user looking at?" The compiler enforces exhaustive when on it.
  • when (val screen = state.value) — the dispatch from "which screen is selected" to "render that screen."

What we are building

A two-screen app:

  • List screen — a top bar reading "My Notes," then a LazyColumn of 20 note cards (title, date, body preview).
  • Detail screen — a top bar with a back arrow plus the note's title, then the note's date and full body.

Click a list item → detail screen. Click back → list screen.

Project layout

src/main/kotlin/
├── Main.kt                 # App + window + screen dispatch
├── Screen.kt               # sealed class Screen
├── Note.kt                 # data class Note + sampleNotes()
├── NoteListScreen.kt       # LazyColumn of NoteItems
├── NoteItem.kt             # individual list card
├── NoteDetailScreen.kt     # full detail view
├── TopBar.kt               # shared title bar
└── Colors.kt               # palette

Eight files, none over 30 lines. The architectural lesson: as the app grows, each new screen adds a few small files in known places.

Screen.kt: the navigation state

sealed class Screen {
  object List : Screen()
  data class Detail(val noteId: Int) : Screen()
}

A sealed class is Kotlin's tagged union. Screen has exactly two subtypes: List (no parameters, modelled as object since there's only ever one) and Detail(val noteId: Int) (carries the ID of the note to show).

The compiler tracks the exhaustive set of subtypes. A when (screen) that misses a branch is flagged at compile time. No runtime "unknown screen" bugs.

For an app with three screens — list, detail, settings — add a third subtype:

sealed class Screen {
  object List : Screen()
  data class Detail(val noteId: Int) : Screen()
  object Settings : Screen()
}

The when statement that dispatches to screens is now incomplete; the compiler tells you immediately.

Note.kt: the data

data class Note(
  val id: Int,
  val title: String,
  val body: String,
  val date: String
)

fun sampleNotes(): List<Note> = (1..20).map { i ->
  Note(
    id = i,
    title = "Note $i",
    body = "This is the body of note $i. It contains several sentences...",
    date = "2025-01-${"%02d".format(i)}"
  )
}

20 sample notes with sequential dates. Real app would load from a database (Lesson 9) or an API (Lesson 8); for now, hard-coded sample data.

Main.kt: the dispatch

@Composable
fun App() {
  val notes = remember { sampleNotes() }
  val currentScreen = remember { mutableStateOf<Screen>(Screen.List) }

  MaterialTheme(colorScheme = darkColorScheme()) {
    Surface(modifier = Modifier.fillMaxSize(), color = DarkBackground) {
      when (val screen = currentScreen.value) {
        is Screen.List -> NoteListScreen(
          notes = notes,
          onNoteClick = { id -> currentScreen.value = Screen.Detail(id) }
        )
        is Screen.Detail -> NoteDetailScreen(
          note = notes.first { it.id == screen.noteId },
          onBack = { currentScreen.value = Screen.List }
        )
      }
    }
  }
}

Three pieces.

val notes = remember { sampleNotes() } — load the notes once. remember { ... } ensures we don't regenerate them on every recomposition.

val currentScreen = remember { mutableStateOf<Screen>(Screen.List) } — the navigation state. Starts on the list screen.

when (val screen = currentScreen.value) { ... } — the dispatch. Two branches:

  • is Screen.List — render the list. Pass onNoteClick that flips to Screen.Detail with the clicked note's ID.
  • is Screen.Detail — render the detail. Look up the note by ID. Pass onBack that flips back to Screen.List.

The val screen = currentScreen.value inside the when lets us destructure: in the is Screen.Detail branch, screen.noteId is accessible.

NoteListScreen.kt: the list

@Composable
fun NoteListScreen(notes: List<Note>, onNoteClick: (Int) -> Unit) {
  Column(modifier = Modifier.fillMaxSize()) {
    TopBar(title = "My Notes")
    LazyColumn(
      modifier = Modifier.fillMaxSize(),
      contentPadding = PaddingValues(vertical = 8.dp)
    ) {
      items(notes) { note ->
        NoteItem(note = note, onClick = { onNoteClick(note.id) })
      }
    }
  }
}

Two children. The top bar at the top, the LazyColumn filling the rest.

LazyColumn is a virtualised list — only the rows currently visible (or about to be) get composed. A list of 20 fits on screen without virtualisation; a list of 20,000 needs LazyColumn to stay performant.

The closure inside LazyColumn is a special DSL with an items function. items(notes) { note -> ... } produces one item per element in the list, passing the element to the lambda.

onNoteClick = { onNoteClick(note.id) } adapts the per-item callback to send the note's ID upward. Standard pattern.

NoteItem.kt: each row

@Composable
fun NoteItem(note: Note, onClick: () -> Unit) {
  Card(
    onClick = onClick,
    modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
    shape = RoundedCornerShape(12.dp),
    colors = CardDefaults.cardColors(containerColor = DarkSurface)
  ) {
    Column(modifier = Modifier.padding(16.dp)) {
      Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.SpaceBetween
      ) {
        Text(note.title, fontSize = 18.sp, color = Purple80)
        Text(note.date, fontSize = 12.sp, color = PurpleGrey40)
      }
      Spacer(modifier = Modifier.height(4.dp))
      Text(
        note.body,
        fontSize = 14.sp,
        color = PurpleGrey80,
        maxLines = 1,
        overflow = TextOverflow.Ellipsis
      )
    }
  }
}

A clickable Card (Material 3's Card has an onClick overload that handles ripple, accessibility, and click semantics). Inside, a row with title and date, then a one-line preview of the body.

maxLines = 1 plus overflow = TextOverflow.Ellipsis truncates long bodies with "..." instead of wrapping them across multiple lines.

NoteDetailScreen.kt: the detail view

@Composable
fun NoteDetailScreen(note: Note, onBack: () -> Unit) {
  Column(modifier = Modifier.fillMaxSize()) {
    TopBar(title = note.title, onBack = onBack)
    Column(
      modifier = Modifier
        .fillMaxSize()
        .verticalScroll(rememberScrollState())
        .padding(16.dp)
    ) {
      Text(note.date, fontSize = 14.sp, color = PurpleGrey40)
      Spacer(modifier = Modifier.height(12.dp))
      Card(
        shape = RoundedCornerShape(12.dp),
        colors = CardDefaults.cardColors(containerColor = DarkSurface)
      ) {
        Text(
          note.body,
          fontSize = 16.sp,
          color = PurpleGrey80,
          modifier = Modifier.padding(16.dp)
        )
      }
    }
  }
}

Top bar with the note's title and a back callback. Then a vertically scrollable area (verticalScroll(rememberScrollState())) for the body in case it's long.

verticalScroll is the simpler alternative to LazyColumn when you have a fixed amount of content that might overflow. LazyColumn is for unbounded lists; verticalScroll is for bounded content with possible overflow.

TopBar.kt: shared

@Composable
fun TopBar(title: String, onBack: (() -> Unit)? = null) {
  Surface(color = DarkSurface) {
    Row(
      modifier = Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 8.dp),
      verticalAlignment = Alignment.CenterVertically
    ) {
      if (onBack != null) {
        IconButton(onClick = onBack) {
          Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = Purple80)
        }
      } else {
        Spacer(modifier = Modifier.width(48.dp))
      }
      Text(title, fontSize = 20.sp, color = Purple80)
    }
  }
}

Same top bar used by both screens. The onBack: (() -> Unit)? = null parameter is optional — null for the list screen (no back arrow), provided for the detail screen.

Icons.AutoMirrored.Filled.ArrowBack flips automatically for right-to-left languages (Arabic, Hebrew). Material's auto-mirrored icons are the right default for navigation chrome.

Why a sealed class

You could represent the current screen as var currentScreen: String = "list" and if (currentScreen == "detail") .... It would work for two screens. But:

  • Typos compile ("detial" is fine to the compiler).
  • Adding a third screen means hunting through every if to add a branch.
  • Carrying associated data (the note's ID for the detail screen) requires a parallel var currentNoteId: Int? — coupled but unconnected.

The sealed class fixes all three. Screen.Detail(noteId) ties the screen identity to its data. The when is exhaustive. New screens are new subclasses; the compiler points out every place that needs updating.

What we're not doing (yet)

This isn't real navigation. There's no back-stack, no animation, no deep-linking, no URL bar. For three or four screens it's overkill to bring in a navigation library; for ten, you'll want one (Voyager and PreCompose are popular choices for Compose Multiplatform).

The pattern stays the same regardless: a typed state describing what's on screen, a dispatch that maps state to Composable.

Running it

./gradlew run. The list appears with 20 notes. Click any → detail view. Click the back arrow → list. The list's scroll position resets (we don't preserve it); a real navigation library would.

Common mistakes

Using Column for huge lists. A Column composes every child eagerly. For 1000 items, the app freezes. LazyColumn virtualises.

Forgetting items(notes) inside LazyColumn. Calling notes.forEach { NoteItem(...) } inside LazyColumn works but renders every item — defeating virtualisation. Use the items DSL function.

Hardcoding screen identity as strings. Use a sealed class. The type system pays back immediately.

Sharing state via globals. "It's just one variable" turns into ten globals fast. Pass state through Composable parameters; let the parent own it.

Not preserving scroll position. LazyColumn resets when its parent recomposes. Use rememberLazyListState() and pass it as state = ... if you need to preserve scroll across recompositions.

What's next

Lesson 5: file I/O. Build a markdown editor with Open and Save backed by File.readText() / File.writeText() and JFileChooser. The first lesson where the app touches the disk.

Recap

LazyColumn for virtualised lists. sealed class Screen for typed navigation state. when (state.value) for the dispatch from screen to Composable. State hoisted to the App level. Card(onClick = ...) for clickable rows. Optional callbacks (onBack: (() -> Unit)? = null) for shared Composables that work in multiple contexts.

Next lesson: file I/O. See you in the next one.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.