Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 081 / 100

Migrating from XML views to Compose in Android & Kotlin

Adopt Compose gradually.

Expona Admin9 min read

Introduction

Moving from XML views to Compose doesn't mean ripping out your entire codebase on day one. We've seen teams paralyzed by the scale of a complete migration, and we've seen others succeed by adopting Compose gradually—embedding it piece by piece into their existing XML layouts until the whole application has evolved. Migrating from XML views to Compose in Android & Kotlin is not a binary choice; it's a spectrum, and the smartest teams treat it as a multi-sprint strategy.

Your production app runs on XML layouts right now. Your team knows them. Your business metrics depend on them. So why rush? In this article, we'll walk through the practical patterns that let you ship Compose screens alongside XML without breaking your app, how to bridge your design system, and what sequence makes sense for a real team operating under deadline pressure.

ComposeView: Embed Compose Inside XML

The fastest way to start using Compose is to drop a ComposeView into your existing XML layouts. This is your entry ramp. You don't need to rewrite the whole screen; you can replace individual components or entire sections of a layout with Compose logic while keeping the navigation, lifecycle management, and parent Activity or Fragment exactly as it is today.

Here's a concrete scenario: your app has an XML-based product listing screen, but your design system team has just created a new Compose-based filter chip component. Instead of waiting for a full screen rewrite, you embed the filter UI in a ComposeView.

kotlin
// activity_product_list.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <androidx.compose.ui.platform.ComposeView
        android:id="@+id/filter_compose_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <RecyclerView
        android:id="@+id/product_list"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1" />

</LinearLayout>

Then in your Activity or Fragment, you set the Compose content on that view:

kotlin
class ProductListActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_product_list)

        val composeView = findViewById<ComposeView>(R.id.filter_compose_view)
        composeView.setContent {
            MaterialTheme {
                FilterChips(
                    filters = listOf("Price", "Rating", "In Stock"),
                    onFilterSelected = { filter ->
                        // Update your ViewModel or LiveData
                    }
                )
            }
        }
    }
}

@Composable
fun FilterChips(
    filters: List<String>,
    onFilterSelected: (String) -> Unit
) {
    var selectedFilter by remember { mutableStateOf<String?>(null) }
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp)
            .horizontalScroll(rememberScrollState()),
        horizontalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        filters.forEach { filter ->
            FilterChip(
                selected = selectedFilter == filter,
                onClick = {
                    selectedFilter = filter
                    onFilterSelected(filter)
                },
                label = { Text(filter) }
            )
        }
    }
}

This approach keeps your Activity lifecycle management simple. Compose respects the Activity's onCreate, onResume, onDestroy cycle. Your View Model, LiveData, and state management patterns continue to work unchanged. You've injected Compose into XML; you haven't replaced XML wholesale.

Why this matters in production: your QA team doesn't have to re-test the entire screen on day one. You're testing one new UI component. Your feature flags, logging, and crash reporting continue without modification. You deploy to production, measure, iterate, then do the next section.

AndroidView: Embed XML Views Inside Compose Screens

As you build new screens in Compose, you'll sometimes need legacy XML components or third-party views that don't have Compose equivalents yet. That's where AndroidView comes in. It wraps a traditional View and drops it into your Compose tree.

Common scenario: you've migrated half your app to Compose, and now you're building a new checkout screen. The payment form is already rock-solid in XML (tested to death, works with your legacy payment library). Rather than rewrite it in Compose, you embed it:

kotlin
@Composable
fun CheckoutScreen(
    viewModel: CheckoutViewModel = hiltViewModel()
) {
    val uiState by viewModel.uiState.collectAsState()
    
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {
        // Compose sections
        Text("Order Summary", style = MaterialTheme.typography.headlineSmall)
        OrderSummary(order = uiState.order)
        
        Spacer(modifier = Modifier.height(16.dp))
        
        // Embedded XML view
        AndroidView(
            factory = { context ->
                LayoutInflater.from(context).inflate(
                    R.layout.legacy_payment_form,
                    null
                )
            },
            modifier = Modifier
                .fillMaxWidth()
                .height(300.dp),
            update = { view ->
                val emailField = view.findViewById<EditText>(R.id.email)
                val cardField = view.findViewById<EditText>(R.id.card)
                // Update bindings when state changes
                emailField.setText(uiState.email)
                cardField.setText(uiState.cardLast4)
            }
        )
        
        Spacer(modifier = Modifier.height(16.dp))
        
        Button(
            onClick = { viewModel.submitOrder() },
            modifier = Modifier.fillMaxWidth()
        ) {
            Text("Place Order")
        }
    }
}

The factory lambda inflates the XML layout once. The update lambda is called whenever your Compose state changes, so you can sync data between the Compose side and the View side. This is a bridge, not a permanent solution—but it buys you time to rewrite the payment form in Compose without blocking the screen launch.

Production consideration: AndroidView has lifecycle overhead. If you have dozens of them on screen, performance will degrade. Use this for large, self-contained components, not for every button in a Compose screen.

Screen by Screen: New Screens First

Your migration strategy matters as much as the technology. We recommend this sequence:

  1. New screens first. Any screen your team designs fresh? Build it in Compose. No legacy code to carry forward, no XML to maintain in parallel.
  2. Low-risk screens second. Screens with minimal business logic and high confidence in test coverage. Think about a settings screen, a help page, a simple profile view.
  3. Core transaction screens last. Your checkout, login, or payment flow. These get migrated when your team is comfortable and has built muscle memory with Compose patterns.

Here's why this order works: new screens generate real Compose code that other engineers learn from. Low-risk screens build confidence and establish shared patterns for navigation, state, and theming. By the time you're on core screens, you've learned what works and what doesn't in your codebase.

Practically, this means:

  • Your navigation graph keeps both XML and Compose destinations. Android's Navigation component supports both.
  • Your ViewModel layer stays untouched. A Compose screen uses the same ViewModel as an XML screen.
  • Your analytics, logging, and crash reporting fire the same way whether a screen is Compose or XML.

A real team might go: feature screens (Compose) → onboarding flow (Compose) → home feed (mixed ComposeView + XML RecyclerView) → checkout (gradual, AndroidView for payment) → settings (Compose). This takes 3–6 months, not 3 weeks.

Shared Theme: Bridge Your XML Colors and Typography to MaterialTheme

Here's a painful detail: your app has an XML theme with colors, dimensions, and typography defined in colors.xml, dimens.xml, and styles.xml. Your Compose screens need to reference the same palette and type scale, or your app will look fractured—one part XML-styled, one part Compose-styled.

The bridge is to build a Compose MaterialTheme that sources values from your XML resources.

kotlin
// ui/theme/Color.kt
package com.example.shop.ui.theme

import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext

val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)

val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)

// color.xml reference
@Composable
fun colorFromXmlResource(resourceId: Int): Color {
    val context = LocalContext.current
    return Color(context.getColor(resourceId))
}
kotlin
// ui/theme/Type.kt
package com.example.shop.ui.theme

import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

val AppTypography = Typography(
    displayLarge = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Bold,
        fontSize = 32.sp
    ),
    headlineSmall = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.SemiBold,
        fontSize = 20.sp
    ),
    bodyMedium = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Normal,
        fontSize = 14.sp
    )
)
kotlin
// ui/theme/Theme.kt
package com.example.shop.ui.theme

import android.os.Build
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext

private val DarkColorScheme = darkColorScheme(
    primary = Purple80,
    secondary = PurpleGrey80,
    tertiary = Pink80
)

private val LightColorScheme = lightColorScheme(
    primary = Purple40,
    secondary = PurpleGrey40,
    tertiary = Pink40
)

@Composable
fun ShopTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,
    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
    }

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

Now when you use ComposeView in XML, wrap it:

kotlin
composeView.setContent {
    ShopTheme {
        FilterChips(filters = listOf("Price", "Rating", "In Stock"))
    }
}

And when you build new Compose screens:

kotlin
@Composable
fun HomeScreen() {
    ShopTheme {
        Column(modifier = Modifier.fillMaxSize()) {
            // Your Compose UI
        }
    }
}

Your XML app and Compose app now share the same visual language. No inconsistent colors, no duplicate definitions, no design system drift.

Common Mistakes

Mistake 1: Starting with ComposeView everywhere. If you embed Compose in every XML screen, you end up with the worst of both worlds—two UI frameworks, two state patterns, twice the complexity. Use ComposeView for isolated components or as a temporary measure while migrating a specific section.

Mistake 2: Ignoring performance of AndroidView. Embedding dozens of legacy Views inside Compose can tank performance. Recomposition still happens, and each AndroidView is a bridge with overhead. If you have multiple AndroidViews, profile your frame rate and memory usage before shipping.

Mistake 3: Skipping theme alignment. Teams rush to build Compose screens and forget to plug them into the shared MaterialTheme. Suddenly, one screen looks polished and one screen looks hastily thrown together. The design debt compounds. Invest 2–3 hours early to unify colors, typography, and spacing.

Mistake 4: Migrating before your team is ready. If half your team has never used Compose, you'll spend weeks in code review answering basic questions. Get the team on a Compose training course or hackathon first. Pair experienced engineers with newer ones on the first three screens.

Mistake 5: Not using feature flags. Always ship Compose screens behind a feature flag. If there's a subtle UI bug, you can roll back without a hotfix. If performance degrades, you disable it and investigate. Feature flags cost 15 minutes to set up and save you from production chaos.

Key Takeaways

  • Migrate gradually using ComposeView to embed Compose in existing XML screens and AndroidView to embed XML views in Compose.
  • Prioritize new screens and low-risk screens for Compose first; save core transaction screens for when your team has mastery.
  • Build a shared MaterialTheme that bridges your XML colors, typography, and dimensions so your app looks cohesive during the transition.
  • Use feature flags to safely roll out Compose screens and roll back if needed without requiring a hotfix.
  • Invest in team training early; Compose code quality depends on the team's familiarity with reactive composition and state management.
  • Profile performance before shipping, especially if you're using multiple AndroidViews or ComposeViews on a single screen.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles