Mixing Compose and Views in one app
Nobody rewrites a forty-screen View app in one go. What actually happens is that a team writes the next new piece in Compose, inside the View app, and the piece after that, and replaces old screens as they come up for changes anyway. For a year or more the app is both. This lesson does both halves of that on XML Demo: a Compose filter row inside the XML prices screen, then a detail screen written in Compose that still uses an old custom View and the old card layout. Each half has a setting that looks optional and is not, and each was tested on the emulator.
Adding Compose to a View app
Three changes to the build. In gradle/libs.versions.toml, the Compose BOM version and four libraries:
composeBom = "2026.09.00"
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-viewbinding = { group = "androidx.compose.ui", name = "ui-viewbinding" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
In app/build.gradle.kts, the Compose compiler plugin (already declared in the catalogue since lesson 1), the build feature, and the dependencies:
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
}
buildFeatures {
viewBinding = true
compose = true
}
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui.viewbinding)
implementation(libs.androidx.lifecycle.runtime.compose)
ui holds ComposeView and AndroidView; ui-viewbinding holds AndroidViewBinding; lifecycle-runtime-compose gives you module 5's collectAsStateWithLifecycle. You do not need activity-compose here: nothing calls setContent on an activity.
What it costs. I built release APKs of XML Demo before and after, with R8 and resource shrinking on: 1,561,719 bytes without Compose, 2,365,762 bytes with it. About 800 KB more for a farmer to download on mobile data, for one filter row and one screen. The cost is mostly paid once: the next twenty Compose screens add little, and when the last View screen goes, the View libraries can go too. A team that adds Compose and then stops halfway pays for two toolkits for good.
Compose inside an XML screen: ComposeView
Priya wants commodity chips on the prices list, the same chips Mandi Bhav has, and the team has agreed new UI is Compose. A ComposeView is a View like any other, so it goes in fragment_prices.xml between the Refresh button and the list:
<androidx.compose.ui.platform.ComposeView
android:id="@+id/header"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
The ViewModel learns to filter. Replace PricesViewModel with:
package com.riztechacademy.xmldemo
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
class PricesViewModel : ViewModel() {
// 500 prices: a busy day at Vashi, as in module 6.
private val _allPrices = MutableStateFlow(SamplePrices.many(500))
val allPrices: StateFlow<List<CommodityPrice>> = _allPrices.asStateFlow()
private val _selectedCommodity = MutableStateFlow<String?>(null)
val selectedCommodity: StateFlow<String?> = _selectedCommodity.asStateFlow()
val commodities: List<String> = SamplePrices.all.map { it.commodity }.distinct().sorted()
/** The prices the list shows: all of them, or one commodity's. */
val prices: StateFlow<List<CommodityPrice>> =
combine(_allPrices, _selectedCommodity) { prices, commodity ->
if (commodity == null) prices else prices.filter { it.commodity == commodity }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), _allPrices.value)
fun selectCommodity(commodity: String?) {
_selectedCommodity.value = commodity
}
/**
* The 4 pm closing prices arrive: a brand-new list, as a server or a
* database would give you, in which only the first price has changed.
*/
fun refresh() {
_allPrices.update { prices ->
prices.mapIndexed { i, price ->
if (i == 0) price.copy(modalPrice = price.modalPrice + 150) else price.copy()
}
}
}
}
The composables, in a new file CommodityChips.kt. Nothing in it knows about Views; it is module 6's LazyRow of chips plus a one-off hint with a "Got it" button:
package com.riztechacademy.xmldemo
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/** The top of the XML prices screen, written in Compose: a hint and the commodity chips. */
@Composable
fun PricesHeader(
commodities: List<String>,
selected: String?,
onCommodityClick: (String?) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
FilterHint()
CommodityChips(commodities, selected, onCommodityClick)
}
}
@Composable
fun FilterHint(modifier: Modifier = Modifier) {
var dismissed by rememberSaveable { mutableStateOf(false) }
if (!dismissed) {
Row(
modifier = modifier.padding(start = 16.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Tap a commodity to see only its prices.",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
TextButton(onClick = { dismissed = true }) { Text("Got it") }
}
}
}
@Composable
fun CommodityChips(
commodities: List<String>,
selected: String?,
onCommodityClick: (String?) -> Unit,
modifier: Modifier = Modifier,
) {
LazyRow(
modifier = modifier,
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
item(key = "all") {
FilterChip(
selected = selected == null,
onClick = { onCommodityClick(null) },
label = { Text("All") },
)
}
items(items = commodities, key = { it }) { commodity ->
FilterChip(
selected = selected == commodity,
onClick = { onCommodityClick(commodity) },
label = { Text(commodity) },
)
}
}
}
In PricesFragment.onViewCreated, after the Refresh listener, the fragment hands the ComposeView its content:
binding.header.apply {
// Keep the composition as long as the fragment's view, not the window attachment.
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
XmlDemoTheme {
val selected by viewModel.selectedCommodity.collectAsStateWithLifecycle()
PricesHeader(
commodities = viewModel.commodities,
selected = selected,
onCommodityClick = viewModel::selectCommodity,
)
}
}
}
with three new imports: androidx.compose.runtime.getValue, androidx.compose.ui.platform.ViewCompositionStrategy and androidx.lifecycle.compose.collectAsStateWithLifecycle. XmlDemoTheme comes in a moment.
Follow a tap through it. A chip calls selectCommodity on the shared ViewModel; prices emits a filtered list; the fragment's collector from lesson 2 calls submitList; ListAdapter diffs it into the RecyclerView. Compose and the View list never talk to each other directly. The ViewModel is the border between the two toolkits, and that is what makes mixing them manageable: each side reads state and sends events, as it would on its own.
The trap: when the composition is thrown away
A ComposeView must decide when its composition, with all its remembered state and running effects, is disposed. The default, ViewCompositionStrategy.Default, disposes it when the view is detached from the window (it is cleverer inside a RecyclerView). The interop guide recommends DisposeOnViewTreeLifecycleDestroyed for a ComposeView in a fragment, which ties the composition to the fragment's view lifecycle instead. The guide says the default "may cause state loss in some scenarios", so I tested which.
Opening a price and coming back. With a DisposableEffect logging in FilterHint, I tapped "Got it", opened a price and pressed Back, under both strategies. Both logged the same order, composition disposed then onDestroyView, and in both the dismissed hint stayed dismissed: rememberSaveable state was saved with the fragment's view state. No difference.
A view that is detached and attached again. View code moves views around: a header taken out of one container and put into another, a panel removed while something animates. To reproduce that, a scratch copy's Refresh button also did:
val parent = binding.header.parent as ViewGroup
val index = parent.indexOfChild(binding.header)
parent.removeView(binding.header)
parent.addView(binding.header, index)
ViewCompositionStrategy |
Hint state kept in | After remove and re-add |
|---|---|---|
DisposeOnViewTreeLifecycleDestroyed |
remember |
Composition kept; hint stays dismissed |
| Default | remember |
"chips composition: disposed", then "entered"; the hint is back |
| Default | rememberSaveable |
Disposed and rebuilt; hint stays dismissed |
The default threw the whole composition away the instant the view left the window, and built a new one when it came back. Saveable state survived; everything else did not: remembered values, scroll positions held in plain state, a running LaunchedEffect, an animation half done. In a fragment, set DisposeOnViewTreeLifecycleDestroyed and the composition lives exactly as long as the fragment's view, which is what the rest of the fragment's code already assumes. It costs nothing but the line.
The other trap: two themes
The first time the chips ran, they were wrapped in nothing, and they were purple: the selected chip a pale lavender, "Got it" in violet, under a green Refresh button. Compose does not read the View theme. With no MaterialTheme above them, Material 3 composables use the library's baseline colours. During a migration you have two themes, one in themes.xml for Views and one in Kotlin for Compose, and the migration guide says plainly that they must be kept in step until the XML one can be deleted.
You can at least keep the values in one place. XmlDemoTheme.kt reads the same colour resources Theme.XmlDemo uses:
package com.riztechacademy.xmldemo
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.colorResource
/**
* The Compose half of the app's theme. The greens are read from
* res/values/colors.xml, the same resources Theme.XmlDemo uses, so each
* colour is written down once.
*/
@Composable
fun XmlDemoTheme(content: @Composable () -> Unit) {
val base = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()
val colorScheme = base.copy(
primary = colorResource(R.color.mandi_green),
primaryContainer = colorResource(R.color.mandi_green_container),
onPrimaryContainer = colorResource(R.color.on_mandi_green_container),
secondaryContainer = colorResource(R.color.mandi_green_container),
onSecondaryContainer = colorResource(R.color.on_mandi_green_container),
)
MaterialTheme(colorScheme = colorScheme, content = content)
}
With it, the selected chip is Mandi Bhav's pale green and "Got it" matches the Refresh button. secondaryContainer is there because a selected FilterChip uses it; finding which role a component reads, and mapping it, is most of the work of bridging a real theme. The XML names map to Compose roles: colorPrimary to primary, colorSurface to surface, ?attr/textAppearanceTitleLarge to MaterialTheme.typography.titleLarge; the theme migration guide has the full table. You may find an older project using a "theme adapter" library that read the XML theme into Compose at run time. The current migration guide does not use one: it moves the values into a Compose theme, as above, and deletes the XML theme when the last View screen goes.
Views inside Compose
Arjun is rewriting the detail screen in Compose. Two pieces he cannot rewrite this sprint: a custom chart View the trading desk relies on, and the card layout, which another team's screen also uses. Here is the chart, WeekChartView.kt, standing in for the years of custom Views an old codebase has:
package com.riztechacademy.xmldemo
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.util.AttributeSet
import android.view.View
import androidx.core.content.ContextCompat
/**
* The legacy chart: seven bars, one per day's modal price, the last one
* today's. A custom View, the kind of thing an old codebase has years of.
*/
class WeekChartView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : View(context, attrs) {
var prices: List<Long> = emptyList()
set(value) {
field = value
invalidate() // draw again with the new prices
}
private val barPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = ContextCompat.getColor(context, R.color.mandi_green)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (prices.isEmpty()) return
val highest = prices.max().toFloat()
val lowest = prices.min().toFloat() * 0.9f
val slot = width.toFloat() / prices.size
prices.forEachIndexed { i, price ->
val fraction = (price - lowest) / (highest - lowest)
val top = height * (1f - fraction)
canvas.drawRect(i * slot + slot * 0.15f, top, (i + 1) * slot - slot * 0.15f, height.toFloat(), barPaint)
}
}
}
/** A made-up week ending in [price]'s yesterday and today, so the chart has something to draw. */
fun weekOf(price: CommodityPrice): List<Long> =
listOf(-250L, -100L, -180L, 40L, -60L).map { price.yesterdayModal + it } +
listOf(price.yesterdayModal, price.modalPrice)
The new PriceDetailFragment.kt returns a ComposeView instead of inflating a layout, so delete fragment_price_detail.xml. Navigation between fragments is unchanged; only what is inside this one changed:
package com.riztechacademy.xmldemo
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.viewinterop.AndroidViewBinding
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.riztechacademy.xmldemo.databinding.ItemPriceBinding
/** The detail screen, rewritten in Compose. The rest of the app still navigates with fragments. */
class PriceDetailFragment : Fragment() {
private val viewModel: PricesViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View = ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
XmlDemoTheme {
val prices by viewModel.allPrices.collectAsStateWithLifecycle()
val priceId = requireArguments().getString(ARG_PRICE_ID)
val price = prices.find { it.id == priceId }
if (price != null) {
PriceDetailScreen(price = price, onRefresh = viewModel::refresh)
}
}
}
}
companion object {
private const val ARG_PRICE_ID = "priceId"
fun newInstance(priceId: String) = PriceDetailFragment().apply {
arguments = bundleOf(ARG_PRICE_ID to priceId)
}
}
}
@Composable
fun PriceDetailScreen(price: CommodityPrice, onRefresh: () -> Unit) {
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "${price.commodity} at ${price.market}",
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = "Today ${formatRupees(price.modalPrice)} per quintal",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
WeekChart(prices = weekOf(price))
Button(onClick = onRefresh) { Text("Refresh") }
Text("The old card, reused:", style = MaterialTheme.typography.labelLarge)
OldPriceCard(price)
}
}
}
/** The legacy chart View, inside Compose. */
@Composable
fun WeekChart(prices: List<Long>, modifier: Modifier = Modifier) {
AndroidView(
factory = { context -> WeekChartView(context) },
update = { chart -> chart.prices = prices },
modifier = modifier.fillMaxWidth().height(120.dp),
)
}
/** The XML card from item_price.xml, inside Compose. */
@Composable
fun OldPriceCard(price: CommodityPrice) {
AndroidViewBinding(ItemPriceBinding::inflate) {
showPrice(price)
}
}
The ViewModel no longer needs its price(id) function; the detail screen now watches the whole list, so a refresh shows up while the screen is open. Run it and open Lasalgaon onion: the title, "Today ₹2,450 per quintal", seven green bars from the old chart, a Refresh button, and the old card, drawn by the old XML and filled by lesson 2's showPrice.
AndroidView: factory once, update every time
AndroidView takes two lambdas, and the difference is the whole API. factory runs once, to create the view. update runs after that and again whenever any state it reads changes. Arjun's first version put the data in factory, because that is where the view is made:
AndroidView(
factory = { context -> WeekChartView(context).apply { this.prices = prices } },
modifier = modifier.fillMaxWidth().height(120.dp),
)
On the emulator, I tapped Refresh twice on the detail screen. The text above changed to "Today ₹2,750 per quintal", the old card below changed to ₹2,750 and "Up ₹450", and the chart stayed exactly as it was, bar for bar, still ending at ₹2,450. With the code above, a log line in update printed "today 2450", "today 2600", "today 2750", and the earlier bars shrank against the new, higher last one. Create in factory, set data in update. The same rule covers listeners that read changing values. AndroidViewBinding's trailing lambda is an update block, which is why the card refreshed without help.
Two more things worth knowing from the guide, not built here: inside a LazyColumn, use the AndroidView overload with onReset so views can be reused like a RecyclerView's; and AndroidFragment, in androidx.fragment:fragment-compose, puts a whole existing fragment inside a composable during a migration.
Choosing what to move first
A working order that holds up on real projects:
| Move | Why | What it costs |
|---|---|---|
| New screens and new components | Nothing to rewrite, nothing to break | Two toolkits from day one, about 800 KB here |
| Leaf screens, such as a detail or settings screen | Self-contained; navigation stays as it is | A ComposeView per fragment, two themes to keep in step |
| Shared pieces, such as the price card | Every screen that uses them gets them in Compose | Every View screen still using the XML card needs AndroidViewBinding or stays as it is |
| Navigation, last | Once most screens are Compose, Navigation 3 can take over from the fragments | The biggest single change; do it when there is little left to break |
What not to do: rewrite a screen nobody needs to change, convert a working RecyclerView that nobody complains about before everything around it, or keep a hand-rolled bridge between two themes for ever.
Check your work
What is a ComposeView? A View that hosts a composition; the way Compose goes into an XML layout, and what setContent adds to an activity.
Why DisposeOnViewTreeLifecycleDestroyed in a fragment? It keeps the composition until the fragment's view is destroyed, instead of disposing it whenever the view leaves the window.
What did the default strategy lose when the header was removed and added back? All remembered state; the "Got it" hint came back. rememberSaveable state survived.
Why were the chips purple? No MaterialTheme wrapped them, so they used Material 3's baseline colours; Compose does not read themes.xml.
What runs in factory, and what in update? Creating the view, once; setting its data, after creation and on every change to state it reads.
Where do the two toolkits meet in XML Demo? The ViewModel: Compose sends selectCommodity, the fragment collects prices.
Practice 2. The hint comes back after the remove and re-add with the default strategy and remember; it stays dismissed with DisposeOnViewTreeLifecycleDestroyed.
Practice
- Add Compose and the chips to XML Demo. Tap "Onion" and check the list shows only onion prices; tap "All" to get them back.
- Break it on purpose: change
rememberSaveabletorememberinFilterHint, add the remove-and-re-add lines to the Refresh button, and try both strategies. Put the code back afterwards. - Remove
XmlDemoThemearoundPricesHeader, run it, and name each colour you see that is not Mandi Bhav's. Then changemandi_greenincolors.xmlto another green and check that the button, the chip and the chart all follow. - Build the Compose detail screen. Then move the data into
factory, tap Refresh, and compare the chart with the text above it. - Add the
ui-tooling-previewandui-toolinglibraries as Mandi Bhav has them, and a@PreviewforPriceDetailScreenwithSamplePrices.all.first(). Look at what the preview pane shows for theAndroidViewand theAndroidViewBinding, and decide how far you can trust a preview of a mixed screen. - Harder: rewrite the price card itself as a composable, use it in the detail screen, and use it inside the
RecyclerViewtoo, by makingitem_price.xmlaComposeView. Measure binds as in lesson 2, check the composition strategy the guide recommends for aComposeViewinside aRecyclerView, and decide whether converting the list was worth it.
Official documentation
- Using Compose in Views —
ComposeView, everyViewCompositionStrategyand when to use it, and multipleComposeViews needing unique ids. - Using Views in Compose —
AndroidViewwithfactoryandupdate,onResetin lazy lists,AndroidViewBindingandAndroidFragment. - Migrate XML themes to Compose — The colour, type and shape mappings from XML theme attributes to Material 3 roles.
- Migration strategy — Google's recommended order for moving a View app to Compose.
Next: module 13, Testing, Profiling and Debugging.
That is module 12. You can now:
- recognise a View codebase from its files, read an XML layout attribute by attribute, and say what each piece would be in Compose;
- explain why
findViewByIdreturnsnull, reproduce the landscape-only crash, and use view binding to turn it into a compile error; - read and write a fragment with a
RecyclerView, aViewHolderand aListAdapter, and find the three classic list bugs:notifyDataSetChangedrebinding every card, recycled cards showing another price's label, and a fragment holding its old view tree through a binding or a collector in the wrong scope; - add Compose to a View app, put a
ComposeViewin a fragment with the right composition strategy, keep the two themes in step, and put old Views and old layouts inside Compose withAndroidViewandAndroidViewBinding; - name what mixing the toolkits costs, and choose what to move first.
Every bug in this module was found by running the code, not by reading it: a list that looked right until it scrolled, a screen that worked until it rotated, a chart that was right until the price changed. Module 13, Testing, Profiling and Debugging, is about finding those before a farmer does: Logcat, breakpoints and the Layout Inspector, unit tests for ViewModels and repositories, tests for Compose UI, profiling jank, memory and battery, and reading a crash from the field.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.
About the internship