Skip to main content

Android UI Development

Jetpack Compose Benefits

Jetpack Compose provides declarative, type-safe UI development for Android. Proper state management with remember, State, and StateFlow prevents UI bugs and enables predictable behavior. Navigation Compose enables deep linking for push notifications and external entry points. Reusable composables and Material Design 3 ensure consistency across screens and reduce maintenance burden.

Overview

This guide covers Jetpack Compose best practices for building robust, accessible user interfaces. We focus on declarative UI patterns, state management, navigation strategies, Material Design 3 theming, and reusable component design.

What This Guide Covers

  • Jetpack Compose Basics: Declarative syntax, composables, modifiers
  • State Management: remember, State, StateFlow, collectAsStateWithLifecycle
  • Navigation: Navigation Compose, type-safe routes, deep linking
  • Material Design 3: Theming, colors, typography, dynamic colors
  • Reusable Components: Building modular, testable UI components
  • Performance: Recomposition optimization, derivedStateOf, key

Compose State Management

State in Jetpack Compose represents any value that can change over time. When state changes, Compose automatically recomposes (re-executes) the affected composable functions to update the UI. This declarative approach eliminates manual view updates and reduces bugs.

Compose uses a unidirectional data flow pattern where state flows down from ViewModels to composables, and events flow up from user interactions back to ViewModels. This separation ensures predictable behavior and makes testing straightforward.

State in Compose

State Management Rules

Understanding when to use each state mechanism is critical for building performant, maintainable UIs. remember caches values across recompositions but loses state during configuration changes (like rotation). rememberSaveable persists state through these changes using the Bundle mechanism. StateFlow from ViewModels provides lifecycle-aware state that survives both recompositions and configuration changes, making it ideal for business data. derivedStateOf optimizes performance by only recomposing when computed values actually change, not when intermediate dependencies change.

State TypeUse CaseScopeExample
rememberLocal UI stateComposableToggle, text input
mutableStateOfObservable stateComposableSelected item, expanded state
StateFlowViewModel stateViewModelData from repository
derivedStateOfComputed stateComposableFiltered lists, calculations
rememberSaveableSurvive config changesComposableScroll position, form data

Jetpack Compose Basics

Jetpack Compose uses a declarative paradigm where you describe what the UI should look like for a given state, rather than imperatively updating views. Composable functions are the building blocks - they take data and emit UI elements. The @Composable annotation marks functions that can call other composables and participate in recomposition.

The Scaffold composable provides Material Design layout structure with slots for common UI elements like top bars, floating action buttons, and bottom navigation. Using Scaffold ensures consistent layout behavior and handles insets automatically. The example below demonstrates proper state collection using collectAsStateWithLifecycle(), which respects the Android lifecycle and prevents memory leaks by stopping collection when the UI is not visible. See Android Architecture for ViewModel patterns and Android Data & Networking for repository integration.

Basic Screen with Material Design 3

// presentation/screens/payments/PaymentListScreen.kt
package com.bank.paymentapp.presentation.screens.payments

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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PaymentListScreen(
viewModel: PaymentListViewModel = hiltViewModel(),
onPaymentClick: (String) -> Unit
) {
val state by viewModel.state.collectAsStateWithLifecycle()

Scaffold(
topBar = {
TopAppBar(
title = { Text("Payments") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary
)
)
},
floatingActionButton = {
FloatingActionButton(
onClick = { /* Navigate to create payment */ }
) {
Icon(Icons.Default.Add, contentDescription = "Create Payment")
}
}
) { paddingValues ->
when (state) {
is PaymentListState.Loading -> {
LoadingContent(modifier = Modifier.padding(paddingValues))
}

is PaymentListState.Success -> {
val payments = (state as PaymentListState.Success).payments
PaymentListContent(
payments = payments,
onPaymentClick = onPaymentClick,
modifier = Modifier.padding(paddingValues)
)
}

is PaymentListState.Error -> {
ErrorContent(
message = (state as PaymentListState.Error).message,
onRetry = { viewModel.loadPayments() },
modifier = Modifier.padding(paddingValues)
)
}
}
}
}

@Composable
private fun LoadingContent(modifier: Modifier = Modifier) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}

@Composable
private fun PaymentListContent(
payments: List<Payment>,
onPaymentClick: (String) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(
items = payments,
key = { it.id }
) { payment ->
PaymentCard(
payment = payment,
onClick = { onPaymentClick(payment.id) }
)
}
}
}

Compose Lifecycle


Reusable Components

Building reusable composable components is fundamental to maintaining consistent UI and reducing code duplication. Well-designed components accept only the data they need via parameters and expose behavior through lambda callbacks. This makes them testable in isolation and reusable across different contexts.

The key principle is state hoisting - components should be stateless and receive their state from parents. This pattern, borrowed from React, makes components predictable and testable. Stateless components can be easily previewed with different states using @Preview annotations. For complex styling, Material Design 3 provides the Card, Surface, and Button composables with built-in elevation, shape, and color handling based on the theme.

Payment Card Component

// presentation/components/PaymentCard.kt
package com.bank.paymentapp.presentation.components

import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.bank.paymentapp.domain.model.Payment
import com.bank.paymentapp.domain.model.PaymentStatus
import java.text.NumberFormat
import java.text.SimpleDateFormat
import java.util.*

@Composable
fun PaymentCard(
payment: Payment,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
onClick = onClick,
modifier = modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = payment.recipientName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)

PaymentStatusChip(status = payment.status)
}

Spacer(modifier = Modifier.height(8.dp))

// Amount
Text(
text = formatCurrency(payment.amount, payment.currency),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary
)

Spacer(modifier = Modifier.height(4.dp))

// Date
Text(
text = formatDate(payment.createdAt),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)

// Description (if exists)
payment.description?.let { description ->
Spacer(modifier = Modifier.height(8.dp))
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2
)
}
}
}
}

@Composable
private fun PaymentStatusChip(status: PaymentStatus) {
val (text, containerColor, contentColor) = when (status) {
PaymentStatus.COMPLETED -> Triple(
"Completed",
MaterialTheme.colorScheme.primaryContainer,
MaterialTheme.colorScheme.onPrimaryContainer
)
PaymentStatus.PENDING -> Triple(
"Pending",
MaterialTheme.colorScheme.secondaryContainer,
MaterialTheme.colorScheme.onSecondaryContainer
)
PaymentStatus.FAILED -> Triple(
"Failed",
MaterialTheme.colorScheme.errorContainer,
MaterialTheme.colorScheme.onErrorContainer
)
}

Surface(
color = containerColor,
contentColor = contentColor,
shape = MaterialTheme.shapes.small
) {
Text(
text = text,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Medium
)
}
}

private fun formatCurrency(amount: Double, currency: String): String {
val formatter = NumberFormat.getCurrencyInstance()
formatter.currency = Currency.getInstance(currency)
return formatter.format(amount)
}

private fun formatDate(date: Date): String {
val formatter = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault())
return formatter.format(date)
}

Empty State Component

// presentation/components/EmptyStateView.kt
@Composable
fun EmptyStateView(
icon: ImageVector,
title: String,
message: String,
actionText: String? = null,
onAction: (() -> Unit)? = null,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)

Spacer(modifier = Modifier.height(16.dp))

Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)

Spacer(modifier = Modifier.height(8.dp))

Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)

if (actionText != null && onAction != null) {
Spacer(modifier = Modifier.height(24.dp))

Button(onClick = onAction) {
Text(text = actionText)
}
}
}
}

List and Interaction Patterns

LazyColumn for List Virtualization

Jetpack Compose's LazyColumn renders only visible items for optimal performance.

// presentation/screens/payments/PaymentListScreen.kt
@Composable
fun PaymentListScreen(
viewModel: PaymentListViewModel = hiltViewModel(),
onPaymentClick: (String) -> Unit
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val payments = (state as? PaymentListState.Success)?.payments ?: emptyList()

LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(
items = payments,
key = { it.id }
) { payment ->
PaymentCard(
payment = payment,
onClick = { onPaymentClick(payment.id) }
)
}
}
}

Why LazyColumn: Renders only visible items, recycling composables as you scroll. Essential for lists with more than 20-30 items to prevent memory issues and maintain smooth scrolling.

Pull-to-Refresh

Material 3 provides PullRefreshIndicator for pull-to-refresh functionality:

import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun TransactionListScreen(
viewModel: TransactionViewModel = hiltViewModel()
) {
val isRefreshing by viewModel.isRefreshing.collectAsState()
val transactions by viewModel.transactions.collectAsState()

val pullRefreshState = rememberPullRefreshState(
refreshing = isRefreshing,
onRefresh = { viewModel.refresh() }
)

Box(
modifier = Modifier
.fillMaxSize()
.pullRefresh(pullRefreshState)
) {
LazyColumn {
items(transactions) { transaction ->
TransactionRow(transaction = transaction)
}
}

PullRefreshIndicator(
refreshing = isRefreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter)
)
}
}

Gesture Handling

Jetpack Compose provides gesture detection through Modifier.pointerInput:

@Composable
fun DraggableCard() {
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }

Box(
modifier = Modifier
.size(200.dp, 100.dp)
.offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
.background(Color.Blue, RoundedCornerShape(12.dp))
.pointerInput(Unit) {
detectDragGestures(
onDragEnd = {
// Animate back to original position
offsetX = 0f
offsetY = 0f
}
) { change, dragAmount ->
change.consume()
offsetX += dragAmount.x
offsetY += dragAmount.y
}
}
)
}

Common Gestures:

  • detectTapGestures: Tap, double-tap, long press
  • detectDragGestures: Pan/drag
  • detectTransformGestures: Pinch, zoom, rotate
  • Modifier.clickable: Simple click handling
// Example: Swipe-to-dismiss
import androidx.compose.material3.SwipeToDismiss
import androidx.compose.material3.rememberDismissState

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SwipeableListItem(
item: Item,
onDismiss: () -> Unit
) {
val dismissState = rememberDismissState(
confirmValueChange = {
if (it == DismissValue.DismissedToStart) {
onDismiss()
true
} else {
false
}
}
)

SwipeToDismiss(
state = dismissState,
background = {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Red),
contentAlignment = Alignment.CenterEnd
) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete",
modifier = Modifier.padding(16.dp)
)
}
}
) {
Card(modifier = Modifier.fillMaxWidth()) {
Text(
text = item.name,
modifier = Modifier.padding(16.dp)
)
}
}
}

For foundational navigation concepts (stack, tab, drawer, modal patterns), see Mobile Navigation. This section covers Jetpack Compose-specific navigation implementation with Navigation Component.

Navigation in Jetpack Compose is handled by the Navigation Component, which manages the back stack and handles deep links. Unlike traditional Fragment-based navigation, Navigation Compose uses a NavHost that displays different composables based on the current route. The NavController manages navigation operations like navigate, popBackStack, and handles the back stack state.

Type-safe navigation is achieved using sealed classes to represent screens, preventing runtime errors from typos in route strings. Arguments are passed through the route URL or as navigation arguments, with type safety enforced through navArgument definitions. This approach integrates seamlessly with deep links from push notifications or external URLs. For complete navigation patterns including bottom navigation and nested graphs, see the Navigation Compose documentation.

// presentation/navigation/NavGraph.kt
package com.bank.paymentapp.presentation.navigation

import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import androidx.navigation.NavType
import com.bank.paymentapp.presentation.screens.payments.PaymentListScreen
import com.bank.paymentapp.presentation.screens.payments.PaymentDetailScreen
import com.bank.paymentapp.presentation.screens.auth.LoginScreen

@Composable
fun NavGraph(
navController: NavHostController,
startDestination: String = Screen.PaymentList.route
) {
NavHost(
navController = navController,
startDestination = startDestination
) {
composable(Screen.Login.route) {
LoginScreen(
onLoginSuccess = {
navController.navigate(Screen.PaymentList.route) {
popUpTo(Screen.Login.route) { inclusive = true }
}
}
)
}

composable(Screen.PaymentList.route) {
PaymentListScreen(
onPaymentClick = { paymentId ->
navController.navigate(Screen.PaymentDetail.createRoute(paymentId))
}
)
}

composable(
route = Screen.PaymentDetail.route,
arguments = listOf(
navArgument("paymentId") { type = NavType.StringType }
)
) { backStackEntry ->
val paymentId = backStackEntry.arguments?.getString("paymentId")
PaymentDetailScreen(
paymentId = paymentId ?: "",
onBack = { navController.popBackStack() }
)
}
}
}

sealed class Screen(val route: String) {
data object Login : Screen("login")
data object PaymentList : Screen("payments")
data object PaymentDetail : Screen("payment/{paymentId}") {
fun createRoute(paymentId: String) = "payment/$paymentId"
}
data object CreatePayment : Screen("payment/create")
data object Profile : Screen("profile")
}

Material Design 3 Theming

Material Design 3 (Material You) introduces dynamic theming based on the user's wallpaper and system preferences. Themes define color schemes, typography, and shapes used throughout the app. The MaterialTheme composable provides these values to all child composables through composition locals.

Color schemes consist of primary, secondary, tertiary colors plus their variants (container, on-container). The "on" prefix indicates colors used for text/icons displayed on that color surface. Dynamic colors, available on Android 12+, adapt to the user's wallpaper. For branding requirements, use custom color schemes but follow Material Design accessibility guidelines with minimum 4.5:1 contrast ratios for text. Typography uses scaled text sizes that respect user accessibility settings. For comprehensive theming guidance, see Material Design 3.

Theme Setup

// presentation/theme/Theme.kt
package com.bank.paymentapp.presentation.theme

import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat

private val LightColorScheme = lightColorScheme(
primary = BankPrimary,
onPrimary = BankOnPrimary,
primaryContainer = BankPrimaryContainer,
onPrimaryContainer = BankOnPrimaryContainer,
secondary = BankSecondary,
onSecondary = BankOnSecondary,
tertiary = BankTertiary,
error = BankError,
onError = BankOnError,
background = BankBackground,
onBackground = BankOnBackground,
surface = BankSurface,
onSurface = BankOnSurface
)

private val DarkColorScheme = darkColorScheme(
primary = BankPrimaryDark,
onPrimary = BankOnPrimaryDark,
primaryContainer = BankPrimaryContainerDark,
onPrimaryContainer = BankOnPrimaryContainerDark,
secondary = BankSecondaryDark,
onSecondary = BankOnSecondaryDark,
tertiary = BankTertiaryDark,
error = BankErrorDark,
background = BankBackgroundDark,
surface = BankSurfaceDark
)

@Composable
fun BankingAppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = false,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}

val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}

MaterialTheme(
colorScheme = colorScheme,
typography = BankingTypography,
content = content
)
}

Design System Colors

// presentation/theme/Color.kt
import androidx.compose.ui.graphics.Color

// Light theme colors
val BankPrimary = Color(0xFF0066CC)
val BankOnPrimary = Color(0xFFFFFFFF)
val BankPrimaryContainer = Color(0xFFD1E4FF)
val BankOnPrimaryContainer = Color(0xFF001D35)

val BankSecondary = Color(0xFF535F70)
val BankOnSecondary = Color(0xFFFFFFFF)

val BankTertiary = Color(0xFF6B5778)
val BankError = Color(0xFFBA1A1A)
val BankOnError = Color(0xFFFFFFFF)

val BankBackground = Color(0xFFFDFCFF)
val BankOnBackground = Color(0xFF1A1C1E)
val BankSurface = Color(0xFFFDFCFF)
val BankOnSurface = Color(0xFF1A1C1E)

// Dark theme colors
val BankPrimaryDark = Color(0xFF9ECAFF)
val BankOnPrimaryDark = Color(0xFF003258)
// ... additional dark colors

Performance Optimization

Compose's recomposition system is intelligent but requires proper optimization for complex UIs. Recomposition occurs when state changes, but Compose's smart recomposition only re-executes composables that read the changed state. If a composable doesn't read a changed state value, it skips recomposition entirely. This granular recomposition is what makes Compose performant, but requires thoughtful state structure to maximize efficiency.

The primary optimization techniques involve making state changes granular (use multiple State objects instead of one large object), using derivedStateOf for computed values (prevents recomposition when intermediate calculations don't change the result), and providing stable keys for LazyColumn items (enables efficient item reuse). Using @Immutable or @Stable annotations helps Compose understand when objects haven't changed, preventing unnecessary recompositions. For deep-dive performance optimization including profiling recompositions, see Android Performance.

Recomposition Optimization


Common Mistakes

Compose's declarative, reactive paradigm differs from the imperative View system. Developers transitioning from XML layouts and findViewById often make the same mistakes initially. Recognizing these anti-patterns helps you avoid performance issues and subtle bugs.

Don't: Create New Lambdas in Composables

//  BAD: New lambda on every recomposition causes child recompositions
@Composable
fun PaymentList(payments: List<Payment>) {
LazyColumn {
items(payments) { payment ->
PaymentCard(
payment = payment,
onClick = { handleClick(payment.id) } // New lambda every time!
)
}
}
}

// GOOD: Stable lambda or remember
@Composable
fun PaymentList(
payments: List<Payment>,
onPaymentClick: (String) -> Unit
) {
LazyColumn {
items(
items = payments,
key = { it.id }
) { payment ->
PaymentCard(
payment = payment,
onClick = { onPaymentClick(payment.id) }
)
}
}
}

Don't: Use mutableStateOf Without remember

//  BAD: State resets on every recomposition
@Composable
fun PaymentFilter() {
var expanded = mutableStateOf(false) // WRONG!

Button(onClick = { expanded.value = !expanded.value }) {
Text("Filter")
}
}

// GOOD: Use remember
@Composable
fun PaymentFilter() {
var expanded by remember { mutableStateOf(false) }

Button(onClick = { expanded = !expanded }) {
Text("Filter")
}
}

Code Review Checklist

Security (Block PR)

  • No sensitive data displayed in plain text - Mask account numbers
  • Screenshot protection on sensitive screens - Use FLAG_SECURE
  • Biometric auth before sensitive actions - Payments require authentication

Watch For (Request Changes)

  • Stable keys in LazyColumn - Use unique IDs, not index
  • No heavy operations in composables - Use LaunchedEffect/rememberCoroutineScope
  • State hoisting - Stateless composables where possible
  • Content descriptions - All interactive elements accessible
  • Remember expensive calculations - Use remember or derivedStateOf
  • Proper navigation - Use NavController, not manual Fragment management

Best Practices

  • @Composable functions - Start with capital letter
  • Modifier parameter last - Standard Compose convention
  • Preview annotations - All composables have @Preview
  • Material Design 3 - Use MaterialTheme colors/typography
  • Hilt ViewModel injection - Use hiltViewModel()
  • collectAsStateWithLifecycle - Collect StateFlow lifecycle-aware

Further Reading

Android Framework Guidelines

UI and Mobile Guidelines

External Resources


Summary

Key Takeaways

  1. Declarative UI - Compose functions describe UI state
  2. State management - Use proper state holders (remember, StateFlow)
  3. Recomposition - Only affected composables recompose
  4. Navigation Compose - Type-safe navigation with deep linking
  5. Material Design 3 - Consistent theming and design system
  6. Reusable composables - Build modular, testable components
  7. Performance - Use keys, derivedStateOf, stable types
  8. Accessibility - Content descriptions, semantic properties
  9. Previews - Every composable has @Preview
  10. State hoisting - Keep composables stateless when possible

Next Steps: Explore Android Data & Networking for integrating UI with backend APIs and local storage.