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 |
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 |
Migrating |
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
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.
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>
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.
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" />
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 |
|---|---|
|
A |
|
The composition should be disposed exactly when the surrounding view tree’s |
|
An explicit |
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.
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" />
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.
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
}
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.
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>
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)
}
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.
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>
// 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."
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
}
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 `NavHostFragmentartifacts if no fragment remains,kotlin-parcelizeif it was only used for now-deletedParcelablesaved 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.baselineprofile1.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 |
The default strategy disposes on detach from window, which for a |
Mixing |
|
Double-consuming |
A |
Migrating navigation before its destinations |
Converting |
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.
-
TrailMateThemeexists and produces colors/typography identical to the legacy MDC theme (Phase 1). -
Every
ComposeViewsets an explicitViewCompositionStrategy(Phase 2). -
Every
AndroidView/AndroidViewBindingwraps only what genuinely cannot yet be rewritten in Compose (Phase 3). -
Every migrated list uses
LazyColumn/LazyRowwith a stablekey, not an unkeyed loop (Phase 4). -
No
ViewModelexposesLiveDataonce 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). -
MainActivityextendsComponentActivitydirectly, with no remainingNavHostFragment(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.