Droid Manual // Android Knowledge Base

Droid Manual

Migrating Fragments to Jetpack Compose

November 02, 2026 · 7 min read

Introduction

Migrating a mature Android app from XML-based Fragments to Jetpack Compose is rarely a sudden rewrite. In production environments, legacy UI code must coexist with modern composables while preserving navigation, state, and lifecycle correctness. This tutorial covers a pragmatic, incremental strategy that keeps both paradigms running side by side. You will learn how to embed Compose inside existing Fragments, how to embed Fragments inside Compose screens, how to share a single ViewModel across the boundary, and how to wire navigation so users never notice the transition. The goal is incremental progress without introducing architectural debt, using current, stable APIs from the AndroidX ecosystem.

Embedding Compose Inside a Fragment

The most common starting point is a traditional Fragment that renders a new Compose screen while the rest of the app remains in Fragments. The official way to do this is through ComposeView, a real Android View that hosts the Compose runtime and plays nicely with the Fragment lifecycle. You inflate the ComposeView in onCreateView, then call setContent to begin rendering composables. Because ComposeView subscribes to the host View's lifecycle, recomposition and state updates happen automatically as the Fragment moves through its life cycle.

class HybridFragment : Fragment() {
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup,
        savedInstanceState: Bundle?
    ): View {
        return ComposeView(requireContext()).apply {
            setId(R.id.compose_view)
            setContent {
                MaterialTheme {
                    Column(modifier = Modifier.padding(16.dp)) {
                        Text("Hello from Compose inside a Fragment", style = MaterialTheme.typography.headlineSmall)
                        Spacer(Modifier.height(8.dp))
                        Button(onClick = { /* navigate or act */ }) {
                            Text("Continue")
                        }
                    }
                }
            }
        }
    }
}

This pattern lets you migrate one screen at a time. The Fragment remains the navigation and lifecycle owner, while its content becomes fully declarative. The ComposeView instance behaves like any other View, so you can still pass it through view binding or findViewById if your migration path requires intermediate steps.

Embedding a Fragment Inside a Composable Screen

When you want to keep a legacy Fragment but render it from within a Compose screen, you should avoid legacy wrapping approaches and use the modern fragmentContainerView composable instead. This composable works like a <fragment> tag in XML but lives inside your Compose tree, automatically observing the Fragment back stack and preserving Fragment navigation semantics. It replaces older Workarounds like AndroidView wrappers because it natively supports Fragment transactions and the activity's navigation back stack.

@Composable
fun LegacyFragmentHostScreen(navigateBack: () -> Unit) {
    Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
        Text("Hosting a legacy Fragment via Compose", style = MaterialTheme.typography.titleMedium)
        Spacer(Modifier.height(16.dp))
        FragmentContainerView(
            name = "com.example.LegacyListFragment",
            arguments = bundleOf("mode" to "legacy"),
            modifier = Modifier.fillMaxWidth().height(200.dp)
        )
        Spacer(Modifier.height(16.dp))
        Button(onClick = navigateBack) {
            Text("Back to Compose")
        }
    }
}

At runtime, the Fragment is created inside the same activity and participates in the normal Fragment back stack. Navigation actions from inside the Fragment still work because the container is attached to the activity's FragmentManager. This means you can safely keep complex legacy screens while gradually moving the surrounding shell to Compose.

Sharing a Single ViewModel Across the Boundary

One of the biggest wins in a mixed architecture is state sharing. A single ViewModel scoped to the activity can be observed by both Compose composables and legacy Fragments without duplication. By using the activity as the ViewModelStoreOwner, both UI layers read from and write to the same state holder. This avoids the need for bus events, BroadcastReceivers, or redundant data layers.

class SharedViewModel : ViewModel() {
    private val _count = MutableStateFlow(0)
    val count: StateFlow<Int> = _count.asStateFlow()

    fun increment() {
        _count.value++
    }
}

class ComposeCounterScreen @Inject constructor(
    private val viewModel: SharedViewModel
) : AbstractComposeScreen() {
    @OptIn(ExperimentalComposeApi::class)
    @Composable
    override fun Content() {
        val count by viewModel.count.collectAsState()
        Column(modifier = Modifier.padding(16.dp)) {
            Text("Count from shared ViewModel: $count", style = MaterialTheme.typography.headlineSmall)
            Button(onClick = viewModel::increment) {
                Text("Increment")
            }
        }
    }
}

In a Fragment, you retrieve the same activity-scoped ViewModel with requireActivity().viewModel. Because the ViewModel lives outside both the Fragment and Compose scopes, rotations, configuration changes, and even navigation between Compose and Fragment screens preserve state naturally. This is especially valuable for forms, search filters, or checkout flows that span both UI paradigms during migration.

Wiring Navigation Between Fragments and Compose

Navigation in a hybrid app requires a single source of truth. The cleanest approach is to keep your primary navigation graph defined in Jetpack Navigation for Fragments, then use the Navigation Compose integration to trigger Fragment navigation from composables and vice versa. From Compose, you call navigate() with a destination that resolves to a Fragment route. From a Fragment, you use the Navigation.findNavController() API to trigger composable destinations if they are registered in the same graph. This keeps the routing table centralized while letting each UI layer call into the same navigation engine.

// In a composable, navigate to a Fragment route
NavHost(navController = navController, startDestination = "home") {
    composable("home") { HomeScreen(onNavigate = { navController.navigate("details") }) }
    fragment<FriendDetailsFragment>("details")
}

// In a Fragment, navigate back to a composable route
binding.button.setOnClickListener {
    findNavController().navigate(R.id.action_friendDetails_to_home)
}

At runtime, the navigation controller handles back-stack updates, argument passing, and transitions exactly as it does in a fully Compose or fully Fragment app. The only difference is that routes may point to either a composable or a Fragment class, and the graph must include both destination types. This keeps migration incremental while preventing navigation glue code from spreading across the codebase.

Lifecycle and Recomposition Considerations

A frequent mistake is treating Compose as a drop-in replacement for onViewCreated without respecting the recomposition model. Composables re-run when their observed state changes, which means side effects like network calls or LiveData observations belong inside LaunchedEffect, produceState, or ViewModel methods—not directly in the composable body. When a ComposeView lives inside a Fragment, the composable respects the Fragment's view lifecycle, but heavy effects should still be gated by isResumed or cancelled explicitly to avoid work during pauses.

Another important point is that Fragments inside Compose via fragmentContainerView still run their own lifecycle. They are not recomposed; they are instantiated and managed by the FragmentManager. If you need to pass updated data into a hosted Fragment, use ViewModel state or explicit arguments rather than trying to trigger Fragment recreation from Compose state flows. This prevents unexpected restarts and preserves smooth animations during navigation.

Gradle Dependencies and Version Alignment

A clean hybrid migration depends on correct dependency alignment. Mixing mismatched versions of Compose, Navigation, and Fragment libraries often produces runtime crashes or missing symbols. The safest practice is to import the Compose BOM and pin Navigation Compose to a version that supports Fragment navigation integration. This guarantees compatible APIs without manual version juggling.

plugins {
    id 'org.jetbrains.kotlin.android'
    id 'androidx.navigation.safeargs.kotlin'
}

dependencies {
    implementation platform('androidx.compose:compose-bom:2024.09.00')
    implementation 'androidx.compose.ui:ui'
    implementation 'androidx.compose.material3:material3'
    implementation 'androidx.compose.runtime:runtime-livedata'

    implementation 'androidx.fragment:fragment-ktx:1.8.2'
    implementation 'androidx.navigation:navigation-compose:2.8.0'
    implementation 'androidx.navigation:navigation-fragment-ktx:2.8.0'
    implementation 'androidx.navigation:navigation-ui-ktx:2.8.0'
}

With these versions aligned, you can safely use fragmentContainerView, shared activity-scoped ViewModels, and cross-paradigm navigation without encountering compatibility breakage. Always verify the BOM and Navigation versions match your target SDK and Kotlin version before committing the migration plan.

Common Pitfalls

Mixed-architecture apps commonly suffer from three recurring issues. First, developers often put network or database calls directly inside composables, causing duplicate requests on every recomposition. Always hoist side effects into ViewModels or guard them with LaunchedEffect keyed to stable identifiers. Second, some teams try to access Fragment views from Compose using volatile view references, which breaks during configuration changes. Prefer arguments, ViewModel state, or event callbacks instead. Third, navigation graphs sometimes contain duplicate routes for the same screen in both Fragment and Compose forms, creating confusion and deep back stacks. Keep one canonical route per destination and migrate routes gradually rather than maintaining parallel implementations.

Conclusion

Migrating Fragments to Jetpack Compose is best treated as a staged architectural evolution, not a big-bang rewrite. By embedding Compose in Fragments with ComposeView, hosting Fragments in Compose with fragmentContainerView, sharing an activity-scoped ViewModel, and wiring both worlds through a single Navigation graph, you can deliver modern UI increments without sacrificing stability. Follow the lifecycle rules, align your Gradle dependencies carefully, and avoid side-effect anti-patterns, and your hybrid app will remain maintainable throughout the transition.