Jetpack Compose 2.0: Android UI Toolkit
Jetpack Compose 2.0 is the latest evolution of Android’s declarative UI toolkit, and it feels like a breath of fresh air for developers who have been wrestling with XML for years. With a more intuitive API, better performance, and a tighter integration with Kotlin, Compose 2.0 lets you build complex screens in a fraction of the time. In this article we’ll explore the core concepts, walk through a couple of real‑world examples, and sprinkle in some pro tips that can shave minutes—or even hours—off your development cycle.
Why Jetpack Compose 2.0 Matters
First, let’s address the “why”. Traditional Android UI relies on a mix of XML layouts, View hierarchies, and a lifecycle that can be tricky to manage. Compose replaces that with a single, Kotlin‑first paradigm where UI is just a function of state. This means:
- Less boilerplate: No more findViewById or view binding for every widget.
- Hot reload: Changes appear instantly in the preview, accelerating iteration.
- Better tooling: The new Compose Compiler 2.0 provides faster incremental builds and smarter diagnostics.
Beyond productivity, Compose 2.0 introduces stability guarantees that make recomposition cheaper, and a revamped Modifier system that feels more like a fluent DSL than a collection of static attributes.
Getting Started: The Basic Building Blocks
At its core, a composable is a Kotlin function annotated with @Composable. Think of it as a reusable UI component that can be combined with others to form a screen.
Simple Greeting Example
@Composable
fun Greeting(name: String) {
Text(
text = "Hello, $name!",
style = MaterialTheme.typography.h5,
modifier = Modifier.padding(16.dp)
)
}
This snippet demonstrates three essential concepts:
- The
@Composableannotation marks the function for the Compose compiler. - UI is described declaratively via composable functions like
Text. Modifierchains layout, drawing, and interaction behavior.
When Greeting is called from a higher‑level composable, Compose automatically tracks any state read inside and triggers a recomposition only when that state changes.
State Management in Compose 2.0
State is the lifeblood of a Compose UI. The toolkit provides remember, mutableStateOf, and derivedStateOf to keep data local to a composable, while ViewModel integration ensures a clean separation of concerns.
Counter with Remember
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().padding(24.dp)
) {
Text("You clicked $count times")
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { count++ }) {
Text("Increment")
}
}
}
Notice the by delegate syntax that makes the state variable feel like a regular Int. Compose watches count and only recomposes the parts of the UI that read it.
Pro tip: UserememberSaveableinstead ofrememberwhen you need the state to survive process death or configuration changes. It automatically persists simple types using the saved‑instance‑state bundle.
Layout System: Modifiers and Constraints
Compose’s layout engine revolves around Modifier. Unlike XML attributes, modifiers are immutable objects that you chain to describe size, padding, click handling, and more.
Responsive Card Layout
@Composable
fun ResponsiveCard(
title: String,
description: String,
imageRes: Int,
onClick: () -> Unit
) {
Card(
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(4.dp),
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.clickable(onClick = onClick)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Image(
painter = painterResource(id = imageRes),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(80.dp)
.clip(RoundedCornerShape(8.dp))
)
Spacer(modifier = Modifier.width(12.dp))
Column(
verticalArrangement = Arrangement.Center,
modifier = Modifier.weight(1f)
) {
Text(title, style = MaterialTheme.typography.titleMedium)
Text(
description,
style = MaterialTheme.typography.bodySmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
This card automatically adapts to screen width because it uses fillMaxWidth and weight. The Modifier.clickable adds touch feedback without any extra boilerplate.
Pro tip: When you need fine‑grained control over measurement, implement a customLayout. Compose 2.0’s newMeasurePolicyAPI is more ergonomic than the olderLayoutlambda.
Theming and Material 3 Integration
Compose 2.0 ships with first‑class support for Material 3 (Material You). Themes are now defined via a single ColorScheme and Typography object, making dark mode and dynamic color adoption trivial.
Setting Up a Material 3 Theme
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) darkColorScheme() else lightColorScheme()
MaterialTheme(
colorScheme = colorScheme,
typography = Typography(),
content = content
)
}
Wrap your entire app with AppTheme in setContent, and every composable automatically inherits the color palette. You can also pull dynamic colors from the OS with dynamicDarkColorScheme / dynamicLightColorScheme.
Navigation: From One Screen to Another
Compose Navigation 2.5+ aligns perfectly with the declarative mindset. Define destinations as composable functions and let the NavHost handle back‑stack management.
Simple NavHost Example
@Composable
fun MainNavGraph(startDestination: String = "home") {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = startDestination) {
composable("home") { HomeScreen(navController) }
composable(
"detail/{itemId}",
arguments = listOf(navArgument("itemId") { type = NavType.IntType })
) { backStackEntry ->
val id = backStackEntry.arguments?.getInt("itemId") ?: 0
DetailScreen(itemId = id, navController = navController)
}
}
}
Notice how the route string can contain placeholders, enabling type‑safe argument passing. Inside HomeScreen you would call navController.navigate("detail/$id") to transition.
Pro tip: Use the collectAsStateWithLifecycle extension when observing Flow or LiveData inside a composable. It respects the lifecycle and prevents memory leaks.
Real‑World Use Case: Building a News Feed
Let’s stitch together the concepts above into a mini news feed app. The UI will display a list of articles, support pull‑to‑refresh, and navigate to a detail screen on tap.
Data Model and ViewModel
data class Article(
val id: Int,
val title: String,
val summary: String,
@DrawableRes val thumbnail: Int
)
class NewsViewModel : ViewModel() {
private val _articles = MutableStateFlow>(emptyList())
val articles: StateFlow> = _articles.asStateFlow()
init { fetchArticles() }
fun fetchArticles() {
viewModelScope.launch {
// Simulate network delay
delay(1200)
_articles.value = sampleArticles()
}
}
}
The ViewModel exposes a StateFlow that the UI can collect as state.
List Screen
@Composable
fun NewsListScreen(
viewModel: NewsViewModel = viewModel(),
navController: NavHostController
) {
val articles by viewModel.articles.collectAsStateWithLifecycle()
val isRefreshing = remember { mutableStateOf(false) }
Scaffold(
topBar = { TopAppBar(title = { Text("Compose News") }) }
) { paddingValues ->
SwipeRefresh(
state = rememberSwipeRefreshState(isRefreshing.value),
onRefresh = {
isRefreshing.value = true
viewModel.fetchArticles()
isRefreshing.value = false
},
modifier = Modifier.padding(paddingValues)
) {
LazyColumn {
items(articles) { article ->
ResponsiveCard(
title = article.title,
description = article.summary,
imageRes = article.thumbnail,
onClick = {
navController.navigate("detail/${article.id}")
}
)
}
}
}
}
}
Key takeaways:
LazyColumnlazily composes only visible items, saving memory.SwipeRefresh(from Accompanist) adds native pull‑to‑refresh behavior.- Each card uses the
ResponsiveCardcomposable we defined earlier, demonstrating reuse.
Detail Screen
@Composable
fun DetailScreen(itemId: Int, navController: NavHostController) {
// In a real app you’d fetch the article by ID from a repository.
val article = remember(itemId) { sampleArticles().first { it.id == itemId } }
Scaffold(
topBar = {
TopAppBar(
title = { Text(article.title) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
) {
Image(
painter = painterResource(id = article.thumbnail),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
article.summary,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(16.dp)
)
}
}
}
The detail screen showcases how easy it is to compose scrollable content, add a custom top bar, and handle back navigation—all with a few lines of code.
Pro tip: When your UI grows complex, split it into smaller composables and keep each one under 50 LOC. This improves readability and makes recomposition analysis easier.
Performance Optimizations in Compose 2.0
Compose 2.0 introduces several compiler and runtime improvements that directly impact app performance.
- Stable‑parameter inference: The compiler now automatically marks many parameters as stable, reducing unnecessary recompositions.
- Skippable recomposition: If a composable’s inputs haven’t changed, Compose can skip its entire subtree.
- Improved snapshot system: State reads are batched more efficiently, leading to smoother UI updates.
To take advantage of these, follow a few best practices:
- Prefer immutable data classes for UI state.
- Mark custom classes with
@Stableif they expose mutable properties that you control. - Avoid passing large collections directly; use
derivedStateOfto compute a subset.
Testing Compose UI
Compose ships with a dedicated testing library that lets you verify UI behavior without a device. The API mirrors Espresso but works directly on composables.
Sample UI Test
@RunWith(AndroidJUnit4::class)
class NewsListTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun clickingCardNavigatesToDetail() {
composeTestRule.setContent {
val navController = rememberTestNavController()
NewsListScreen(navController = navController)
}
// Assume the first card contains "Breaking News"
composeTestRule
.onNodeWithText("Breaking News")
.performClick()
// Verify navigation
composeTestRule
.onNodeWithText("Breaking News")
.assertIsDisplayed()
}
}
The createComposeRule spins up a Compose environment, and onNodeWithText queries the UI tree. Tests run fast because they don’t need a full Android instrumented environment.
Migrating Existing XML Layouts to Compose
If you have a legacy codebase, you don’t have to rewrite everything at once. Compose 2.0 provides AndroidView and ComposeView bridges that let you embed composables inside XML and vice‑versa.
Embedding a Composable in XML
<androidx.compose.ui.platform.ComposeView
android:id="@+id/compose_greeting"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
In your activity:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
findViewById<ComposeView>(R.id.compose_greeting).setContent {
Greeting(name = "Compose 2.0")
}
}
}
This incremental approach lets you adopt Compose module by module, reducing risk and allowing you to measure performance gains early.
Pro tip: When mixing View and Compose, keep the hierarchy shallow. Deep nesting can cause layout passes to become expensive.
Advanced Topics: Custom Layouts and Animations
Compose’s animation APIs are declarative and concise. The new animateContentSize modifier, for example, automatically animates size changes without extra code.
Animated Expandable Card
@Composable
fun ExpandableCard(
title: String,
content: String,
modifier: Modifier = Modifier
) {
var expanded by remember { mutableStateOf(false) }
Card(
modifier = modifier
.fillMaxWidth()
.animateContentSize()
.clickable { expanded = !expanded },
elevation = CardDefaults.cardElevation(2.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium)
if (expanded) {
Spacer(modifier = Modifier.height(8.dp))
Text(content, style = MaterialTheme.typography.bodySmall)
}
}
}
}
When the user taps the card, expanded toggles, and the card smoothly animates its height change thanks to animateContentSize. No explicit animation specs