Migrating from Views to Compose

This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Nobody rewrites a shipping app overnight. This page is the practical companion to Views, Fragments and Activities vs. Compose: Architecture Comparison — once a team has decided that Compose is where new work should go, this page walks through how an existing Views/Fragments/Activities codebase gets there in place, one small, shippable, revertible step at a time, using TrailMate’s legacy build (introduced across Activities and the Activity Lifecycle, Fragments and the Fragment Lifecycle, Views, XML Layouts, View Binding and Material Components, RecyclerView and Lists and Navigation with Fragments) as the running before/after example throughout. It targets AGP 9.4.1 with built-in Kotlin, Compose BOM 2026.09.00, Navigation 2.10.2 / Navigation 3 1.2.0, Lifecycle 2.11.0 and Android 17 (API level 37), the same baseline used across this section. State-saving specifics during the migration are covered in depth on Saving UI State: Views vs. Compose and are only summarized here where a phase depends on them.

Every phase below is designed to leave the app in a shippable state at the end of it — this is a strangler fig migration, where Compose grows around the existing Views code and gradually replaces it, rather than a big-bang rewrite that blocks all other feature work until it finishes.

Migration Strategy

Three decisions shape the whole migration before any code changes: where to start, whether to build a design system first, and which direction to migrate a screen hierarchy in.

New Features in Compose First

The highest-leverage rule is also the simplest: from the day Compose is adopted, every new screen and every new feature is built in Compose, never in XML/Fragments, even while the rest of the app is still Views-based. This does three things at once: it stops the Views codebase from growing while the migration is in progress, it lets the team build Compose fluency on low-risk, self-contained surfaces before touching business-critical legacy screens, and it means the migration effort itself only has to work through a fixed, shrinking backlog rather than a moving target. TrailMate’s PhotoCaptureScreen (a feature added after the legacy app already shipped) is written directly in Compose from the start, coexisting with the still-Fragment-based TrailListFragment and TrailDetailFragment for the rest of this page’s phases.

Build the Design System First

Before migrating any screen, invest in TrailMateTheme (covered on Material 3 and Theming in Compose) so that every screen migrated afterward reuses the same colors, typography, shapes and common components (buttons, cards, text fields) instead of each screen inventing its own. Migrating the theme first, and having it coexist with the legacy MDC XML theme, is Phase 1 below precisely because every later phase depends on it — without it, a migrated screen looks visually inconsistent with the screens still in Views, which is often the detail that turns "the migration is going well" into a user-visible regression.

Bottom-Up vs. Top-Down

The two general shapes a migration can take, and TrailMate uses both, on different parts of the app:

Direction How it works When TrailMate uses it

Bottom-up (inside-out)

Replace leaf-level pieces first — a single custom view, a list item layout, one RecyclerView — while the screen around them stays a Fragment, using ComposeView to host the new Compose piece inside the old layout (Phase 2 below).

Migrating `TrailListFragment’s item rendering (Phase 4) while the fragment, its toolbar and its navigation stay XML/Fragment-based a while longer — lower risk, ships incrementally, but the app runs both toolkits side by side for longer.

Top-down (outside-in)

Replace a whole screen’s root — the Fragment itself, or eventually the Activity — with a composable first, using AndroidView/AndroidViewBinding to keep any Views-only pieces still needed inside the new Compose screen (Phase 3 below).

Migrating TrailDetailFragment to a Compose screen wholesale in one release, keeping only the embedded ElevationProfileView as an AndroidView-wrapped leaf — faster to finish per screen, but each migrated screen is a bigger, riskier change.

Neither direction is universally correct: bottom-up favors caution on a large, high-traffic screen (TrailList), top-down favors speed on a smaller, self-contained one (TrailDetail). Most real migrations mix the two, as TrailMate’s phases below do. See Android Developers — Migration strategy.

The Phased Migration Path for TrailMate

flowchart TB P0["Phase 0
Prerequisites: AGP 9, BOM, Compose deps"] P1["Phase 1
MDC XML theme -> TrailMateTheme (coexisting)"] P2["Phase 2
ComposeView inside XML / Fragments"] P3["Phase 3
AndroidView / AndroidViewBinding for MapView, GLTextureView, ElevationProfileView"] P4["Phase 4
RecyclerView + ListAdapter -> LazyColumn"] P5["Phase 5
LiveData -> StateFlow + collectAsStateWithLifecycle; remove Data Binding"] P6["Phase 6
Navigation XML -> Navigation Compose -> (optionally) Navigation 3"] P7["Phase 7
Remove fragments: single ComponentActivity"] P8["Phase 8
Tests: Espresso -> Compose testing; hybrid tests"] P9["Phase 9
Cleanup: dependencies, Baseline Profiles"] P0 --> P1 --> P2 --> P3 --> P4 --> P5 --> P6 --> P7 --> P8 --> P9

Each phase is independently shippable — a team can pause between any two of them for as many releases as it needs, and TrailMate’s app store listing never advertises "we migrated to Compose," because users should not be able to tell a migration is happening at all.

Phase 0 — Prerequisites

Before any Compose code is written, the project needs: Kotlin 2.4.20 with the Compose compiler plugin org.jetbrains.kotlin.plugin.compose applied to every module that will contain composables; AGP 9.4.1 with buildFeatures.compose = true; and the Compose BOM 2026.09.00 platform dependency so every Compose artifact resolves to a mutually compatible version, exactly as set up on Jetpack Compose Fundamentals.

// feature/trails/build.gradle.kts
plugins {
    id("com.android.library")
    id("org.jetbrains.kotlin.plugin.compose")
}

android {
    buildFeatures {
        compose = true
        viewBinding = true // still needed: legacy fragments are not migrated yet
    }
}

dependencies {
    implementation(platform("androidx.compose:compose-bom:2026.09.00"))
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.material3:material3")
    implementation("androidx.compose.ui:ui-tooling-preview")
    debugImplementation("androidx.compose.ui:ui-tooling")

    // interop artifacts used starting Phase 2
    implementation("androidx.fragment:fragment-ktx:1.9.1")
    implementation("androidx.activity:activity-compose:1.13.0")
}

See Android Developers — Bill of Materials (BOM) and Project Structure, Gradle and Build Variants for the built-in-Kotlin/AGP-9 setup this depends on. viewBinding and Compose coexist in the same module without conflict; there is no need to remove one to add the other.

Phase 1 — MDC XML Theme to TrailMateTheme, Coexisting

The two theming systems are structurally different — an MDC theme is a styles.xml hierarchy applied at the AndroidManifest.xml/activity level, while TrailMateTheme is a composable wrapper applied around a Compose subtree — so they can run side by side for the entire length of the migration rather than needing a flag-day cutover.

Before: res/values/themes.xml (MDC, applies to the whole legacy Activity)
<!-- app/src/main/res/values/themes.xml -->
<resources>
    <style name="Theme.TrailMate" parent="Theme.Material3.DayNight.NoActionBar">
        <item name="colorPrimary">@color/trail_green_600</item>
        <item name="colorOnPrimary">@color/white</item>
        <item name="colorSecondary">@color/trail_amber_500</item>
    </style>
</resources>
After: TrailMateTheme composable, with matching color tokens
// core/design/src/main/kotlin/com/example/trailmate/core/design/TrailMateTheme.kt
private val TrailMateLightColors = lightColorScheme(
    primary = TrailGreen600,
    onPrimary = Color.White,
    secondary = TrailAmber500,
)

private val TrailMateDarkColors = darkColorScheme(
    primary = TrailGreen200,
    onPrimary = TrailGreen900,
    secondary = TrailAmber200,
)

@Composable
fun TrailMateTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit,
) {
    val context = LocalContext.current
    val colorScheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
        darkTheme -> TrailMateDarkColors
        else -> TrailMateLightColors
    }
    MaterialTheme(colorScheme = colorScheme, typography = TrailMateTypography, content = content)
}

The Activity’s manifest theme stays Theme.TrailMate (parented on Theme.Material3.DayNight.NoActionBar) unchanged through every phase below, so every screen not yet migrated keeps its existing look with zero code changes; only the Compose subtrees added from Phase 2 onward are wrapped in TrailMateTheme { }. Keeping the two color palettes numerically identical (same hex values behind colorPrimary and TrailGreen600) is what makes a screen visually seamless whether it happens to be rendered by the old theme or the new one — see Material 3 and Theming in Compose for `TrailMateTheme’s full definition. See Android Developers — Migrate XML themes to Compose.

Phase 2 — ComposeView Inside XML/Fragments

The narrowest possible unit of Compose adoption is a single ComposeView embedded inside an existing XML layout or added programmatically inside a Fragment — this is the bottom-up entry point, and it is how TrailMate introduces its first sliver of Compose into TrailListFragment without touching the fragment’s own lifecycle, navigation or RecyclerView yet: a header banner promoting the new photo-capture feature.

Before: a plain TextView banner in fragment_trail_list.xml
<!-- feature/trails/src/main/res/layout/fragment_trail_list.xml -->
<TextView
    android:id="@+id/promo_banner"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="@string/photo_capture_promo"
    android:padding="16dp" />
After: a ComposeView in the same slot, populated from the fragment
<!-- feature/trails/src/main/res/layout/fragment_trail_list.xml -->
<androidx.compose.ui.platform.ComposeView
    android:id="@+id/promo_banner_compose"
    android:layout_width="match_parent"
    android:layout_height="wrap_content" />
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListFragment.kt
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    binding.promoBannerCompose.apply {
        setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
        setContent {
            TrailMateTheme {
                PhotoCapturePromoBanner(onClick = { findNavController().navigate(toPhotoCapture()) })
            }
        }
    }
}

setViewCompositionStrategy controls exactly when the ComposeView’s composition is disposed, which matters because a `ComposeView is a plain View with no lifecycle awareness of its own by default:

Strategy When to use it

DisposeOnDetachedFromWindowOrReleasedFromPool (the default)

A ComposeView that is never reused across a RecyclerView view-holder pool and does not need to survive a fragment’s view being briefly detached, e.g. by `FragmentContainerView’s back-stack transitions

DisposeOnViewTreeLifecycleDestroyed

The composition should be disposed exactly when the surrounding view tree’s Lifecycle reaches DESTROYED —  the right choice inside a Fragment, since it ties disposal to viewLifecycleOwner, matching the pattern every other view binding in the fragment already follows (nulling on onDestroyView)

DisposeOnLifecycleDestroyed(lifecycle)

An explicit Lifecycle to tie disposal to, when the natural view-tree lifecycle above is not the right one (a ComposeView inside a custom dialog, for instance)

TrailMate uses DisposeOnViewTreeLifecycleDestroyed for every fragment-hosted ComposeView, for the same reason Fragments and the Fragment Lifecycle nulls out view bindings in onDestroyView(): it keeps the Compose composition’s lifetime aligned with the fragment’s view lifecycle, not its longer fragment-instance lifecycle. See Android Developers — Use Compose in Views.

Phase 3 — AndroidView/AndroidViewBinding to Keep a View That Is Not Worth Rewriting

Some Views-based UI is either backed by a third-party SDK with no Compose equivalent (a MapView), wraps low-level graphics code that would need to be rewritten rather than merely re-hosted (GLTextureView, from com.irurueta:irurueta-android-glutils), or is simply not worth the rewrite cost yet (the custom ElevationProfileView from Custom Views, Touch, Drawing and View Animation). The AndroidView composable is the top-down counterpart to ComposeView: it lets an already-migrated, otherwise all-Compose screen keep exactly one such view embedded as a leaf.

Before: ElevationProfileView inside fragment_trail_detail.xml
<!-- feature/trails/src/main/res/layout/fragment_trail_detail.xml -->
<com.example.trailmate.feature.trails.ElevationProfileView
    android:id="@+id/elevation_profile"
    android:layout_width="match_parent"
    android:layout_height="160dp" />
After: the same view wrapped inside a fully Compose TrailDetailScreen
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailDetailScreen.kt
@Composable
fun ElevationProfile(elevations: List<Float>, modifier: Modifier = Modifier) {
    AndroidView(
        modifier = modifier.fillMaxWidth().height(160.dp),
        factory = { context -> ElevationProfileView(context) },
        update = { view -> view.setElevations(elevations) },
    )
}

factory runs exactly once, when the AndroidView first enters composition; update re-runs on every recomposition where any value it reads (elevations, here) has changed, which is where new data is pushed into the wrapped View rather than recreating it. A GLTextureView from irurueta-android-glutils follows the same shape, with factory also calling setEGLContextClientVersion(2) and setRenderer(…​) once, and update calling requestRender() when the underlying scene data changes; a Google MapView needs its own onCreate/onResume/onPause/onDestroy calls forwarded from a DisposableEffect tied to the composition’s lifecycle, since MapView’s lifecycle methods have no `AndroidView equivalent of their own.

AndroidViewBinding is the same idea specialized for an entire inflated XML layout accessed through a generated ViewBinding class, useful when several related Views (not just one custom view) need to keep existing behind a single Compose leaf during a transitional phase:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailDetailScreen.kt
AndroidViewBinding(FragmentTrailDetailLegacyHeaderBinding::inflate) {
    trailNameLabel.text = trailName
}

Phase 4 — RecyclerView + ListAdapter to LazyColumn

Once the screen hosting a list is ready to become a composable, TrailMate’s list of trails moves from a RecyclerView/TrailAdapter/DiffUtil.ItemCallback (from RecyclerView and Lists) to a LazyColumn (from Compose Layouts, Modifiers and Lazy Lists), which gets efficient diffing "for free" through the key parameter instead of a hand-written ItemCallback.

Before: TrailAdapter : ListAdapter<Trail, TrailViewHolder>
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailAdapter.kt
class TrailAdapter(
    private val onClick: (Trail) -> Unit,
) : ListAdapter<Trail, TrailViewHolder>(TrailDiffCallback) {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TrailViewHolder =
        TrailViewHolder(ItemTrailBinding.inflate(LayoutInflater.from(parent.context), parent, false))

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

private object TrailDiffCallback : DiffUtil.ItemCallback<Trail>() {
    override fun areItemsTheSame(old: Trail, new: Trail) = old.id == new.id
    override fun areContentsTheSame(old: Trail, new: Trail) = old == new
}
After: LazyColumn with key doing the same job
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListScreen.kt
@Composable
fun TrailListScreen(uiState: TrailListUiState, onTrailClick: (Trail) -> Unit) {
    LazyColumn {
        items(items = uiState.trails, key = { it.id }, contentType = { "trail" }) { trail ->
            TrailCard(trail = trail, onClick = { onTrailClick(trail) })
        }
    }
}

There is no adapter class, no view type constant, and no manual notifyItemChanged bookkeeping: recomposition already only re-renders the items whose read state actually changed, and key gives LazyColumn the same stable-identity information DiffUtil.ItemCallback.areItemsTheSame provided, so a favorite-toggle animates in place instead of the whole list flashing. Multiple item shapes, which used getItemViewType() under ListAdapter, become a when inside the item lambda, or, for performance on a very large heterogeneous list, distinct contentType values as covered on Compose Layouts, Modifiers and Lazy Lists. See Android Developers — Lists and grids.

Phase 5 — LiveData to StateFlow, Removing Data Binding

Legacy LegacyTrailListViewModel-style code (see ViewModel, Lifecycle-Aware Components, Coroutines and Flow) that still exposes LiveData, observed through Data Binding’s <variable> bindings in XML, converts to StateFlow collected with collectAsStateWithLifecycle() once the screen itself is Compose. LiveData.asFlow() lets this happen one layer at a time, exactly as TrailListViewModel.observeAsState() shows below as a transitional step before the ViewModel itself is rewritten.

Before: LiveData + Data Binding in fragment_trail_list.xml
<!-- feature/trails/src/main/res/layout/fragment_trail_list.xml -->
<layout xmlns:android="http://schemas.android.com/apk/res/android">
    <data>
        <variable name="viewModel" type="com.example.trailmate.feature.trails.LegacyTrailListViewModel" />
    </data>
    <TextView
        android:text="@{viewModel.trails.size + \" trails\"}"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />
</layout>
Transitional: keep the LiveData-exposing ViewModel, bridge it with observeAsState()
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListScreen.kt
@Composable
fun TrailListRoute(viewModel: LegacyTrailListViewModel = viewModel()) {
    val trails by viewModel.trails.observeAsState(initial = emptyList())
    TrailListScreen(trails = trails)
}
After: the ViewModel itself rewritten to StateFlow, Data Binding removed entirely
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListViewModel.kt
@HiltViewModel
class TrailListViewModel @Inject constructor(
    private val repository: TrailRepository,
) : ViewModel() {
    val uiState: StateFlow<TrailListUiState> = repository.observeTrails()
        .map<List<Trail>, TrailListUiState> { TrailListUiState.Success(it) }
        .catch { emit(TrailListUiState.Error(it.message.orEmpty())) }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TrailListUiState.Loading)
}
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListScreen.kt
@Composable
fun TrailListRoute(viewModel: TrailListViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    TrailListScreen(uiState = uiState)
}

observeAsState() (from androidx.compose.runtime:runtime-livedata) exists specifically to make this middle step possible: it lets a screen be rewritten in Compose before its ViewModel is rewritten, decoupling the two migrations so neither blocks on the other. Once every caller of a given LiveData has moved to Compose, the ViewModel itself converts to StateFlow (the "after" step), and both the Data Binding layout variable and the kotlin-kapt/kotlin-parcelize-adjacent Data Binding compiler setup for that module are deleted — Data Binding has no place in an all-Compose module. See Android Developers — LiveData Overview and ViewModel, Lifecycle-Aware Components, Coroutines and Flow for the full StateFlow/SharedFlow/Channel picture.

Phase 6 — Navigation XML to Navigation Compose, Then Optionally Navigation 3

Navigation migrates screen-by-screen too, but its back stack is shared state, so this phase is usually completed in one release per navigation graph rather than split arbitrarily: nav_graph.xml and NavHostFragment (Navigation with Fragments) are replaced by a NavHost and type-safe @Serializable routes (Navigation in Compose), still hosted inside the same Fragment/Activity shell as before via a single top-level ComposeView.

Before: nav_graph.xml destinations, NavHostFragment in activity_main.xml
<!-- app/src/main/res/navigation/nav_graph.xml -->
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    app:startDestination="@id/trailListFragment">
    <fragment android:id="@+id/trailListFragment" android:name="...TrailListFragment">
        <action android:id="@+id/toTrailDetail" app:destination="@id/trailDetailFragment" />
    </fragment>
    <fragment android:id="@+id/trailDetailFragment" android:name="...TrailDetailFragment">
        <argument android:name="trailId" app:argType="string" />
    </fragment>
</navigation>
After: Navigation Compose with type-safe routes
// app/src/main/kotlin/com/example/trailmate/TrailMateApp.kt
@Serializable object TrailList
@Serializable data class TrailDetail(val trailId: String)

@Composable
fun TrailMateApp() {
    val navController = rememberNavController()
    NavHost(navController = navController, startDestination = TrailList) {
        composable<TrailList> {
            TrailListRoute(onTrailClick = { trail -> navController.navigate(TrailDetail(trail.id)) })
        }
        composable<TrailDetail> {
            TrailDetailRoute()
        }
    }
}

TrailMate follows the official migration scenario, which replaces one Fragment destination at a time with a composable<T> destination inside the same graph while the remaining destinations stay <fragment> entries, using FragmentNavigator/DialogFragmentNavigator interop only as a short-lived bridge; the fully-Compose NavHost above is the end state, reached once every destination has moved. Migrating from Navigation 2 to Navigation 3 afterward — adopting NavKey/rememberNavBackStack and NavDisplay instead of NavHost, per Navigation in Compose — is a separate, optional, later step: it is worth doing when a screen needs an app-owned back stack (a multi-pane list-detail layout, custom back-stack manipulation) and is not a requirement for finishing the Views-to-Compose migration itself. See Android Developers — Migrate navigation and Android Developers — Migrate to Navigation 3.

Phase 7 — Removing Fragments for a Single ComponentActivity

Once every destination in a navigation graph is a composable<T>, the Fragment/NavHostFragment shell around it has no remaining job — MainActivity : AppCompatActivity hosting a NavHostFragment collapses into a single MainActivity : ComponentActivity calling setContent { TrailMateApp() } directly, matching the shape every other page in this section calls TrailMate’s "Compose version."

Before: AppCompatActivity + NavHostFragment shell
// app/src/main/kotlin/com/example/trailmate/MainActivity.kt
class MainActivity : AppCompatActivity(R.layout.activity_main) {
    // NavHostFragment declared in activity_main.xml, everything else lives in fragments
}
After: single ComponentActivity
// app/src/main/kotlin/com/example/trailmate/MainActivity.kt
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            TrailMateTheme {
                TrailMateApp()
            }
        }
    }
}

This is the point where AppCompatActivity-specific behavior (an XML ActionBar, AppCompatDelegate night-mode overrides not already handled by TrailMateTheme) needs a final check, since ComponentActivity does not supply any of it. Activity-scoped shared ViewModel`s (below) and any `Application-level Hilt setup are unaffected by this change — only the Activity’s own base class and content view change.

Phase 8 — Tests: Espresso to Compose Testing, Hybrid Tests

Every phase above changes what a test needs to drive. TrailMate keeps TrailListFragmentTest (an Espresso-based FragmentScenario test, from Instrumented and UI Testing) passing unmodified for as long as TrailListFragment itself still exists, and adds a parallel TrailListScreenTest (createComposeRule()/onNodeWithText/onNodeWithTag) for the Compose replacement once Phase 4 lands, running both suites side by side rather than deleting the Espresso test before its Compose replacement has proven itself in CI. A screen mid-migration — a Fragment hosting a ComposeView (Phase 2), or a Compose screen embedding an AndroidView (Phase 3) — is exercised by a hybrid test: FragmentScenario.launchInContainer plus composeTestRule.onNodeWithText(…​) assertions against the same rendered hierarchy, since Espresso’s onView matchers and Compose’s semantics tree can both inspect the one view hierarchy a ComposeView produces. Only once a Fragment’s Espresso test has no more assertions left to make (because it has fully become `TrailListScreenTest) is the old Espresso test deleted. See Android Developers — Testing your Compose layout and Instrumented and UI Testing for the full Espresso vs. Compose testing comparison.

Phase 9 — Cleanup

The last phase is bookkeeping, done once no screen depends on the old stack any more:

  • remove now-unused dependencies — Data Binding’s compiler/runtime, androidx.fragment’s `NavHostFragment artifacts if no fragment remains, kotlin-parcelize if it was only used for now-deleted Parcelable saved state;

  • delete dead legacy code (TrailAdapter, TrailViewHolder, LegacyTrailListViewModel, unused layout XML) — the compiler and lint both help find it once nothing references it;

  • re-measure startup time and APK/AAB size, since Compose’s own runtime and the removal of an entire View inflation/Data Binding path both shift these numbers, sometimes in the app’s favor and sometimes not, per the performance comparison on Views, Fragments and Activities vs. Compose: Architecture Comparison;

  • add or update Baseline Profiles (androidx.baselineprofile 1.5.0), since a freshly-Compose screen benefits from ahead-of-time compilation of its hot composition paths just as much as a Views screen benefited from one for its inflation path — see Android Developers — Baseline Profiles overview.

Shared ViewModels Between Fragments and Composables

During Phases 2–6, a single screen can be part-Fragment, part-Compose, and both parts often need the same ViewModel instance rather than two independent ones. Because by activityViewModels() and Compose’s viewModel()/hiltViewModel() both ultimately resolve against a ViewModelStoreOwner, the same activity-scoped ViewModel is reachable from both toolkits without any bridging code:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListFragment.kt
class TrailListFragment : Fragment(R.layout.fragment_trail_list) {
    private val sharedViewModel: TrailSelectionViewModel by activityViewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        val binding = FragmentTrailListBinding.bind(view)
        // Tie the composition to the fragment's view lifecycle, per the Phase 2 checklist item.
        binding.detailPaneCompose.setViewCompositionStrategy(
            ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed,
        )
        binding.detailPaneCompose.setContent {
            TrailMateTheme {
                // The very same instance activityViewModels() resolved above.
                val vm: TrailSelectionViewModel = viewModel(viewModelStoreOwner = requireActivity())
                TrailDetailPane(selection = vm.selectedTrail.collectAsStateWithLifecycle().value)
            }
        }
    }
}

This is what lets TrailListFragment’s dual-pane layout keep sharing selection state with a detail pane, whether that pane is still a `Fragment or has already become a ComposeView-hosted composable — the ViewModel itself does not need to know or care which toolkit is currently observing it.

Context and Resources

A composable has no Context implicitly, unlike a View or Fragment method; LocalContext.current retrieves the current one when a Compose-side API genuinely needs it (starting an Intent, for instance), but reading resources almost never needs it directly — stringResource(R.string.trail_count), painterResource(R.drawable.ic_trail) and colorResource(R.color.trail_green_600) (all covered on Resources, Configuration, Localization and Themes) replace getString()/ContextCompat.getDrawable()/ContextCompat.getColor() calls a migrated Fragment used to make against its own requireContext(). A ViewModel still has no Context of its own in either stack — an Application reference injected by Hilt (@ApplicationContext) is the one exception, unchanged by this migration, needed only for a Context a ViewModel genuinely cannot avoid (looking up a system service, for instance).

Insets Interop

TrailListFragment’s `ViewCompat.setOnApplyWindowInsetsListener calls (from Views, XML Layouts, View Binding and Material Components) and a Compose screen’s Modifier.windowInsetsPadding(WindowInsets.safeDrawing) (from Adaptive Layouts, Edge-to-Edge and Large Screens) both ultimately consume the same platform WindowInsets dispatched to the Activity’s root view, so a mixed-toolkit screen during Phase 2/3 needs exactly one of them to consume a given inset, never both — a ComposeView embedded inside a Fragment layout that already applies padding for the system bars at the Fragment’s root should not also apply safeDrawing padding inside the composable, or the content gets padded twice. The simplest rule TrailMate follows: whichever layer is closer to the Activity’s root (the XML layout, in a Fragment-hosted screen; the top-level composable, once Phase 7 removes fragments) owns inset consumption for that screen, and inner content trusts it has already been handled.

Accessibility Parity

A screen’s accessibility behavior must not regress across a migration — contentDescription in XML (Accessibility (Views and Compose)) becomes Modifier.semantics { contentDescription = …​ } or the contentDescription parameter on Image/Icon; a custom View’s AccessibilityDelegateCompat becomes Modifier.semantics { role = …​; customActions = …​ }; TalkBack’s traversal order, which followed XML declaration order in the legacy layout, needs Modifier.semantics { traversalIndex = …​ } if the migrated Compose layout’s visual order differs from the order composables happen to be declared in. Re-running the Accessibility Scanner and the Compose accessibility checks (both described on Accessibility (Views and Compose)) against each migrated screen, before and after, is the only reliable way to confirm parity rather than assuming a like-for-like API swap preserved it.

Common Pitfalls

Pitfall What actually goes wrong

Forgetting setViewCompositionStrategy on a ComposeView

The default strategy disposes on detach from window, which for a ComposeView inside a RecyclerView item or a fragment whose view is briefly detached (a back-stack transaction) can dispose and recompose more often than expected, losing remember-only state; use DisposeOnViewTreeLifecycleDestroyed inside a fragment (Phase 2)

Mixing LiveData and StateFlow in the same ViewModel indefinitely

observeAsState() is meant as a transitional bridge (Phase 5), not a permanent pattern — leaving both types side by side long-term doubles the surface a future maintainer has to reason about for no lasting benefit

Double-consuming WindowInsets

A ComposeView/AndroidView boundary that pads the same inset on both sides of the boundary produces visibly wrong spacing around system bars; see Insets Interop above

Migrating navigation before its destinations

Converting nav_graph.xml to a NavHost while several destinations are still fragments works only through interop navigators and is more fragile than finishing Phase 4/5 on a screen before including it in Phase 6’s navigation graph

Deleting the Espresso suite before the Compose suite has parity

Phase 8 keeps both suites running in CI until the Compose test’s assertions cover everything the Espresso test covered — deleting early leaves a coverage gap invisible until a regression ships

Treating the migration as a single project-wide flag day

A migration that tries to convert every screen simultaneously loses the main benefit of a phased approach —  the ability to ship, measure and revert one screen at a time — and tends to stall indefinitely instead

Final Checklist

  • New features are built in Compose only, from day one of the migration.

  • TrailMateTheme exists and produces colors/typography identical to the legacy MDC theme (Phase 1).

  • Every ComposeView sets an explicit ViewCompositionStrategy (Phase 2).

  • Every AndroidView/AndroidViewBinding wraps only what genuinely cannot yet be rewritten in Compose (Phase 3).

  • Every migrated list uses LazyColumn/LazyRow with a stable key, not an unkeyed loop (Phase 4).

  • No ViewModel exposes LiveData once every one of its observers has moved to Compose; Data Binding is removed from any module with no remaining consumer (Phase 5).

  • Navigation graphs contain only composable<T> destinations before being considered fully migrated (Phase 6).

  • MainActivity extends ComponentActivity directly, with no remaining NavHostFragment (Phase 7).

  • Every migrated screen has a passing Compose test, and its Espresso predecessor is deleted only after that (Phase 8).

  • Unused Views-era dependencies are removed and Baseline Profiles are refreshed (Phase 9).

  • Context/resource access, inset consumption and accessibility semantics were re-verified on every migrated screen, not assumed to carry over automatically.