Skip to content
← All Posts
·6 min read

From XML Layouts to Jetpack Compose — A Practical Migration Guide

What actually changes when you switch from XML to Compose, the gotchas nobody warns you about, and why I'd never go back.

AndroidJetpack ComposeKotlinUI

I built my first Android screen in XML. Two screens later, I switched to Jetpack Compose. Three apps later, I've never touched XML again. But the switch wasn't instant — there was a solid two-week period where Compose felt harder, not easier. Here's what the transition actually looks like.

The XML Comfort Zone

XML layouts are familiar if you've ever written HTML. You define views declaratively, set attributes, nest them in layout containers. Android Studio's Layout Editor gives you a visual preview. Data binding or view binding connects your XML to Kotlin code. It works.

The problems show up when things get dynamic. Updating a TextView based on state requires finding the view by ID, setting the text, maybe updating visibility, and handling nullability. A moderately complex screen with a list, header, loading state, and error state ends up with 200+ lines of XML and another 200 lines of Kotlin just to wire everything together. State management becomes a mess of LiveData observers, ViewBinding references, and manual view updates.

The First Week of Compose (It Gets Worse Before It Gets Better)

My first Compose screen took longer than it would have in XML. I was fighting the framework instead of working with it. I kept trying to "find" views and "set" values — the imperative mindset doesn't translate.

The conceptual shift is this: in XML, you create views once and mutate them. In Compose, you describe what the UI should look like for a given state, and the framework handles the rest. You don't update a text field — you declare that a text field shows state.username, and when state changes, the framework re-renders the text field automatically.

Once this clicked, everything accelerated. Screen development went from hours to minutes. A full settings screen with toggles, dropdowns, and navigation — maybe 40 minutes in Compose. The same screen in XML with proper data binding? Half a day.

State Management: The Core Difference

In XML Android, state is scattered. Some lives in the ViewModel as LiveData. Some lives in the Fragment as local variables. Some is implicit in the view hierarchy (is this checkbox checked? Go ask the view). Synchronizing all of this is where most bugs come from.

In Compose, state flows in one direction. The ViewModel holds the source of truth as a StateFlow. The composable function reads that state and renders accordingly. User actions trigger callbacks that update the ViewModel, which emits new state, which triggers recomposition. The cycle is predictable, testable, and hard to break.

I use a pattern where each screen has a single UiState data class:

The ViewModel exposes a StateFlow<CalculatorUiState>, and the composable observes it with collectAsStateWithLifecycle(). Every user action goes through the ViewModel. The UI never mutates state directly. This pattern scales beautifully — Smart Calculator has 15 different tools, each with its own state, and the code stays manageable because every screen follows the same flow.

Recomposition: The Gotcha Nobody Warns You About

Compose re-executes your composable functions whenever state changes. This is called recomposition, and it's both the magic and the trap. If you put a Log.d() inside a composable, you'll see it fires far more often than you expect.

The problem comes when you do expensive work inside a composable — allocating objects, computing lists, formatting strings. If a parent composable recomposes, all its children recompose too (unless they can be skipped). I had a screen in Smart Calculator where typing a single character triggered recomposition of the entire calculator grid because I was creating new lambda instances on every render.

The fix is remember and derivedStateOf. Use remember to cache values across recompositions. Use derivedStateOf for computed values that only change when their inputs change. Use key() to help Compose identify items in lists. And keep your composables small — a composable that renders a single card is easier for the framework to skip than one that renders an entire screen.

Material Design 3 Integration

Compose and Material 3 are built for each other. The theming system uses MaterialTheme with a ColorScheme, Typography, and Shapes — all Kotlin objects, no XML themes or styles.

Dynamic color (extracting colors from the user's wallpaper) is a single line: dynamicDarkColorScheme(context). Dark mode support is trivial — provide two color schemes and switch between them. Custom theming is just creating your own ColorScheme.

The component library covers everything: TopAppBar, NavigationBar, Card, TextField, Switch, FloatingActionButton. They all follow Material 3 specs out of the box, including motion, elevation, and color mapping. I rarely build custom components from scratch — the provided ones handle 90% of use cases.

Performance: Compose vs XML

There's a myth that Compose is slower than XML. In practice, the difference is negligible for most apps. Compose's initial load might be 10-20ms slower than inflating a simple XML layout, but recomposition is faster than manually rebinding views.

Where Compose genuinely wins is in lists. LazyColumn with proper key parameters is as fast as RecyclerView with DiffUtil, but with a fraction of the boilerplate. No adapter, no view holder, no layout manager setup. Just LazyColumn { items(list, key = { it.id }) { item -> ItemCard(item) } }.

The one place I've seen Compose struggle is deeply nested layouts with many animations running simultaneously. Vixit's timeline view with 50+ video thumbnails animating during scroll caused frame drops. The fix was extracting the thumbnail rendering into a separate composable with remember for the bitmap loading and using Modifier.drawWithCache instead of Image composables for better GPU batching.

The Parts I Don't Miss

`findViewById` — gone. No more null pointer exceptions from finding a view that doesn't exist in the current layout variant.

Fragment lifecycle — Compose navigation replaces fragments entirely. No more onCreateView, onViewCreated, onDestroyView lifecycle confusion.

RecyclerView adapters — writing an adapter with a ViewHolder and DiffUtil callback for every list was tedious. LazyColumn eliminates all of it.

XML layout variants — portrait, landscape, tablet, phone — Compose handles this with simple if statements and BoxWithConstraints. No more maintaining four versions of the same layout.

Should You Migrate?

If you're starting a new project: absolutely use Compose. There's no reason to learn XML in 2025.

If you have an existing XML project: migrate screen by screen. Compose and XML coexist perfectly — you can embed Compose in XML with ComposeView and XML in Compose with AndroidView. I migrated Smart Calculator over two weeks, one screen at a time, and the app never broke.

The honest truth: The first week will feel slower. The second week, you'll be as fast as XML. By the third week, you'll wonder how you ever tolerated the old way.

Enjoyed this post?

I'm available for freelance Android and web development projects.