Migrating from XML layouts to Jetpack Compose is now the most impactful modernization you can undertake for an Android codebase. With Compose 1.7 stable and performance parity with Views well-established, the question isn’t whether to migrate—it’s how to do it without disrupting your shipping cadence.

After migrating several production apps ranging from 50 to 400 screens, we’ve developed a battle-tested approach. This guide covers the practical realities: when to use interoperability versus rewriting, how to handle complex RecyclerViews, and the patterns that make incremental migration sustainable.

Assessing Your Migration Path

Not every screen deserves the same migration approach. Start by categorizing your UI components based on complexity and change frequency.

Migration Priority Matrix

High Priority (Migrate First):

  • Screens under active development
  • Simple forms and static content
  • New features not yet built
  • Components with extensive custom view code

Medium Priority:

  • Settings screens and preferences
  • Profile and account screens
  • Moderate complexity lists

Lower Priority (Migrate Later):

  • Complex custom views with heavy Canvas drawing
  • Screens that rarely change
  • Third-party library integrations that require Views
// Document your migration status in a sealed class
sealed class MigrationStatus {
    data object FullyCompose : MigrationStatus()
    data object Hybrid : MigrationStatus()
    data object LegacyViews : MigrationStatus()
}

// Track per-screen status
val screenMigrationStatus = mapOf(
    "HomeScreen" to MigrationStatus.FullyCompose,
    "ProfileScreen" to MigrationStatus.Hybrid,
    "LegacyPaymentScreen" to MigrationStatus.LegacyViews
)

Setting Up Your Project for Compose

Before writing Compose code, ensure your project configuration supports incremental adoption.

Gradle Configuration

// build.gradle.kts (app module)
android {
    compileSdk = 35

    defaultConfig {
        minSdk = 24 // Compose supports API 21+, but 24+ recommended
    }

    buildFeatures {
        compose = true
        viewBinding = true // Keep for interop with existing Views
    }

    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.14"
    }
}

dependencies {
    // Compose BOM for consistent versions
    val composeBom = platform("androidx.compose:compose-bom:2026.05.00")
    implementation(composeBom)

    // Core Compose dependencies
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.ui:ui-graphics")
    implementation("androidx.compose.ui:ui-tooling-preview")
    implementation("androidx.compose.material3:material3")

    // Interoperability with Views
    implementation("androidx.compose.ui:ui-viewbinding")
    implementation("androidx.activity:activity-compose:1.9.0")
    implementation("androidx.fragment:fragment-compose:1.8.0")

    // Navigation
    implementation("androidx.navigation:navigation-compose:2.8.0")

    // Debug tooling
    debugImplementation("androidx.compose.ui:ui-tooling")
    debugImplementation("androidx.compose.ui:ui-test-manifest")
}

Interoperability: Views in Compose and Com

pose in Views

The key to sustainable migration is mastering interoperability. You’ll use both directions extensively.

Using Views Inside Compose (AndroidView)

For complex Views that aren’t worth rewriting immediately, embed them in Compose using AndroidView.

@Composable
fun LegacyChartView(
    data: ChartData,
    modifier: Modifier = Modifier
) {
    AndroidView(
        factory = { context ->
            // Create the legacy View
            MPAndroidChart(context).apply {
                // Initial configuration
                setTouchEnabled(true)
                setDrawGridBackground(false)
                description.isEnabled = false
            }
        },
        update = { chart ->
            // Update when data changes
            chart.data = data.toMPChartData()
            chart.invalidate()
        },
        modifier = modifier
    )
}

// Use in a Compose screen
@Composable
fun AnalyticsDashboard(viewModel: AnalyticsViewModel) {
    val chartData by viewModel.chartData.collectAsState()

    Column(modifier = Modifier.fillMaxSize()) {
        Text(
            "Revenue Trends",
            style = MaterialTheme.typography.headlineMedium
        )

        LegacyChartView(
            data = chartData,
            modifier = Modifier
                .fillMaxWidth()
                .height(300.dp)
        )

        // Rest of the UI in Compose
        MetricCards(viewModel.metrics)
    }
}

Using Compose Inside Views (ComposeView)

For adding Compose to existing Activities or Fragments, use ComposeView.

// In an existing Fragment
class ProfileFragment : Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        return ComposeView(requireContext()).apply {
            setViewCompositionStrategy(
                ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
            )
            setContent {
                YourAppTheme {
                    ProfileScreen(
                        viewModel = viewModel(),
                        onNavigateToSettings = { findNavController().navigate(R.id.settings) }
                    )
                }
            }
        }
    }
}

// Or embed Compose in an XML layout
// layout/fragment_hybrid.xml
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <include layout="@layout/legacy_header" />

    <androidx.compose.ui.platform.ComposeView
        android:id="@+id/compose_content"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1" />

    <include layout="@layout/legacy_footer" />
</LinearLayout>

// In the Fragment
class HybridFragment : Fragment(R.layout.fragment_hybrid) {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        view.findViewById<ComposeView>(R.id.compose_content).apply {
            setViewCompositionStrategy(
                ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
            )
            setContent {
                YourAppTheme {
                    MainContent()
                }
            }
        }
    }
}

Migrating Complex

RecyclerViews

RecyclerView migration is often the most challenging part. Compose’s LazyColumn and LazyRow replace RecyclerView, but complex adapters require careful translation.

Basic RecyclerView to LazyColumn

// Before: RecyclerView with Adapter
class TaskAdapter : ListAdapter<Task, TaskViewHolder>(TaskDiffCallback()) {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TaskViewHolder {
        val binding = ItemTaskBinding.inflate(
            LayoutInflater.from(parent.context), parent, false
        )
        return TaskViewHolder(binding)
    }

    override fun onBindViewHolder(holder: TaskViewHolder, position: Int) {
        holder.bind(getItem(position))
    }
}

// After: LazyColumn with Composables
@Composable
fun TaskList(
    tasks: List<Task>,
    onTaskClick: (Task) -> Unit,
    onTaskComplete: (Task) -> Unit,
    modifier: Modifier = Modifier
) {
    LazyColumn(
        modifier = modifier,
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        items(
            items = tasks,
            key = { task -> task.id } // Stable keys for animations
        ) { task ->
            TaskItem(
                task = task,
                onClick = { onTaskClick(task) },
                onComplete = { onTaskComplete(task) }
            )
        }
    }
}

@Composable
fun TaskItem(
    task: Task,
    onClick: () -> Unit,
    onComplete: () -> Unit,
    modifier: Modifier = Modifier
) {
    Card(
        modifier = modifier.fillMaxWidth(),
        onClick = onClick
    ) {
        Row(
            modifier = Modifier.padding(16.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Checkbox(
                checked = task.isComplete,
                onCheckedChange = { onComplete() }
            )
            Column(modifier = Modifier.weight(1f)) {
                Text(
                    text = task.title,
                    style = MaterialTheme.typography.bodyLarge
                )
                Text(
                    text = task.dueDate.format(),
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
            }
        }
    }
}

Multi-ViewType RecyclerViews

Complex adapters with multiple view types translate to when expressions in Compose.

// Before: Multiple view types in adapter
override fun getItemViewType(position: Int): Int {
    return when (getItem(position)) {
        is FeedItem.Header -> VIEW_TYPE_HEADER
        is FeedItem.Post -> VIEW_TYPE_POST
        is FeedItem.Ad -> VIEW_TYPE_AD
        is FeedItem.Suggestion -> VIEW_TYPE_SUGGESTION
    }
}

// After: Composable with sealed class handling
sealed class FeedItem {
    data class Header(val title: String, val subtitle: String) : FeedItem()
    data class Post(val id: String, val author: User, val content: PostContent) : FeedItem()
    data class Ad(val id: String, val creative: AdCreative) : FeedItem()
    data class Suggestion(val users: List<User>) : FeedItem()
}

@Composable
fun FeedList(
    items: List<FeedItem>,
    onPostClick: (String) -> Unit,
    onUserClick: (String) -> Unit,
    modifier: Modifier = Modifier
) {
    LazyColumn(modifier = modifier) {
        items(
            items = items,
            key = { item ->
                when (item) {
                    is FeedItem.Header -> "header_${item.title}"
                    is FeedItem.Post -> "post_${item.id}"
                    is FeedItem.Ad -> "ad_${item.id}"
                    is FeedItem.Suggestion -> "suggestions"
                }
            },
            contentType = { item ->
                // Helps Compose reuse compositions efficiently
                when (item) {
                    is FeedItem.Header -> "header"
                    is FeedItem.Post -> "post"
                    is FeedItem.Ad -> "ad"
                    is FeedItem.Suggestion -> "suggestion"
                }
            }
        ) { item ->
            when (item) {
                is FeedItem.Header -> HeaderItem(item)
                is FeedItem.Post -> PostItem(item, onClick = { onPostClick(item.id) })
                is FeedItem.Ad -> AdItem(item)
                is FeedItem.Suggestion -> SuggestionRow(item, onUserClick)
            }
        }
    }
}

State Management Migration

Moving from ViewModel with LiveData to Compose-aware state requires pattern updates.

From LiveData to StateFlow

// Before: ViewModel with LiveData
class TaskViewModel : ViewModel() {
    private val _tasks = MutableLiveData<List<Task>>()
    val tasks: LiveData<List<Task>> = _tasks

    private val _isLoading = MutableLiveData<Boolean>()
    val isLoading: LiveData<Boolean> = _isLoading

    fun loadTasks() {
        _isLoading.value = true
        viewModelScope.launch {
            _tasks.value = repository.getTasks()
            _isLoading.value = false
        }
    }
}

// After: ViewModel with StateFlow
class TaskViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(TaskUiState())
    val uiState: StateFlow<TaskUiState> = _uiState.asStateFlow()

    init {
        loadTasks()
    }

    private fun loadTasks() {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }

            try {
                val tasks = repository.getTasks()
                _uiState.update {
                    it.copy(tasks = tasks, isLoading = false, error = null)
                }
            } catch (e: Exception) {
                _uiState.update {
                    it.copy(isLoading = false, error = e.message)
                }
            }
        }
    }

    fun toggleTask(taskId: String) {
        viewModelScope.launch {
            repository.toggleTask(taskId)
            // Optimistic update
            _uiState.update { state ->
                state.copy(
                    tasks = state.tasks.map { task ->
                        if (task.id == taskId) task.copy(isComplete = !task.isComplete)
                        else task
                    }
                )
            }
        }
    }
}

data class TaskUiState(
    val tasks: List<Task> = emptyList(),
    val isLoading: Boolean = false,
    val error: String? = null
)

// In Compose
@Composable
fun TaskScreen(viewModel: TaskViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsState()

    when {
        uiState.isLoading -> LoadingIndicator()
        uiState.error != null -> ErrorMessage(uiState.error!!)
        else -> TaskList(
            tasks = uiState.tasks,
            onToggle = viewModel::toggleTask
        )
    }
}

Migrating navigation is often the final phase. You can run both Navigation Component and Navigation Compose simultaneously.

// Hybrid navigation setup
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            YourAppTheme {
                val navController = rememberNavController()

                NavHost(
                    navController = navController,
                    startDestination = "home"
                ) {
                    // Fully Compose destinations
                    composable("home") {
                        HomeScreen(
                            onNavigateToProfile = { navController.navigate("profile") },
                            onNavigateToLegacy = { navController.navigate("legacy") }
                        )
                    }

                    composable("profile") {
                        ProfileScreen(navController)
                    }

                    // Hybrid: Fragment-based screen wrapped in Compose
                    composable("legacy") {
                        AndroidViewBinding(FragmentLegacyContainerBinding::inflate) {
                            val fragment = LegacyFeatureFragment()
                            childFragmentManager.beginTransaction()
                                .replace(this.fragmentContainer.id, fragment)
                                .commit()
                        }
                    }

                    // Deep link support
                    composable(
                        route = "task/{taskId}",
                        arguments = listOf(navArgument("taskId") { type = NavType.StringType }),
                        deepLinks = listOf(navDeepLink { uriPattern = "app://tasks/{taskId}" })
                    ) { backStackEntry ->
                        TaskDetailScreen(
                            taskId = backStackEntry.arguments?.getString("taskId") ?: ""
                        )
                    }
                }
            }
        }
    }
}

Testing Your Migrated Code

Compose provides excellent testing APIs. Migrate your Espresso tests incrementally.

@RunWith(AndroidJUnit4::class)
class TaskListTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun taskList_displaysItems() {
        val tasks = listOf(
            Task(id = "1", title = "Buy groceries", isComplete = false),
            Task(id = "2", title = "Call mom", isComplete = true)
        )

        composeTestRule.setContent {
            YourAppTheme {
                TaskList(
                    tasks = tasks,
                    onTaskClick = {},
                    onTaskComplete = {}
                )
            }
        }

        composeTestRule.onNodeWithText("Buy groceries").assertIsDisplayed()
        composeTestRule.onNodeWithText("Call mom").assertIsDisplayed()
    }

    @Test
    fun taskItem_clickTriggersCallback() {
        var clickedTask: Task? = null
        val task = Task(id = "1", title = "Test task", isComplete = false)

        composeTestRule.setContent {
            TaskItem(
                task = task,
                onClick = { clickedTask = task },
                onComplete = {}
            )
        }

        composeTestRule.onNodeWithText("Test task").performClick()

        assert(clickedTask == task)
    }
}

Conclusion

Migrating to Jetpack Compose is a marathon, not a sprint. The most successful migrations we’ve seen take 6-12 months for medium-sized apps, with teams shipping features throughout the process.

Start with new screens in Compose. Use interoperability liberally—there’s no shame in AndroidView for complex legacy components. Migrate RecyclerViews strategically, prioritizing the ones you modify frequently. Convert to StateFlow as you touch ViewModels. Save navigation for last.

The payoff is substantial: faster UI development, fewer bugs from XML/code mismatches, and a codebase that attracts Kotlin-native developers. With Compose now the default for new Android projects, migration is an investment in your app’s future.


Planning a Compose migration for your Android app? The Awesome Apps team has guided multiple Australian companies through successful migrations. Reach out to discuss your modernization strategy.