RizTech Academy logo
RizTech Academy
Meeting XML LayoutsLesson 1 of 330 min

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, RecyclerView and ConstraintLayout still 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 ComponentActivity that calls setContent { 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

  1. Build XML Demo from this lesson and run it. Then open activity_main.xml in Android Studio's Design view, change tools:text="Onion" to tools:text="Tur dal", and confirm the phone still shows Onion.
  2. Break it on purpose: move showPrice above setContentView in the findViewById version, run, and find your own line numbers in the stack trace.
  3. Reproduce Kavita's bug: copy activity_main.xml into res/layout-land/, delete the maxPrice TextView, run the findViewById version, and rotate the emulator. Then switch to view binding and read the compiler error.
  4. Change binding.maxPrice.text = ... to binding.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.
  5. Add a TextView with no android:id. Look in the generated ActivityMainBinding for it, and explain what you find.
  6. Harder: in res/layout-land/activity_main.xml, change the market view from a TextView to an EditText. Build, and read what type the binding field now has. The view binding guide's section on configurations explains it and the tools:viewBindingType fix; apply the fix.

Official documentation

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