Kotlin for Android with Jetpack Compose

70% of New Android Projects on GitHub Use Compose. XML Is Fading.

That number isn’t some made-up stat from a keynote slide. Go browse the trending Android repositories right now. Count the XML layout files versus Compose functions. The shift has already happened. We’re just arguing about whether to call it a migration or a revolution.

And honestly? I think the XML holdouts are running out of arguments.

For the better part of a decade, building Android UIs meant writing XML layouts and stitching them to your Kotlin or Java code with findViewById, then View Binding, then data binding — each solution papering over the fundamental awkwardness of describing your interface in one language and controlling it in another. Two files for every screen. Two mental models. Two places where things could go wrong.

Jetpack Compose throws all of that out. Gone. No XML. No binding. No inflation. You write Kotlin functions that describe what your UI should look like given the current state, and Compose handles the rest. When state changes, only the affected parts re-render. No manual view updates. No adapter notifications. No “did I forget to call notifyDataSetChanged again” debugging sessions at 11 PM.

If you’ve used React, SwiftUI, or Flutter, the mental model will feel familiar. If you haven’t — if you’ve been living in XML-land for years — buckle up. Once this clicks, you won’t want to go back.

Composable Functions: Everything Is a Function

In Compose, every piece of UI is a function annotated with @Composable. Not a class. Not an XML tag. A function. It doesn’t return anything — instead, it emits UI elements by calling other composable functions inside its body.

import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview

@Composable
fun Greeting(name: String) {
    Text(
        text = "Hello, $name!",
        style = MaterialTheme.typography.headlineMedium,
        color = MaterialTheme.colorScheme.primary
    )
}

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    MaterialTheme {
        Greeting(name = "ByteYogi")
    }
}

Simple enough, right? But there’s something subtle happening here that deserves attention. That @Preview annotation? It lets you see your composable rendered in Android Studio’s preview pane without running the app on a device or emulator. You tweak the code, the preview updates. No waiting for a build. No deploying to a phone. Just… seeing what you wrote, instantly.

If you’ve ever waited 45 seconds for a Gradle build to finish just to check whether your padding looks right, you already understand why this matters. The feedback loop goes from “minutes” to “seconds.” Productivity gains don’t come from writing code faster. They come from seeing results faster.

State: The One Concept That Changes Everything

Here’s where Compose gets interesting — and where most beginners trip up.

Composable functions re-execute whenever their inputs change. Compose calls this recomposition. If you declare a variable inside a composable function with a regular var, it gets wiped out and re-initialized every single time recomposition happens. Your counter goes back to zero. Your text field goes blank. Your user rage-quits.

The fix: remember combined with mutableStateOf.

import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }

    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center,
        modifier = Modifier.fillMaxSize()
    ) {
        Text(
            text = "Count: $count",
            style = MaterialTheme.typography.displayMedium
        )

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

        Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
            Button(onClick = { count-- }) {
                Text("Decrease")
            }
            Button(onClick = { count++ }) {
                Text("Increase")
            }
        }
    }
}

remember tells Compose: “Hold onto this value across recompositions. Don’t throw it away.” And mutableStateOf makes it reactive — when the value changes, Compose knows exactly which composables depend on it and re-renders only those. Not the whole screen. Not the whole tree. Just the bits that actually need updating.

That by keyword, in case you’re wondering, is Kotlin’s property delegation. It lets you read and write count as a plain integer instead of calling count.value everywhere. Small syntactic convenience, big readability win.

Now, remember survives recomposition but not configuration changes. Rotate the phone and your count resets to zero. For state that needs to survive rotation, process death, and other lifecycle events, swap remember for rememberSaveable. Or better yet, move your state into a ViewModel. Which brings us to a bigger question.

Where Should State Live? (Strong Opinion Ahead)

State hoisting. If you take one architectural concept from this post, make it this one.

The idea is simple: push state up. A composable that displays data shouldn’t own that data. It should receive the data as parameters and emit events (callbacks) when the user does something. The parent composable — or a ViewModel, or a state holder class — owns the state and decides what to do with those events.

Why? Because composables that don’t own state are reusable. Testable. Predictable. A TaskCard that takes a title, description, and an onComplete callback can be used anywhere in your app. A TaskCard that reaches into a ViewModel to fetch its own data is welded to that one screen forever.

I’ve seen codebases where every composable manages its own state, talks to its own repository, and fires its own network calls. It works for a prototype. It becomes a nightmare at 50 screens. Don’t learn this lesson the hard way — I already did.

Layouts: Column, Row, Box — That’s It

After years of ConstraintLayout, LinearLayout, RelativeLayout, FrameLayout, and whatever else the Android team invented during their layout-XML fever dream, Compose gives you three layout composables. Three.

Column stacks children vertically. Row lines them up horizontally. Box layers them on top of each other. Combined with the Modifier system, these three can express any layout you need.

Here’s a real-world example — a task card component:

@Composable
fun TaskCard(
    title: String,
    description: String,
    priority: String,
    onComplete: () -> Unit
) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp, vertical = 8.dp),
        elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically
            ) {
                Text(
                    text = title,
                    style = MaterialTheme.typography.titleMedium
                )
                AssistChip(
                    onClick = {},
                    label = { Text(priority) },
                    colors = AssistChipDefaults.assistChipColors(
                        containerColor = when (priority) {
                            "High" -> MaterialTheme.colorScheme.errorContainer
                            "Medium" -> MaterialTheme.colorScheme.secondaryContainer
                            else -> MaterialTheme.colorScheme.surfaceVariant
                        }
                    )
                )
            }

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

            Text(
                text = description,
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onSurfaceVariant
            )

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

            Button(
                onClick = onComplete,
                modifier = Modifier.align(Alignment.End)
            ) {
                Text("Mark Complete")
            }
        }
    }
}

Notice the Modifier chain on that Card. Modifiers in Compose are how you handle sizing, padding, background colors, click handling, scrolling — basically everything that isn’t the content itself. They chain together in order, which means .padding(16.dp).background(Color.Red) produces a different result than .background(Color.Red).padding(16.dp). First one: padding inside the red. Second one: padding outside the red. Order matters. Once you internalize that, the Modifier system becomes incredibly powerful.

Also notice how TaskCard doesn’t know anything about where its data comes from. It takes strings and a callback. Pure. Reusable. Testable. That’s state hoisting in action.

Putting It All Together: A Full Task List Screen

Let’s combine everything into a complete screen. A list of tasks, a floating action button to add new ones, a dialog for input, and the state management to wire it all up.

data class Task(
    val id: Int,
    val title: String,
    val description: String,
    val priority: String
)

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TaskListScreen() {
    var tasks by remember {
        mutableStateOf(
            listOf(
                Task(1, "Set up CI/CD", "Configure GitHub Actions pipeline", "High"),
                Task(2, "Write unit tests", "Cover repository layer", "Medium"),
                Task(3, "Update README", "Add setup instructions", "Low")
            )
        )
    }
    var showDialog by remember { mutableStateOf(false) }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("My Tasks") },
                colors = TopAppBarDefaults.topAppBarColors(
                    containerColor = MaterialTheme.colorScheme.primaryContainer
                )
            )
        },
        floatingActionButton = {
            FloatingActionButton(onClick = { showDialog = true }) {
                Text("+", style = MaterialTheme.typography.headlineSmall)
            }
        }
    ) { paddingValues ->
        LazyColumn(
            contentPadding = paddingValues,
            modifier = Modifier.fillMaxSize()
        ) {
            items(tasks.size) { index ->
                val task = tasks[index]
                TaskCard(
                    title = task.title,
                    description = task.description,
                    priority = task.priority,
                    onComplete = {
                        tasks = tasks.filterNot { it.id == task.id }
                    }
                )
            }
        }
    }

    if (showDialog) {
        AddTaskDialog(
            onDismiss = { showDialog = false },
            onAdd = { title, desc, priority ->
                val newId = (tasks.maxOfOrNull { it.id } ?: 0) + 1
                tasks = tasks + Task(newId, title, desc, priority)
                showDialog = false
            }
        )
    }
}

@Composable
fun AddTaskDialog(
    onDismiss: () -> Unit,
    onAdd: (String, String, String) -> Unit
) {
    var title by remember { mutableStateOf("") }
    var description by remember { mutableStateOf("") }
    var priority by remember { mutableStateOf("Medium") }

    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text("New Task") },
        text = {
            Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
                OutlinedTextField(
                    value = title,
                    onValueChange = { title = it },
                    label = { Text("Title") },
                    modifier = Modifier.fillMaxWidth()
                )
                OutlinedTextField(
                    value = description,
                    onValueChange = { description = it },
                    label = { Text("Description") },
                    modifier = Modifier.fillMaxWidth()
                )
                Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                    listOf("Low", "Medium", "High").forEach { level ->
                        FilterChip(
                            selected = priority == level,
                            onClick = { priority = level },
                            label = { Text(level) }
                        )
                    }
                }
            }
        },
        confirmButton = {
            TextButton(
                onClick = { onAdd(title, description, priority) },
                enabled = title.isNotBlank()
            ) {
                Text("Add")
            }
        },
        dismissButton = {
            TextButton(onClick = onDismiss) {
                Text("Cancel")
            }
        }
    )
}

A full, working task management screen. In a single Kotlin file. No XML. No layout inflation. No adapter. No ViewHolder. No RecyclerView boilerplate. Just functions calling functions.

Compare that to what the same screen would look like in the old world: activity_task_list.xml, item_task.xml, TaskAdapter.kt, TaskViewHolder.kt, dialog_add_task.xml, and then the Activity or Fragment tying it all together. Six files minimum. In Compose, it’s one file and maybe 120 lines of code. That’s not a marginal improvement. It’s a category change.

Compose vs XML: An Honest Comparison

I said I had strong opinions. Here they are.

Compose wins on developer productivity, code readability, state management, animations (seriously, try animating anything complex in XML — it’s painful), theming, and reuse. A well-structured Compose codebase is dramatically easier to read and modify than the equivalent XML-based one.

XML still has edges in a few areas. Some complex custom views are still easier to implement with the old View system. If your app has deep custom drawing requirements — canvas-based game UIs, complex chart libraries — you might find Compose’s Canvas API slightly less mature. And if you’re maintaining a large existing app, the migration path from XML to Compose is gradual by design. You don’t have to rewrite everything at once; ComposeView and AndroidView let you mix both systems in the same screen.

But here’s my honest take. If you’re starting a new Android project in 2026 and you choose XML for your UI layer, you need a very specific reason for that choice. “It’s what I know” isn’t a reason. It’s inertia. Compose is Google’s official recommended approach. It’s where the investment is going. The documentation, the codelabs, the new Material components — they’re all Compose-first now.

I watched a similar transition happen with RecyclerView replacing ListView. With Kotlin replacing Java for Android. Each time, there was resistance. Each time, the holdouts eventually migrated anyway, just later and more painfully. Compose is following the same trajectory, except faster.

One more thing on the comparison. Testing. In the XML world, UI testing meant Espresso — a framework that requires a running device or emulator, takes forever to set up, and produces flaky tests that break when the animation timing changes. Compose has built-in testing support with ComposeTestRule. You can test composables in isolation, without a device, in milliseconds. Write a test that sets state, asserts the UI shows the right text, simulates a click, and verifies the callback fires. All in plain Kotlin, all running on JVM. If you’ve ever spent an afternoon debugging why an Espresso test fails on CI but passes locally, you’ll understand why this matters.

Accessibility is another area where Compose quietly wins. In XML, making a view accessible often meant remembering to add contentDescription attributes, handling focus order manually, and testing with TalkBack on a real device. In Compose, accessibility semantics are built into the component model. Text composables are automatically accessible. Interactive elements like Button and Checkbox announce their roles without extra work. You can add custom semantics through the Modifier system. It’s still your responsibility to make good accessibility choices, but the framework does more of the heavy lifting by default.

Three Principles That’ll Save You Grief

After building several production apps with Compose since it hit 1.0, three principles keep proving themselves.

Keep composables side-effect free. Your composable functions describe what the UI looks like. They shouldn’t make network calls, write to databases, or trigger analytics events directly. Side effects belong in LaunchedEffect, SideEffect, or — better yet — in a ViewModel that the composable observes. Violate this rule and you’ll get bugs that fire during recomposition at the worst possible times.

Hoist your state. Already covered this, but it bears repeating because it’s the single most impactful architectural decision in a Compose app. Push state up. Pass data down. Emit events up. If you do nothing else right, do this.

Master the Modifier chain. Modifiers are Compose’s secret weapon. They handle layout, sizing, padding, interaction, accessibility, drawing, and animation. Learning to read and write modifier chains fluently will make you productive in Compose faster than anything else. When something looks wrong on screen, 80% of the time the fix is in the modifiers, not in the composable structure.

A bonus principle that I’d add for anyone coming from an XML background: stop thinking in terms of views and start thinking in terms of data flow. In the old system, you had a view tree and you mutated it. You found a TextView, changed its text. Found a RecyclerView, told its adapter the data changed. Compose inverts that. You have state, and the UI is a function of that state. Change the state, and the UI updates itself. You never tell a composable to update. You just change the data, and Compose figures out the rest. That mental shift is harder than learning the API. Once it clicks, though, entire categories of bugs just vanish. No more “I forgot to update the view” bugs. No more “the view and the data are out of sync” bugs. State goes in, UI comes out. Always.

What’s Coming Next for Compose

Compose isn’t standing still. Google’s been investing heavily, and the roadmap shows it.

Compose Multiplatform (from JetBrains) is pushing toward stable support for iOS, desktop, and web — all from the same Kotlin codebase. It’s not quite “write once, run everywhere” yet, but it’s getting there. Some teams I know are already sharing 70-80% of their UI code between Android and iOS via Compose Multiplatform. That’s a significant deal for shops that currently maintain separate native codebases.

Performance improvements keep landing with each release. Compiler optimizations, smarter recomposition skipping, better baseline profile generation. Apps built with Compose in 2026 are measurably faster than those built with Compose 1.0. The early concerns about Compose being “slower than XML” have been addressed — in many benchmarks now, Compose matches or beats traditional Views.

New components and APIs arrive regularly. Shared element transitions, adaptive layouts for foldables and tablets, improved accessibility tooling. The ecosystem is still expanding rapidly.

Five years from now, I suspect we’ll look back at XML layouts the way we look at AsyncTask today — a relic of a messier era that served its purpose and deserved to be replaced. If you haven’t started learning Compose yet, there’s genuinely no better time than now. The documentation is solid, the community is active, and the migration path is well-worn by thousands of apps that have already made the jump.

XML is fading. That 70% number from the title? By this time next year, I’d wager it’s closer to 85.

Leave a Comment

Your email address will not be published. Required fields are marked with an asterisk.