Why XML layouts are still everywhere
Module 3 showed you a price card written as an XML layout, set it beside the Compose version, and promised you would meet the old way again. This is that module. Everything you have built so far is Compose, and new Android screens should be. But the first app you are paid to work on will very likely have been started before Compose existed, and on your first morning somebody will ask you to change a screen made of res/layout/*.xml, a Fragment and a RecyclerView. This module teaches you to read that code, avoid its classic bugs, and add Compose to it without breaking it. It does not teach you to design new screens in XML.
Why the XML is still there
For the first thirteen years of Android, from 2008 until Compose's first stable release in 2021, Views were the only UI toolkit. Every screen in an app of that age was an XML layout inflated into View objects. Three things keep that code alive:
- Rewriting a working screen earns nothing on its own. A team that rewrites forty screens in Compose spends months shipping an app that looks the same. So teams migrate screen by screen, as screens need changing anyway, and a large app stays mixed for years.
- Views are not deprecated.
TextView,RecyclerViewandConstraintLayoutstill get releases. Some things you need only exist as Views:WebView, many maps, ads and payment SDKs. A Compose app shows them through an adapter you will meet in lesson 3. - Compose itself sits inside a View. On the course emulator, a
ComponentActivitythat callssetContent { Text("Namaste from Compose") }has this view tree under the activity's content frame, read from the running app:
android.widget.FrameLayout > [androidx.compose.ui.platform.ComposeView > [androidx.compose.ui.platform.AndroidComposeView > ...]]
setContent adds one View, a ComposeView, and Compose draws everything inside it. That is why the two toolkits can share a screen at all.
What this module is not for. It will not make you fast at the Layout Editor, teach every attribute of every widget, or cover the data binding library beyond recognising it. It gives you enough to read, fix and extend a View screen, and to move it towards Compose.
Recognising a View codebase
The first hour in a new project is reading. These are the files that tell you which toolkit a screen uses:
| You find | What it is | The Compose idea it matches |
|---|---|---|
res/layout/activity_main.xml |
A layout: a tree of views, inflated at run time | A composable function |
setContentView(R.layout.activity_main) |
Inflates a layout into an activity | setContent { } |
findViewById<TextView>(R.id.price) |
Looks up one view in the inflated tree | Nothing: you never hold UI |
ActivityMainBinding.inflate(...) |
View binding: generated, typed references to views | Nothing |
<layout> root, @{viewModel.price} in XML |
The data binding library | State read in a composable |
import kotlinx.android.synthetic... |
Kotlin synthetics: deprecated, no longer supported | Nothing; migrate to view binding |
class PricesFragment : Fragment() |
A reusable piece of screen with its own lifecycle | A screen composable plus a navigation entry |
RecyclerView, Adapter, ViewHolder |
A recycling list | LazyColumn |
res/values/themes.xml with Theme.Material3... |
The View theme | MaterialTheme |
A practice project
There is no Mandi Bhav checkpoint for this module, because Mandi Bhav stays Compose. Instead you build a small View app, XML Demo, that shows the same prices: the View version of the screen you built in modules 3 and 6, so you can compare the two. Create a new project the way module 1 did, named XML Demo, package com.riztechacademy.xmldemo. Delete the generated ui/theme folder and the generated MainActivity; you are about to replace the build files and every source file, and the code in this module is complete enough to type in.
gradle/libs.versions.toml:
[versions]
agp = "9.5.1"
kotlin = "2.4.21"
coreKtx = "1.19.1"
appcompat = "1.8.0"
material = "1.14.0"
constraintlayout = "2.2.2"
recyclerview = "1.4.0"
fragment = "1.9.1"
lifecycle = "2.11.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragment" }
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
The root build.gradle.kts keeps both plugins with apply false, exactly as module 1 left it. app/build.gradle.kts, with no Compose at all for now:
plugins {
alias(libs.plugins.android.application)
}
android {
namespace = "com.riztechacademy.xmldemo"
compileSdk {
version = release(37) {
minorApiLevel = 2
}
}
defaultConfig {
applicationId = "com.riztechacademy.xmldemo"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "0.1"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
viewBinding = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.recyclerview)
implementation(libs.androidx.fragment.ktx)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
}
appcompat gives you AppCompatActivity, which fragments need; material gives you MaterialCardView and the Material 3 View themes. viewBinding = true is used later in this lesson.
The View theme, in res/values/themes.xml, replacing the file's contents. The widgets read their colours from here, not from Kotlin. If the project has a res/values-night/themes.xml, delete it: a DayNight parent already handles dark mode.
<resources>
<style name="Theme.XmlDemo" parent="Theme.Material3.DayNight.NoActionBar">
<item name="colorPrimary">@color/mandi_green</item>
<item name="colorPrimaryContainer">@color/mandi_green_container</item>
<item name="colorOnPrimaryContainer">@color/on_mandi_green_container</item>
</style>
</resources>
res/values/colors.xml gets three colours. Keep any colours already in the file; the launcher icon may use them, and deleting one breaks the build.
<resources>
<!-- Mandi Bhav's light-theme greens, the ones MandiBhavTheme uses in Compose. -->
<color name="mandi_green">#1B6D24</color>
<color name="mandi_green_container">#B8F0B0</color>
<color name="on_mandi_green_container">#002106</color>
</resources>
res/values/strings.xml:
<resources>
<string name="app_name">XML Demo</string>
<string name="per_quintal">per quintal</string>
<string name="min_price">Min %1$s</string>
<string name="max_price">Max %1$s</string>
</resources>
In AndroidManifest.xml, the <application> gets android:theme="@style/Theme.XmlDemo", and MainActivity stays the launcher activity. Leave the rest of the manifest and the template's other resources as they are.
The model, in CommodityPrice.kt: Mandi Bhav's price with yesterday's modal price added, and the same formatRupees from module 3.
package com.riztechacademy.xmldemo
import java.time.LocalDate
/** One day's price for one commodity at one market, in whole rupees per quintal. */
data class CommodityPrice(
val id: String,
val commodity: String,
val market: String,
val date: LocalDate,
val minPrice: Long,
val maxPrice: Long,
val modalPrice: Long,
val yesterdayModal: Long,
)
/**
* Whole rupees with Indian digit grouping: 245000 -> "₹2,45,000".
* The same function as Mandi Bhav's.
*/
fun formatRupees(amount: Long): String {
val digits = amount.toString().removePrefix("-")
val grouped = if (digits.length <= 3) {
digits
} else {
val lastThree = digits.takeLast(3)
val rest = digits.dropLast(3)
rest.reversed().chunked(2).joinToString(",").reversed() + "," + lastThree
}
val sign = if (amount < 0) "-" else ""
return "$sign₹$grouped"
}
And SamplePrices.kt, the nine prices. (Lesson 2 adds a function to this object.)
package com.riztechacademy.xmldemo
import java.time.LocalDate
object SamplePrices {
private val day = LocalDate.of(2026, 11, 8)
private fun price(market: String, commodity: String, min: Long, max: Long, modal: Long, yesterday: Long) =
CommodityPrice(
id = "${market.substringBefore(' ').lowercase()}-${commodity.lowercase().replace(' ', '-')}-$day",
commodity = commodity,
market = market,
date = day,
minPrice = min,
maxPrice = max,
modalPrice = modal,
yesterdayModal = yesterday,
)
val all: List<CommodityPrice> = listOf(
price("Lasalgaon APMC", "Onion", 1_200, 2_851, 2_450, 2_300),
price("Pune (Gultekdi) APMC", "Onion", 1_500, 2_900, 2_600, 2_650),
price("Nashik APMC", "Onion", 1_100, 2_700, 2_350, 2_350),
price("Pune (Gultekdi) APMC", "Tomato", 800, 1_900, 1_400, 1_250),
price("Nashik APMC", "Tomato", 700, 1_750, 1_300, 1_450),
price("Vashi APMC, Navi Mumbai", "Potato", 1_300, 2_200, 1_800, 1_800),
price("Vashi APMC, Navi Mumbai", "Wheat", 2_500, 3_100, 2_850, 2_800),
price("Lasalgaon APMC", "Soybean", 4_200, 4_900, 4_650, 4_700),
price("Pune (Gultekdi) APMC", "Tur dal", 9_800, 11_200, 10_500, 10_400),
)
}
Reading a layout
res/layout/activity_main.xml, one price card. Type it in rather than pasting; every attribute is in the table after it.
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.google.android.material.card.MaterialCardView
android:id="@+id/card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<TextView
android:id="@+id/commodity"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleLarge"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Onion" />
<TextView
android:id="@+id/market"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/commodity"
tools:text="Lasalgaon APMC" />
<TextView
android:id="@+id/date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/market"
tools:text="8 Nov 2026" />
<TextView
android:id="@+id/modalPrice"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAppearance="?attr/textAppearanceHeadlineSmall"
android:textColor="?attr/colorPrimary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/date"
tools:text="₹2,450" />
<TextView
android:id="@+id/perQuintal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:text="@string/per_quintal"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
app:layout_constraintBaseline_toBaselineOf="@id/modalPrice"
app:layout_constraintStart_toEndOf="@id/modalPrice" />
<TextView
android:id="@+id/minPrice"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="?attr/textAppearanceBodyMedium"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/modalPrice"
tools:text="Min ₹1,200" />
<TextView
android:id="@+id/maxPrice"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyMedium"
app:layout_constraintBaseline_toBaselineOf="@id/minPrice"
app:layout_constraintEnd_toEndOf="parent"
tools:text="Max ₹2,851" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
</FrameLayout>
Read it from the outside in: a FrameLayout filling the screen, holding a card 16 dp from the edges, holding a ConstraintLayout that places seven TextViews against each other. It is module 3's PriceCard without the picture: the same Column of lines, the modal price and "per quintal" sharing a baseline, Min on the left and Max on the right.
| Attribute | Means | Compose equivalent |
|---|---|---|
android:id="@+id/market" |
Creates the id R.id.market (+ means create it) |
none |
@id/market |
Refers to an id that already exists | none |
layout_width="match_parent" |
As wide as the parent allows | Modifier.fillMaxWidth() |
layout_width="wrap_content" |
As wide as the content | the default |
layout_width="0dp" inside ConstraintLayout |
"Match constraints": as wide as its start and end constraints allow | Modifier.weight(1f) or a constrained width |
app:layout_constraintTop_toBottomOf="@id/date" |
My top sits on that view's bottom | the next line in a Column |
app:layout_constraintBaseline_toBaselineOf |
Align text baselines | Modifier.alignByBaseline() |
android:layout_margin, android:padding |
Space outside, space inside | Modifier.padding before or after a background |
?attr/colorPrimary |
A value from the current theme | MaterialTheme.colorScheme.primary |
@string/per_quintal, @color/mandi_green |
A resource | stringResource, colorResource |
tools:text="Onion" |
Shown in the Layout Editor only, never in the app | @Preview data |
xmlns:android, xmlns:app, xmlns:tools |
Three namespaces: the platform's attributes, libraries' attributes, design-time attributes | none |
The tools: attributes are worth a second look: the card in the preview pane says "Onion" and "₹2,450", and the running app shows empty text until Kotlin fills it in. A screen that looks right in the editor and blank on the phone usually means the code never set a value that only tools:text was supplying.
Filling it in with findViewById
MainActivity.kt, the way most screens in an older codebase are written:
package com.riztechacademy.xmldemo
import android.os.Bundle
import android.widget.TextView
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import java.time.format.DateTimeFormatter
import java.util.Locale
private val priceDateFormat = DateTimeFormatter.ofPattern("d MMM yyyy", Locale.ENGLISH)
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_main)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { view, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
insets
}
showPrice(SamplePrices.all.first())
}
private fun showPrice(price: CommodityPrice) {
findViewById<TextView>(R.id.commodity).text = price.commodity
findViewById<TextView>(R.id.market).text = price.market
findViewById<TextView>(R.id.date).text = priceDateFormat.format(price.date)
findViewById<TextView>(R.id.modalPrice).text = formatRupees(price.modalPrice)
findViewById<TextView>(R.id.minPrice).text = getString(R.string.min_price, formatRupees(price.minPrice))
findViewById<TextView>(R.id.maxPrice).text = getString(R.string.max_price, formatRupees(price.maxPrice))
}
}
The insets listener is module 4's edge-to-edge in View form: the app draws behind the system bars, so the root view pads itself by their size. Run it and you get the Lasalgaon onion card, ₹2,450 in green, Min ₹1,200 and Max ₹2,851 on one line.
The trap: findViewById returns null
findViewById searches the views that exist right now and returns null if none has that id. Kotlin cannot see that: the Java signature makes it a platform type, so .text = ... compiles and fails at run time. Two real ways to get there.
Looking before the layout exists. Move showPrice(...) above setContentView(...) and launch:
E/AndroidRuntime: java.lang.RuntimeException: Unable to start activity ComponentInfo{com.riztechacademy.xmldemo/com.riztechacademy.xmldemo.MainActivity}: java.lang.NullPointerException: Attempt to invoke virtual method 'void android.widget.TextView.setText(java.lang.CharSequence)' on a null object reference
E/AndroidRuntime: Caused by: java.lang.NullPointerException: Attempt to invoke virtual method 'void android.widget.TextView.setText(java.lang.CharSequence)' on a null object reference
E/AndroidRuntime: at com.riztechacademy.xmldemo.MainActivity.showPrice(MainActivity.kt:30)
E/AndroidRuntime: at com.riztechacademy.xmldemo.MainActivity.onCreate(MainActivity.kt:19)
A layout that only sometimes has the view. This one ships. Kavita adds a landscape layout so the price sits beside the market on a sideways phone: she copies activity_main.xml into a new folder, res/layout-land/, rearranges it, and drops the Max line because it "did not fit". Portrait still works, every test passes, and the app dies the moment a farmer turns the phone:
E/AndroidRuntime: Caused by: java.lang.NullPointerException: Attempt to invoke virtual method 'void android.widget.TextView.setText(java.lang.CharSequence)' on a null object reference
E/AndroidRuntime: at com.riztechacademy.xmldemo.MainActivity.showPrice(MainActivity.kt:34)
E/AndroidRuntime: at com.riztechacademy.xmldemo.MainActivity.onCreate(MainActivity.kt:25)
Line 34 is the maxPrice line. Module 2 explained why rotation runs onCreate again; with a -land layout, it also inflates a different file.
View binding
View binding generates one class per layout, named after the file: activity_main.xml becomes ActivityMainBinding, with one typed field per view that has an id. Replace MainActivity with:
package com.riztechacademy.xmldemo
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.riztechacademy.xmldemo.databinding.ActivityMainBinding
import java.time.format.DateTimeFormatter
import java.util.Locale
private val priceDateFormat = DateTimeFormatter.ofPattern("d MMM yyyy", Locale.ENGLISH)
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
insets
}
showPrice(SamplePrices.all.first())
}
private fun showPrice(price: CommodityPrice) {
binding.commodity.text = price.commodity
binding.market.text = price.market
binding.date.text = priceDateFormat.format(price.date)
binding.modalPrice.text = formatRupees(price.modalPrice)
binding.minPrice.text = getString(R.string.min_price, formatRupees(price.minPrice))
binding.maxPrice.text = getString(R.string.max_price, formatRupees(price.maxPrice))
}
}
inflate builds the views and finds them all at once, and you cannot touch binding before it exists. With Kavita's landscape layout still in place, the build now fails instead of the app:
e: file:///.../app/src/main/java/com/riztechacademy/xmldemo/MainActivity.kt:37:25 Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type 'TextView?'.
A view that is missing from any configuration of a layout becomes a nullable field in the generated class. The generated Java, under app/build/generated, says so plainly:
@Nullable
public final TextView perQuintal;
(Kavita's copy dropped "per quintal" too, and her Max line.) View binding turns "crashes when the phone rotates" into "does not compile". Fix it the right way, by putting the Max TextView back in the landscape layout, not by adding ?., which would hide the price from every farmer holding the phone sideways.
What view binding costs: one generated class per layout, a little build time, and one more thing to remember in fragments, which is the next lesson's trap. What it is not: data binding. It never puts code in XML. If you see <layout> as a root tag and @{...} expressions, that is the data binding library; read it as "this view's value comes from that expression", and leave it alone until you have a reason to remove it.
Check your work
Why does findViewById before setContentView crash? No views exist yet, so it returns null, and setting text on null throws.
Why did the landscape bug pass every test in portrait? The -land layout is inflated only in landscape; portrait never touches it.
What does layout_width="0dp" mean in a ConstraintLayout? Take the width the start and end constraints allow.
What does tools:text change in the running app? Nothing. It is for the editor.
What does view binding do with a view missing from one configuration? Makes its field nullable, so the code must handle it, and an unhandled use fails to compile.
You see import kotlinx.android.synthetic.main.activity_main.*. What is it? Kotlin synthetics, deprecated and unsupported; the fix is to migrate that screen to view binding.
Practice
- Build XML Demo from this lesson and run it. Then open
activity_main.xmlin Android Studio's Design view, changetools:text="Onion"totools:text="Tur dal", and confirm the phone still shows Onion. - Break it on purpose: move
showPriceabovesetContentViewin thefindViewByIdversion, run, and find your own line numbers in the stack trace. - Reproduce Kavita's bug: copy
activity_main.xmlintores/layout-land/, delete themaxPriceTextView, run thefindViewByIdversion, and rotate the emulator. Then switch to view binding and read the compiler error. - Change
binding.maxPrice.text = ...tobinding.maxPrice?.text = ...with the landscape layout still broken. It compiles and runs. Write down what a farmer in landscape now sees, and why this is worse than a build error. - Add a
TextViewwith noandroid:id. Look in the generatedActivityMainBindingfor it, and explain what you find. - Harder: in
res/layout-land/activity_main.xml, change themarketview from aTextViewto anEditText. Build, and read what type the binding field now has. The view binding guide's section on configurations explains it and thetools:viewBindingTypefix; apply the fix.
Official documentation
- View binding — Setup, activities, fragments, nullable fields for configuration-only views, and
tools:viewBindingType. - Layouts in views — How an XML layout becomes a tree of views, ids, layout parameters and inflation.
- Build a responsive UI with ConstraintLayout — Every constraint type, chains, guidelines and the "match constraints" width.
- Migrate from Kotlin synthetics to Jetpack view binding — For the day you find
kotlinx.android.syntheticimports.
Next: a fragment, a RecyclerView, and three bugs that only exist in View code.
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