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
)
}
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")
}
}
}
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
}
}
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()
}
}
}
}
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")
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 }
}
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") }
)
}
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
)
}
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") }
}
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") }
}
}
// 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) }
}
}
📖 Official docs: Jetpack Compose (developer.android.com) · ViewModel Overview (developer.android.com) · Saving UI State (developer.android.com) · SharedFlow (kotlinlang.org)