Back to Blog

Sortable Tables & Data Grids in Kotlin Compose Desktop | Lesson 14

Sandy LaneSandy Lane

Video: Sortable Tables & Data Grids in Kotlin Compose Desktop | Lesson 14 by Taught by Celeste AI - AI Coding Coach

Watch full page →

Sortable Tables & Data Grids in Kotlin Compose Desktop

In this lesson, you’ll learn how to build a powerful data explorer using Kotlin Compose Desktop with Material 3 dark theme. The example covers sortable column headers, column resizing with drag gestures, pagination controls, text search, category filtering, and exporting data to CSV and JSON formats.

Code

import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

@Serializable
data class Product(val id: Int, val name: String, val category: String, val price: Double)

enum class SortOrder { Ascending, Descending, None }

data class SortState(val column: String, val order: SortOrder)

@Composable
fun SortableDataGrid(products: List<Product>) {
  var sortState by remember { mutableStateOf(SortState("id", SortOrder.None)) }
  var columnWidths by remember { mutableStateOf(mapOf("id" to 60.dp, "name" to 150.dp, "category" to 100.dp, "price" to 80.dp)) }
  var searchQuery by remember { mutableStateOf("") }
  var selectedCategory by remember { mutableStateOf("All") }
  var currentPage by remember { mutableStateOf(0) }
  val itemsPerPage = 10

  // Filter products by search and category
  val filtered = products.filter {
    (selectedCategory == "All" || it.category == selectedCategory) &&
    (it.name.contains(searchQuery, ignoreCase = true) ||
     it.category.contains(searchQuery, ignoreCase = true))
  }

  // Sort products based on sortState
  val sorted = when (sortState.order) {
    SortOrder.Ascending -> filtered.sortedBy { it.getFieldValue(sortState.column) }
    SortOrder.Descending -> filtered.sortedByDescending { it.getFieldValue(sortState.column) }
    else -> filtered
  }

  // Paginate
  val paginated = sorted.drop(currentPage * itemsPerPage).take(itemsPerPage)

  Column(Modifier.fillMaxSize().padding(16.dp)) {
    // Search and Category Filter UI omitted for brevity

    // Table Header with sortable columns and resizable widths
    Row {
      listOf("id", "name", "category", "price").forEach { column ->
        Box(
          Modifier
            .width(columnWidths[column] ?: 100.dp)
            .pointerInput(Unit) {
              detectDragGestures { change, dragAmount ->
                change.consume()
                val currentWidth = columnWidths[column] ?: 100.dp
                val newWidth = (currentWidth + dragAmount.x.dp).coerceAtLeast(50.dp)
                columnWidths = columnWidths.toMutableMap().apply { put(column, newWidth) }
              }
            }
            .clickable {
              sortState = when {
                sortState.column != column -> SortState(column, SortOrder.Ascending)
                sortState.order == SortOrder.Ascending -> SortState(column, SortOrder.Descending)
                sortState.order == SortOrder.Descending -> SortState(column, SortOrder.None)
                else -> SortState(column, SortOrder.Ascending)
              }
            }
            .padding(8.dp)
        ) {
          Text(text = column.capitalize() + when {
            sortState.column == column && sortState.order == SortOrder.Ascending -> " ↑"
            sortState.column == column && sortState.order == SortOrder.Descending -> " ↓"
            else -> ""
          })
        }
      }
    }

    // Data Rows
    LazyColumn {
      itemsIndexed(paginated) { index, product ->
        Row {
          Text(product.id.toString(), Modifier.width(columnWidths["id"] ?: 60.dp).padding(8.dp))
          Text(product.name, Modifier.width(columnWidths["name"] ?: 150.dp).padding(8.dp))
          Text(product.category, Modifier.width(columnWidths["category"] ?: 100.dp).padding(8.dp))
          Text("$${product.price}", Modifier.width(columnWidths["price"] ?: 80.dp).padding(8.dp))
        }
      }
    }

    // Pagination Controls omitted for brevity
  }
}

// Helper to get property value by name for sorting
fun Product.getFieldValue(field: String): Comparable<*> = when (field) {
  "id" -> id
  "name" -> name
  "category" -> category
  "price" -> price
  else -> ""
}

Key Points

  • Use LazyColumn with itemsIndexed for efficient, scrollable data grids in Compose Desktop.
  • Implement sortable columns by tracking SortState and updating sorting on header clicks.
  • Enable column resizing with pointerInput and detectDragGestures, enforcing minimum widths.
  • Combine filtering, sorting, and pagination using derived state chains for responsive UI updates.
  • Serialize data classes with kotlinx-serialization for exporting to CSV or JSON formats.