Kotlin Crash Course
Chapter 7

Android & Compose

Build modern Android UIs with Jetpack Compose, manage state with ViewModel and StateFlow, and understand the Compose lifecycle.

By the end of this chapter, you will:

  • Understand the declarative UI paradigm of Jetpack Compose
  • Build simple Composable functions
  • Manage UI state with ViewModel and StateFlow
  • Use remember, mutableStateOf, and derived state
  • Navigate between screens

What is Jetpack Compose?

Jetpack Compose is Android's modern declarative UI toolkit. Instead of manipulating views imperatively (findViewById, setText), you describe what the UI should look like based on the current state.

// Old way (imperative)
val textView = findViewById<TextView>(R.id.text)
textView.text = "Hello"
textView.setTextColor(Color.RED)

// New way (declarative) — Jetpack Compose
@Composable
fun Greeting(name: String) {
    Text(
        text = "Hello, $name!",
        color = Color.Red
    )
}
Key concept: In Compose, UI is a function of state. When state changes, Compose automatically recomposes (re-runs) the affected Composables. Think React, but for native Android.

Your First Composable

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyAppTheme {
                Greeting(name = "Android")
            }
        }
    }
}

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Text(
        text = "Hello, $name!",
        modifier = modifier.padding(16.dp)
    )
}

Layout Composables

@Composable
fun ProfileCard() {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Alex", fontSize = 24.sp, fontWeight = FontWeight.Bold)
        Spacer(modifier = Modifier.height(8.dp))
        Text("Developer", fontSize = 16.sp, color = Color.Gray)
        Spacer(modifier = Modifier.height(16.dp))
        Row {
            Button(onClick = { /* TODO */ }) {
                Text("Follow")
            }
            Spacer(modifier = Modifier.width(8.dp))
            OutlinedButton(onClick = { /* TODO */ }) {
                Text("Message")
            }
        }
    }
}

Managing State

State in Compose can be local (remember) or shared (ViewModel + StateFlow).

Local State with remember

@Composable
fun Counter() {
    var count by remember { mutableIntStateOf(0) }

    Column {
        Text("Count: $count", fontSize = 20.sp)
        Button(onClick = { count++ }) {
            Text("Increment")
        }
    }
}

Shared State with ViewModel

class CounterViewModel : ViewModel() {
    // Private mutable state
    private val _count = MutableStateFlow(0)
    // Public immutable state
    val count: StateFlow<Int> = _count.asStateFlow()

    fun increment() {
        _count.value++
    }
}

@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
    val count by viewModel.count.collectAsStateWithLifecycle()

    Column {
        Text("Count: $count", fontSize = 20.sp)
        Button(onClick = { viewModel.increment() }) {
            Text("Increment")
        }
    }
}
When to use what: Use remember for UI-only state (text field input, scroll position). Use ViewModel + StateFlow for business logic state that survives configuration changes (screen rotation) and is shared across Composables.

Surviving Process Death

Configuration changes (screen rotation) are handled by ViewModel, but process death (OS kills your app in background) wipes everything. Use rememberSaveable for UI state and SavedStateHandle for ViewModel state.

rememberSaveable

For UI-only state that must survive both config changes and process death. Use it for text input, scroll position, or checkbox states.

@Composable
fun SearchField() {
    // Survives process death automatically
    var query by rememberSaveable { mutableStateOf("") }

    TextField(
        value = query,
        onValueChange = { query = it },
        label = { Text("Search") }
    )
}

SavedStateHandle in ViewModel

For business logic state that must survive process death. The ViewModel receives a SavedStateHandle that persists to a Bundle.

class SearchViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
    // Survives process death
    var query by savedStateHandle.saveable { mutableStateOf("") }
        private set

    fun onQueryChange(newQuery: String) {
        query = newQuery
    }
}
Rule of thumb: Use remember for temporary UI state (animation, dropdown open/closed). Use rememberSaveable for user input that should persist. Use ViewModel + SavedStateHandle for business state that drives navigation or data loading.

One-Time Events with SharedFlow

StateFlow is great for UI state, but it replays the last value to new collectors. For one-time events (snackbar messages, navigation triggers, toasts), use SharedFlow with replay = 0.

class LoginViewModel : ViewModel() {
    // State — survives rotation, always has a value
    private val _uiState = MutableStateFlow(LoginUiState())
    val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()

    // Events — one-time, not replayed
    private val _events = MutableSharedFlow<LoginEvent>()
    val events: SharedFlow<LoginEvent> = _events.asSharedFlow()

    fun login(email: String, password: String) {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }
            try {
                val user = repository.login(email, password)
                _events.emit(LoginEvent.NavigateToHome)  // one-time!
            } catch (e: Exception) {
                _events.emit(LoginEvent.ShowError(e.message))
            } finally {
                _uiState.update { it.copy(isLoading = false) }
            }
        }
    }
}

// In Compose — collect events separately from state
@Composable
fun LoginScreen(viewModel: LoginViewModel = viewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()

    LaunchedEffect(Unit) {
        viewModel.events.collect { event ->
            when (event) {
                is LoginEvent.ShowError -> showSnackbar(event.message)
                is LoginEvent.NavigateToHome -> navigateToHome()
            }
        }
    }
}
State vs Event: State answers "What does the screen look like right now?" Events answer "What just happened?" If you put navigation in StateFlow, it triggers again after rotation. If you put it in SharedFlow, it fires exactly once.

Side Effects

Composable functions should be side-effect free. For side effects, use effect APIs.

@Composable
fun UserProfile(userId: String) {
    // Runs once when userId changes
    LaunchedEffect(userId) {
        viewModel.loadUser(userId)
    }

    // Runs on every successful recomposition
    SideEffect {
        println("Profile recomposed")
    }

    // Disposable — cleanup when leaving composition
    DisposableEffect(Unit) {
        val listener = object : SomeListener { /* ... */ }
        registerListener(listener)
        onDispose {
            unregisterListener(listener)
        }
    }
}

Lists in Compose

@Composable
fun UserList(users: List<User>) {
    LazyColumn {
        items(users, key = { it.id }) { user ->
            UserRow(user)
        }
    }
}

@Composable
fun UserRow(user: User) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp)
    ) {
        Text(user.name, modifier = Modifier.weight(1f))
        Text(user.email, color = Color.Gray)
    }
}

Navigation

@Composable
fun AppNavigation() {
    val navController = rememberNavController()

    NavHost(navController = navController, startDestination = "home") {
        composable("home") {
            HomeScreen(onNavigate = { navController.navigate("detail/$it") })
        }
        composable("detail/{userId}") { backStackEntry ->
            val userId = backStackEntry.arguments?.getString("userId")
            DetailScreen(userId = userId)
        }
    }
}

Collecting Flows Safely

When collecting StateFlow in Compose, use collectAsStateWithLifecycle() to automatically stop collecting when the app goes to the background. This prevents unnecessary UI updates and wasted resources.

@Composable
fun CollectAsStateWithLifecycle(flow: Flow<String>) {
    val state by flow.collectAsStateWithLifecycle()
}

Add this dependency to your build.gradle.kts:

implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
Why it matters: Without lifecycle-aware collection, your Composable continues to receive updates even when the screen is not visible, causing unnecessary recompositions and potential crashes if the UI tries to update while backgrounded.

In a Fragment or Activity, you can also use repeatOnLifecycle to launch a coroutine that only runs when the lifecycle is at least STARTED:

lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { state ->
            updateUi(state)
        }
    }
}

Derived State

Use derivedStateOf when a state value is computed from other states and the computation is expensive. It caches the result and only recalculates when the underlying state actually changes.

val isFormValid by remember {
    derivedStateOf { email.isNotBlank() && password.length >= 8 }
}
Use case: Form validation, calculated visibility, filtered lists, or any derived value that would be wasteful to recompute on every recomposition. Without derivedStateOf, the calculation runs every time the Composable recomposes, even if the inputs haven't changed.

State Hoisting

State hoisting means moving state up to the lowest common ancestor of all Composables that need to read or write it. The child receives the state and callbacks as parameters, making it stateless and reusable.

@Composable
fun SearchBar(query: String, onQueryChange: (String) -> Unit) {
    OutlinedTextField(
        value = query,
        onValueChange = onQueryChange,
        label = { Text("Search") }
    )
}
Use case: Previews, testing, and reusability. A stateless Composable can be previewed in Android Studio with any input, tested in isolation without a ViewModel, and dropped into any screen because it doesn't depend on external state.

Theming and Material Design

Material Theme provides a centralized way to define colors, typography, and shapes across your app. Access theme values inside any Composable to keep styling consistent.

MaterialTheme(
    colorScheme = darkColorScheme(
        primary = Purple80,
        secondary = PurpleGrey80
    )
) {
    Text(
        text = "Hello",
        color = MaterialTheme.colorScheme.primary,
        style = MaterialTheme.typography.bodyLarge
    )
}
Use case: Consistent styling. Changing the primary color in the theme updates every Composable that references MaterialTheme.colorScheme.primary. This is especially powerful for supporting dynamic colors and dark mode across your entire app.

Preview Annotations

Use @Preview to render Composables in Android Studio's design view without running the app on a device. You can create multiple previews for different configurations.

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    MyAppTheme { Greeting("Android") }
}

@Preview(
    showBackground = true,
    uiMode = Configuration.UI_MODE_NIGHT_YES
)
@Composable
fun GreetingDarkPreview() {
    MyAppTheme { Greeting("Android") }
}
Use case: Rapid UI iteration. Preview both light and dark themes side by side, test different font scales, or preview the same Composable with multiple data states without touching a physical device.

Common Compose Patterns

Most screens need to handle loading, success, and error states. Use a sealed class to represent UI state and render the appropriate Composable with a when expression.

sealed class UiState<out T> {
    data object Loading : UiState<Nothing>()
    data class Success<T>(val data: T) : UiState<T>()
    data class Error(val message: String) : UiState<Nothing>()
}

@Composable
fun ContentScreen(uiState: UiState<List<Item>>) {
    when (uiState) {
        is UiState.Loading -> CircularProgressIndicator()
        is UiState.Success -> ContentList(uiState.data)
        is UiState.Error -> ErrorMessage(uiState.message)
    }
}

Empty State Pattern

@Composable
fun ItemList(items: List<Item>) {
    if (items.isEmpty()) {
        EmptyMessage("No items found")
    } else {
        LazyColumn {
            items(items) { item -> ItemRow(item) }
        }
    }
}

Error State Pattern

@Composable
fun ErrorMessage(message: String, onRetry: () -> Unit) {
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier.fillMaxSize()
    ) {
        Text(text = message, color = MaterialTheme.colorScheme.error)
        Button(onClick = onRetry) { Text("Retry") }
    }
}
Use case: These patterns keep your UI predictable. By modeling every possible screen state explicitly, you avoid impossible UI states (like showing an error and loading spinner at the same time) and make your code easier to test.
// SideEffect — run code only when composition succeeds
@Composable
fun AnalyticsScreen(screenName: String) {
    SideEffect {
        analytics.logScreenView(screenName)  // not on every recomposition
    }
}

// derivedStateOf — cache expensive calculations
@Composable
fun FilteredList(items: List<Item>, query: String) {
    val filtered by remember(query) {
        derivedStateOf { items.filter { it.name.contains(query) } }
    }
    LazyColumn { items(filtered) { ItemRow(it) } }
}

// DisposableEffect — cleanup when leaving composition
@Composable
fun LocationTracker() {
    val context = LocalContext.current
    DisposableEffect(Unit) {
        val listener = LocationListener { /* ... */ }
        locationManager.requestLocationUpdates(provider, 0L, 0f, listener)
        onDispose { locationManager.removeUpdates(listener) }
    }
}

Try It Yourself

Create a simple todo item Composable that shows a task text and a checkbox. When clicked, it should toggle a strikethrough on the text.

@Composable
fun TodoItem(task: String) {
    // Your code here:




}
Stuck? Show hint
Use var checked by remember { mutableStateOf(false) }, a Row with a Checkbox and a Text. Apply strikethrough with TextDecoration.LineThrough via the textStyle parameter:

Text(task, textDecoration = if (checked) TextDecoration.LineThrough else TextDecoration.None)

Concept Check

What is the purpose of ViewModel in Compose?

To replace Composable functions
To hold and manage UI-related data that survives config changes
To apply themes and styling to the UI