cyberangles guide

Android Jetpack Compose with Kotlin: Building Modern UIs

In the ever-evolving landscape of Android development, creating intuitive, responsive, and visually appealing user interfaces (UIs) has always been a priority. For years, Android developers relied on XML layouts paired with Java/Kotlin code to define UIs—a process often criticized for its verbosity, boilerplate, and disconnect between design and logic. Enter **Jetpack Compose**, Google’s modern, declarative UI toolkit for Android that simplifies and accelerates UI development with Kotlin. Compose shifts the paradigm from imperative (manually updating views) to **declarative**: you describe *what* your UI should look like based on state, and the framework handles the "how" (rendering and updating views automatically). Built entirely with Kotlin, Compose leverages the language’s conciseness, null safety, and functional programming features to create UIs that are easier to write, read, and maintain. Whether you’re building a simple app or a complex enterprise solution, Compose empowers you to craft beautiful UIs with less code, faster iteration, and seamless integration with existing Android architecture components like ViewModel and Navigation. In this blog, we’ll dive deep into Compose’s core concepts, walk through building UIs, explore state management, navigation, advanced features, and share best practices to help you master modern Android UI development.

Table of Contents

  1. What is Jetpack Compose?
  2. Why Choose Jetpack Compose?
  3. Core Concepts of Compose
  4. Building Your First UI with Compose
  5. State Management in Compose
  6. Navigation with Jetpack Compose
  7. Advanced Features
  8. Best Practices
  9. Conclusion
  10. References

What is Jetpack Compose?

Jetpack Compose is an open-source UI toolkit introduced by Google in 2021, designed to simplify Android UI development. It is part of the Jetpack library suite, which provides modular, opinionated components to accelerate Android app development.

Key Definitions:

  • Declarative UI: You define UI components as functions of their current state. When state changes, Compose automatically re-renders the affected parts of the UI.
  • Kotlin-First: Compose is built entirely with Kotlin, leveraging features like lambdas, extension functions, and coroutines for concise and expressive code.
  • Interoperable: Compose works seamlessly with existing XML layouts and Android views (e.g., using AndroidView to embed legacy views in Compose, or ComposeView to embed Compose in XML).

Why Choose Jetpack Compose?

Compose has quickly become the preferred choice for Android UI development. Here’s why:

1. Declarative Paradigm

Instead of writing code to update views (e.g., textView.setText("Hello")), you describe the UI as a function of state. For example:

@Composable  
fun Greeting(name: String) {  
    Text(text = "Hello, $name!") // UI reflects the current "name" state  
}  

When name changes, Compose automatically re-renders the Text component—no manual view updates needed.

2. Less Boilerplate

XML layouts required separate files, findViewById calls, and adapters for lists. Compose eliminates this by combining UI definition and logic in Kotlin functions. A simple screen that once required 100+ lines of XML and Kotlin can now be written in 20–30 lines of Compose code.

3. Intuitive and Expressive

Compose’s Kotlin-based syntax is readable and easy to learn. You build UIs by combining small, reusable “composable” functions (e.g., Text, Button, Column), making it simple to reason about and modify.

4. Powerful Tooling

  • Preview: Use @Preview to see UI changes instantly without running the app.
  • Hot Reload: Update code and see changes in milliseconds, speeding up iteration.
  • Layout Inspector: Debug UI hierarchies with Compose-specific tools.

5. Material Design 3 Integration

Compose natively supports Material Design 3 (MD3), Google’s latest design system, with built-in components like Card, TextField, and BottomNavigation. Custom theming (colors, typography, shapes) is also simplified.

6. Interoperability

You don’t need to rewrite your entire app to adopt Compose. It works alongside existing XML layouts, views, and libraries (e.g., RecyclerView, ViewModel).

Core Concepts of Compose

To effectively use Compose, you need to understand its foundational concepts:

1. Composables

At the heart of Compose are composable functions—annotated with @Composable—that define UI elements. These functions describe what to display, not how to display it.

@Composable  
fun ProfileCard(name: String, email: String) {  
    Card(modifier = Modifier.padding(16.dp)) {  
        Column(modifier = Modifier.padding(16.dp)) {  
            Text(text = name, fontSize = 20.sp, fontWeight = FontWeight.Bold)  
            Text(text = email, fontSize = 14.sp, color = Color.Gray)  
        }  
    }  
}  
  • Composables can accept parameters (e.g., name, email) to customize behavior.
  • They cannot return values (they emit UI elements).

2. Layouts

Compose provides built-in layout components to arrange UI elements:

  • Column: Arrange items vertically.
  • Row: Arrange items horizontally.
  • Box: Stack items (e.g., overlay text on an image).
  • LazyColumn/LazyRow: Efficiently render scrollable lists (like RecyclerView, but with less code).

Example with Column and Row:

@Composable  
fun UserProfile() {  
    Column(  
        modifier = Modifier  
            .fillMaxSize()  
            .padding(16.dp),  
        horizontalAlignment = Alignment.CenterHorizontally  
    ) {  
        Image(  
            painter = painterResource(id = R.drawable.avatar),  
            contentDescription = "User avatar",  
            modifier = Modifier.size(100.dp)  
        )  
        Row(  
            modifier = Modifier.padding(top = 16.dp),  
            horizontalArrangement = Arrangement.spacedBy(8.dp)  
        ) {  
            Button(onClick = { /* Follow */ }) { Text("Follow") }  
            Button(onClick = { /* Message */ }) { Text("Message") }  
        }  
    }  
}  

3. Modifiers

Modifiers customize the appearance and behavior of composables (e.g., size, padding, background, click handling). They are chainable and applied using the modifier parameter:

Text(  
    text = "Styled Text",  
    modifier = Modifier  
        .padding(16.dp) // Add padding  
        .background(Color.Blue) // Blue background  
        .clickable { /* Handle click */ } // Make text clickable  
        .padding(8.dp) // Add inner padding (order matters!)  
)  

4. State

State represents data that can change over time, and Compose UIs react to state changes. To make state observable, use mutableStateOf (or remember for state that persists across recompositions).

Example: A counter that updates when a button is clicked:

@Composable  
fun Counter() {  
    // `remember` retains the state during recompositions  
    var count by remember { mutableStateOf(0) }  

    Button(onClick = { count++ }) {  
        Text("Count: $count") // UI updates when `count` changes  
    }  
}  

5. Material Design 3 Components

Compose includes pre-built MD3 components for common UI patterns:

  • Text: Display text with styling.
  • Button: Clickable action button.
  • TextField: User input field.
  • Card: Container with shadow/elevation.
  • LazyColumn: Scrollable list (replaces RecyclerView).

Building Your First UI with Compose

Let’s put these concepts into practice by building a simple “Counter App” with a text display, a button, and a counter that increments when the button is clicked.

Step 1: Set Up Compose

Ensure your build.gradle (Module level) includes Compose dependencies:

android {  
    buildFeatures {  
        compose true  
    }  
    composeOptions {  
        kotlinCompilerExtensionVersion "1.4.3" // Use latest version  
    }  
}  

dependencies {  
    // Compose core  
    implementation "androidx.activity:activity-compose:1.8.2"  
    implementation platform("androidx.compose:compose-bom:2023.10.01")  
    implementation "androidx.compose.ui:ui"  
    implementation "androidx.compose.ui:ui-graphics"  
    implementation "androidx.compose.ui:ui-tooling-preview"  
    implementation "androidx.compose.material3:material3"  
    debugImplementation "androidx.compose.ui:ui-tooling"  
}  

Step 2: Create the Counter Screen

Define a composable function for the counter screen. We’ll use Column to arrange elements vertically, Text to display the count, and Button to increment it.

import androidx.compose.foundation.layout.Arrangement  
import androidx.compose.foundation.layout.Column  
import androidx.compose.foundation.layout.fillMaxSize  
import androidx.compose.foundation.layout.padding  
import androidx.compose.material3.Button  
import androidx.compose.material3.Text  
import androidx.compose.runtime.getValue  
import androidx.compose.runtime.mutableStateOf  
import androidx.compose.runtime.remember  
import androidx.compose.runtime.setValue  
import androidx.compose.ui.Alignment  
import androidx.compose.ui.Modifier  
import androidx.compose.ui.unit.dp  

@Composable  
fun CounterScreen() {  
    // State: Track the count (retained via `remember`)  
    var count by remember { mutableStateOf(0) }  

    // Layout: Arrange elements vertically  
    Column(  
        modifier = Modifier  
            .fillMaxSize()  
            .padding(16.dp),  
        verticalArrangement = Arrangement.Center,  
        horizontalAlignment = Alignment.CenterHorizontally  
    ) {  
        // Display current count  
        Text(  
            text = "Current Count: $count",  
            fontSize = 24.sp,  
            modifier = Modifier.padding(bottom = 16.dp)  
        )  

        // Button to increment count  
        Button(onClick = { count++ }) {  
            Text("Increment Count")  
        }  
    }  
}  

Step 3: Preview the UI

Add a preview to see the UI without running the app:

@Preview(showBackground = true, name = "Counter Screen Preview")  
@Composable  
fun CounterScreenPreview() {  
    CounterScreen()  
}  

Step 4: Run the App

Set CounterScreen as the content of your Activity:

class MainActivity : ComponentActivity() {  
    override fun onCreate(savedInstanceState: Bundle?) {  
        super.onCreate(savedInstanceState)  
        setContent {  
            // Wrap in MaterialTheme for MD3 styling  
            MaterialTheme {  
                CounterScreen()  
            }  
        }  
    }  
}  

How It Works:

  • remember { mutableStateOf(0) } creates observable state. The by keyword delegates the state to a count variable.
  • When the button is clicked, count increments.
  • Compose detects the state change and re-renders the Text component to display the new count.

State Management in Compose

State is the lifeblood of dynamic UIs, but managing it effectively is critical for maintainability. Compose promotes unidirectional data flow (UDF)—where state flows down and events flow up—and provides tools to handle state at different scopes.

1. Local State with remember

Use remember for state that is local to a composable (e.g., a counter in a single screen). As shown earlier:

var count by remember { mutableStateOf(0) }  

2. State Hoisting

To share state between composables or make them reusable, hoist state up (lift state to a parent composable). This ensures a single source of truth and makes composables stateless and testable.

Example: Hoisting the counter state to a parent:

// Stateless child composable (reusable!)  
@Composable  
fun CounterDisplay(count: Int, onIncrement: () -> Unit) {  
    Button(onClick = onIncrement) {  
        Text("Count: $count")  
    }  
}  

// Parent composable owns the state  
@Composable  
fun ParentComponent() {  
    var count by remember { mutableStateOf(0) }  
    CounterDisplay(count = count, onIncrement = { count++ })  
}  

3. ViewModel Integration

For state that outlives a single screen (e.g., data from an API), use ViewModel with Compose. Combine ViewModel with StateFlow or LiveData to expose observable state.

Example with ViewModel and StateFlow:

// ViewModel  
class CounterViewModel : ViewModel() {  
    private val _count = MutableStateFlow(0)  
    val count: StateFlow<Int> = _count  

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

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

    Button(onClick = { viewModel.increment() }) {  
        Text("Count: $count")  
    }  
}  

4. StateFlow and LiveData

Compose integrates seamlessly with reactive streams:

  • Use collectAsStateWithLifecycle() for StateFlow.
  • Use observeAsState() for LiveData.

Jetpack Navigation Compose simplifies navigation between screens. It uses a NavController to manage back stack and navigation graph.

Step 1: Add Dependencies

Add the Navigation Compose dependency to build.gradle:

implementation "androidx.navigation:navigation-compose:2.7.7" // Use latest version  

Step 2: Set Up NavController

Initialize NavController in your activity or root composable:

class MainActivity : ComponentActivity() {  
    override fun onCreate(savedInstanceState: Bundle?) {  
        super.onCreate(savedInstanceState)  
        setContent {  
            MaterialTheme {  
                val navController = rememberNavController()  
                NavHost(  
                    navController = navController,  
                    startDestination = "home" // Initial screen  
                ) {  
                    composable("home") { HomeScreen(navController) }  
                    composable("details") { DetailsScreen(navController) }  
                }  
            }  
        }  
    }  
}  

Step 3: Navigate Between Screens

Trigger navigation from a composable using navController.navigate():

@Composable  
fun HomeScreen(navController: NavController) {  
    Column(modifier = Modifier.fillMaxSize()) {  
        Text("Home Screen")  
        Button(onClick = { navController.navigate("details") }) {  
            Text("Go to Details")  
        }  
    }  
}  

@Composable  
fun DetailsScreen(navController: NavController) {  
    Column(modifier = Modifier.fillMaxSize()) {  
        Text("Details Screen")  
        Button(onClick = { navController.popBackStack() }) { // Go back  
            Text("Back to Home")  
        }  
    }  
}  

Step 4: Passing Arguments

Define arguments in the navigation graph and pass them during navigation:

// Define route with argument  
composable(  
    route = "user/{userId}",  
    arguments = listOf(navArgument("userId") { type = NavType.IntType })  
) { backStackEntry ->  
    val userId = backStackEntry.arguments?.getInt("userId")  
    UserProfileScreen(userId = userId ?: 0)  
}  

// Navigate with argument  
navController.navigate("user/123")  

Advanced Features

Compose offers powerful tools to build polished, dynamic UIs:

1. Animations

Animate UI changes with Compose’s animation APIs:

  • animateDpAsState: Animate Dp values (e.g., size, padding).
  • Animatable: Fine-grained control over animations (e.g., color transitions).
  • animateContentSize: Automatically animate size changes.

Example: Animate text size when a button is clicked:

@Composable  
fun AnimatedText() {  
    var expanded by remember { mutableStateOf(false) }  
    val textSize by animateDpAsState(  
        targetValue = if (expanded) 32.dp else 16.dp,  
        animationSpec = tween(durationMillis = 300)  
    )  

    Text(  
        text = "Click to expand",  
        fontSize = textSize,  
        modifier = Modifier.clickable { expanded = !expanded }  
    )  
}  

2. Theming

Customize your app’s look with MaterialTheme:

MaterialTheme(  
    colorScheme = lightColorScheme(  
        primary = Color(0xFF6200EE),  
        secondary = Color(0xFF03DAC6)  
    ),  
    typography = Typography(  
        bodyLarge = TextStyle(  
            fontFamily = FontFamily.SansSerif,  
            fontSize = 18.sp,  
            fontWeight = FontWeight.Normal  
        )  
    ),  
    shapes = Shapes(  
        small = RoundedCornerShape(4.dp),  
        medium = RoundedCornerShape(8.dp)  
    )  
) {  
    // App content  
}  

3. Custom Layouts

Build unique layouts with Layout composable for full control over measurement and positioning:

@Composable  
fun StaggeredLayout(children: @Composable () -> Unit) {  
    Layout(  
        content = children,  
        modifier = Modifier.fillMaxWidth()  
    ) { measurables, constraints ->  
        // Measure children and position them (custom logic here)  
        val placeables = measurables.map { it.measure(constraints) }  
        layout(constraints.maxWidth, constraints.maxHeight) {  
            var yPosition = 0  
            placeables.forEach { placeable ->  
                placeable.placeRelative(x = 0, y = yPosition)  
                yPosition += placeable.height + 8.dp.roundToPx() // Staggered spacing  
            }  
        }  
    }  
}  

4. Testing

Test Compose UIs with ComposeTestRule:

@RunWith(AndroidJUnit4::class)  
class CounterTest {  
    @get:Rule  
    val composeTestRule = createComposeRule()  

    @Test  
    fun incrementButton_increasesCount() {  
        composeTestRule.setContent { Counter() }  

        // Initial count is 0  
        composeTestRule.onNodeWithText("Count: 0").assertExists()  

        // Click button  
        composeTestRule.onNodeWithText("Increment").performClick()  

        // Count should be 1  
        composeTestRule.onNodeWithText("Count: 1").assertExists()  
    }  
}  

Best Practices

To build maintainable, performant Compose UIs, follow these practices:

1. Reusable Composables

Write small, focused composables (e.g., PrimaryButton, UserAvatar) that can be reused across the app.

2. State Hoisting

Always hoist state to make composables stateless, reusable, and testable.

3. Avoid Side Effects in Composables

Composables should be pure functions (no network calls, database writes, or logging directly inside them). Use LaunchedEffect or DisposableEffect for side effects.

4. Optimize Recompositions

Minimize unnecessary recompositions by:

  • Using remember for expensive calculations.
  • Passing stable objects (data classes) as parameters.
  • Avoiding lambda parameters that change on every recomposition.

5. Test Early and Often

Write unit tests for composables to catch UI regressions.

Conclusion

Jetpack Compose revolutionizes Android UI development with its declarative paradigm, Kotlin-first approach, and powerful tooling. By adopting Compose, you can build modern, responsive UIs with less code, faster iteration, and seamless integration with Android’s architecture components.

Whether you’re a seasoned Android developer or just starting, Compose’s intuitive API and rich ecosystem make it easier than ever to create beautiful apps. Dive into the official documentation, experiment with codelabs, and join the vibrant Compose community to unlock its full potential.

References

Happy composing! 🚀