Skip to content

Jetpack Compose UI Project — Complete Hands-On Tutorial

DodaTech Updated 2026-06-28 11 min read

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, and StateFlow
  • 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

  1. Calling mutableStateOf outside a composable: State must be created inside a @Composable function or a ViewModel. Creating it in a regular class causes the state to reset on every recomposition.

  2. Modifying state without using copy: Data classes in Kotlin are immutable. To update a Note you must call note.copy(title = newTitle). Mutating the fields directly will not trigger recomposition.

  3. Forgetting key in LazyColumn items: The key parameter helps Compose identify which items changed. Without it, every item recomposes when the list changes, causing performance issues and visual glitches.

  4. 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.

  5. Missing viewModel() scope: Calling viewModel() inside a composable that is not a direct child of a NavHost may create a new ViewModel instance every recomposition. Always retrieve the ViewModel at the screen level, not inside reusable child composables.

Practice Questions

  1. How does remember differ from rememberSaveable in Compose?
  2. What happens to the composable tree when the ViewModel emits a new list of notes?
  3. Why should FloatingActionButton call a lambda from the parent screen rather than navigating directly?
  4. How would you add swipe-to-delete functionality to the note cards?
  5. Challenge: Implement a search bar in the top bar that filters notes by title. Use derivedStateOf to 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 rememberSaveable so they survive Process death

FAQ

How does Compose handle configuration changes like screen rotation?

Compose survives configuration changes automatically when state uses rememberSaveable or lives in a ViewModel. The ViewModel is scoped to the navigation backstack entry and does not recreate on rotation.

Can I use Compose with existing XML-based projects?

Yes. You can add Compose to an existing project incrementally. Use ComposeView in XML layouts to embed composables, and migrate one screen at a time.

Why does my Composable not update when state changes?

Check that you are reading the state with by delegation inside a composable function. If you assign the state to a local variable before using it, Compose cannot track the dependency for recomposition.

What is the difference between `mutableStateOf` and `StateFlow`?

mutableStateOf is Compose's built-in state observable for simple values. StateFlow is a Kotlin coroutines flow that integrates with Compose through collectAsState(). Use StateFlow for data layer state that the ViewModel exposes.

How do I test Compose UI?

Use the Compose UI test library with createComposeRule. You can find composables by text or content description, perform clicks and scrolls, and verify the UI state after actions.

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