Jetpack Compose UI Project — Complete Hands-On Tutorial
In this tutorial, you will learn about Jetpack Compose UI Project. We cover key concepts, practical examples, and best practices to help you master this topic.
Jetpack Compose is Android's modern declarative UI toolkit built with Kotlin, enabling you to build responsive user interfaces using composable functions, reactive state management, and Material Design 3 components without XML layouts.
What You Will Learn
- Composable functions and the Compose rendering model
- State management with
remember,mutableStateOf, andStateFlow - Navigation between screens using the Navigation Compose library
- Material Design 3 theming, typography, and color schemes
- Building a complete multi-screen project from scratch
Why It Matters
Traditional Android development uses XML layouts with Activity and Fragment lifecycle callbacks, leading to boilerplate code and complex state synchronization. Jetpack Compose eliminates XML entirely by describing the UI as a function of state. When state changes, Compose intelligently recomposes only the affected parts of the UI. This declarative model matches how modern web frameworks like React and Flutter work, making Compose the future of Android UI development.
Real-World Use
The DodaTech learning app uses Jetpack Compose for its interactive code sandbox interface. Users type Kotlin code into a composable editor panel, and the output renders in real time in an adjacent composable preview panel. State flows from the editor through a ViewModel to the preview, updating automatically on every keystroke.
Learning Path
flowchart LR A[Compose Basics + Layout] --> B[Compose UI Project\nYou are here] B --> C[ViewModel + Room + Retrofit] style B fill:#f90,color:#fff
Project Overview
We will build a Notes application with three screens: a note list, a note editor, and a settings screen. The app will demonstrate Compose's core features: composable functions, state hoisting, navigation, theming, and Material Design 3 components. You will learn how to structure a production-quality Compose project with clear separation between UI, state, and business logic.
The final app will let users create, edit, and delete notes, with automatic color theming based on the system light or dark mode. All state will persist through configuration changes using ViewModel and rememberSaveable.
Setting Up the Project
Create a new Android project with an Empty Compose Activity template. The minimum SDK should be API 24 for broad device coverage. Your build.gradle.kts (app level) must include the Compose BOM (Bill of Materials) for version alignment:
// build.gradle.kts (app level)
plugins {
id("com.android.application")
kotlin("android")
}
android {
namespace = "com.dodatech.notes"
compileSdk = 34
defaultConfig {
applicationId = "com.dodatech.notes"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
}
buildFeatures { compose = true }
composeOptions {
kotlinCompilerExtensionVersion = "1.5.4"
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.01.00")
implementation(composeBom)
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.activity:activity-compose:1.8.2")
implementation("androidx.navigation:navigation-compose:2.7.6")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
}
The Compose BOM ensures all Compose libraries use compatible versions. The activity-compose dependency provides setContent for Compose inside Activities. navigation-compose handles screen transitions without Fragment overhead.
Building the Data Layer
Define a Note data class and a Repository that will supply notes to the ViewModel. In a real app you would persist notes with Room; here we use an in-memory repository for clarity:
// model/Note.kt
import java.util.UUID
data class Note(
val id: String = UUID.randomUUID().toString(),
val title: String,
val content: String,
val lastEdited: Long = System.currentTimeMillis()
)
// data/NoteRepository.kt
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class NoteRepository {
private val _notes = MutableStateFlow(
mutableListOf(
Note(id = "1", title = "Welcome", content = "Hello Compose!"),
Note(id = "2", title = "Ideas", content = "Build a notes app")
)
)
val notes: StateFlow<List<Note>> = _notes.asStateFlow()
fun addNote(note: Note) {
_notes.value = _notes.value.toMutableList().apply { add(note) }
}
fun deleteNote(id: String) {
_notes.value = _notes.value.toMutableList().apply {
removeAll { it.id == id }
}
}
fun getNoteById(id: String): Note? =
_notes.value.find { it.id == id }
fun updateNote(updated: Note) {
_notes.value = _notes.value.toMutableList().apply {
val index = indexOfFirst { it.id == updated.id }
if (index >= 0) set(index, updated)
}
}
}
StateFlow holds the note list and emits updates whenever notes change. The repository exposes an immutable StateFlow so the ViewModel can collect changes reactively.
Creating the ViewModel
The ViewModel holds UI state and exposes functions that the UI calls in response to user actions. This is the state hoisting pattern in action:
// viewmodel/NotesViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class NotesViewModel : ViewModel() {
private val repository = NoteRepository()
val notes: StateFlow<List<Note>> = repository.notes
.stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(), emptyList())
private var currentNoteId: String? = null
fun createNote(title: String, content: String) {
val note = Note(title = title, content = content)
repository.addNote(note)
}
fun deleteNote(id: String) {
repository.deleteNote(id)
}
fun selectNote(id: String) {
currentNoteId = id
}
fun getSelectedNote(): Note? =
currentNoteId?.let { repository.getNoteById(it) }
fun updateNote(id: String, title: String, content: String) {
repository.updateNote(Note(id = id, title = title, content = content))
}
}
stateIn converts the StateFlow from the repository into a StateFlow that is active within the ViewModel's scope. The ViewModel survives configuration changes because it is scoped to the Compose navigation backstack entry.
Building the Note List Screen
The list screen observes the notes StateFlow and renders each note as a Card composable. Tapping a card navigates to the editor screen. Here is the complete screen:
// ui/screens/NoteListScreen.kt
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NoteListScreen(
onNavigateToEditor: (String?) -> Unit,
viewModel: NotesViewModel = viewModel()
) {
val notes by viewModel.notes.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("My Notes") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
)
},
floatingActionButton = {
FloatingActionButton(onClick = { onNavigateToEditor(null) }) {
Icon(Icons.Default.Add, contentDescription = "New Note")
}
}
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(notes, key = { it.id }) { note ->
NoteCard(
note = note,
onClick = { onNavigateToEditor(note.id) }
)
}
}
}
}
@Composable
fun NoteCard(note: Note, onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = note.title,
style = MaterialTheme.typography.titleMedium,
maxLines = 1
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = note.content,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
collectAsState() observes the StateFlow and triggers recomposition whenever the list changes. The LazyColumn only composes visible items, providing smooth scrolling even with hundreds of notes. The floatingActionButton creates a new note by navigating to the editor with a null ID.
Building the Note Editor Screen
The editor screen receives a note ID, loads the existing note if available, and provides text fields for editing:
// ui/screens/NoteEditorScreen.kt
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NoteEditorScreen(
noteId: String?,
onNavigateBack: () -> Unit,
viewModel: NotesViewModel = viewModel()
) {
val existingNote = remember(noteId) {
noteId?.let { viewModel.getSelectedNote() }
}
var title by remember { mutableStateOf(existingNote?.title ?: "") }
var content by remember { mutableStateOf(existingNote?.content ?: "") }
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (noteId == null) "New Note" else "Edit Note") },
navigationIcon = {
TextButton(onClick = {
if (noteId == null) {
viewModel.createNote(title, content)
} else {
noteId?.let { viewModel.updateNote(it, title, content) }
}
onNavigateBack()
}) {
Text("Save")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp)
) {
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("Title") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = content,
onValueChange = { content = it },
label = { Text("Content") },
modifier = Modifier.fillMaxSize(),
maxLines = 20
)
}
}
}
remember associates the state with the composable's position in the composition. When noteId changes, remember(noteId) reinitializes the state with the new note's data. The Save button in the top bar calls either createNote or updateNote on the ViewModel and then navigates back.
Setting Up Navigation
Navigation Compose connects the screens. Define a sealed class for routes and set up the NavHost in the main activity:
// navigation/Routes.kt
sealed class Routes(val route: String) {
object NoteList : Routes("note_list")
object NoteEditor : Routes("note_editor/{noteId}") {
fun createRoute(noteId: String?) = "note_editor/$noteId"
}
}
// MainActivity.kt
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Routes.NoteList.route
) {
composable(Routes.NoteList.route) {
NoteListScreen(
onNavigateToEditor = { id ->
navController.navigate(
Routes.NoteEditor.createRoute(id)
)
}
)
}
composable(
route = Routes.NoteEditor.route,
arguments = listOf(
navArgument("noteId") { type = NavType.StringType; nullable = true; defaultValue = null }
)
) { backStackEntry ->
val noteId = backStackEntry.arguments?.getString("noteId")
NoteEditorScreen(
noteId = noteId,
onNavigateBack = { navController.popBackStack() }
)
}
}
}
}
}
}
}
The NavController manages the back stack. Navigating to NoteEditor with a note ID pushes the editor onto the stack; popping it returns to the list. The navArgument declaration specifies that noteId is nullable, allowing the same route to serve both new and existing notes.
Applying Material Design 3 Theming
Compose's MaterialTheme supports dynamic color based on the wallpaper on Android 12+. Define a custom theme that respects dark mode:
// ui/theme/Theme.kt
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
private val DarkColorScheme = darkColorScheme(
primary = androidx.compose.ui.graphics.Color(0xFF90CAF9),
secondary = androidx.compose.ui.graphics.Color(0xFFCE93D8),
tertiary = androidx.compose.ui.graphics.Color(0xFFA5D6A7)
)
private val LightColorScheme = lightColorScheme(
primary = androidx.compose.ui.graphics.Color(0xFF1565C0),
secondary = androidx.compose.ui.graphics.Color(0xFF7B1FA2),
tertiary = androidx.compose.ui.graphics.Color(0xFF2E7D32)
)
@Composable
fun NotesTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkTheme) dynamicDarkColorScheme(LocalContext.current)
else dynamicLightColorScheme(LocalContext.current)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography(),
content = content
)
}
Dynamic color uses the user's wallpaper to generate a color scheme, creating a personalized experience. On devices below Android 12, the custom light and dark schemes are used instead.
Running the App
When you run the app on an emulator or device, you will see the note list screen with two default notes. Tapping the FAB creates a new note with the title and content of your choice. The note appears immediately in the list thanks to Compose's reactive state. The app responds to system light and dark mode changes automatically.
Output: The app displays "My Notes" in the top bar, two sample notes as cards, and a floating action button in the bottom-right corner.
Common Mistakes
Calling
mutableStateOfoutside a composable: State must be created inside a@Composablefunction or aViewModel. Creating it in a regular class causes the state to reset on every recomposition.Modifying state without using
copy: Data classes in Kotlin are immutable. To update aNoteyou must callnote.copy(title = newTitle). Mutating the fields directly will not trigger recomposition.Forgetting
keyinLazyColumnitems: Thekeyparameter helps Compose identify which items changed. Without it, every item recomposes when the list changes, causing performance issues and visual glitches.Passing large state objects down the composable tree: Use state hoisting and ViewModel to avoid passing raw state through many composables. Each intermediate composable that reads the state will recompose when it changes.
Missing
viewModel()scope: CallingviewModel()inside a composable that is not a direct child of aNavHostmay create a new ViewModel instance every recomposition. Always retrieve the ViewModel at the screen level, not inside reusable child composables.
Practice Questions
- How does
rememberdiffer fromrememberSaveablein Compose? - What happens to the composable tree when the ViewModel emits a new list of notes?
- Why should
FloatingActionButtoncall a lambda from the parent screen rather than navigating directly? - How would you add swipe-to-delete functionality to the note cards?
- Challenge: Implement a search bar in the top bar that filters notes by title. Use
derivedStateOfto compute the filtered list without triggering unnecessary recompositions.
Mini Project
Build a Todo application in Compose with the following requirements:
- A list screen showing incomplete and completed tasks in separate sections
- An add-task dialog with title and priority fields
- A detail screen showing task description, due date, and completion status
- Support for editing and deleting tasks
- Persist tasks using
rememberSaveableso they survive Process death
FAQ
What is Next
Now that you have built a Compose UI project, you are ready to connect it to real data sources. Proceed to ViewModel and Room Database for persistent storage, and Retrofit Networking for API integration. You can also explore Animations in Compose to make your app feel polished.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro