RizTech Academy logo
RizTech Academy
Jetpack Compose FundamentalsLesson 1 of 625 min

Declarative UI, and how it differs from XML

For most of Android's life, a screen was a tree of View objects built from an XML file, and your Kotlin code reached into that tree and changed it whenever something happened. Jetpack Compose throws that model away. You write a function that describes what the screen should look like for the data you have, and when the data changes, Compose calls the function again. This lesson shows both ways side by side with one Mandi Bhav price card, because the difference is not syntax. It is who is responsible for keeping the screen correct.

One price card, the old way

Arjun's first job at a Pune agritech start-up is a screen that shows the onion price at Lasalgaon. The app is ten years old, so the card is an XML layout:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:padding="16dp">

    <TextView
        android:id="@+id/commodity"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="22sp" />

    <TextView
        android:id="@+id/modalPrice"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

    <TextView
        android:id="@+id/range"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />
</LinearLayout>

The XML only builds the empty structure. Filling it in happens in Kotlin, by finding each view by its id and setting its text:

class PriceActivity : ComponentActivity() {
    private lateinit var modalPrice: TextView
    private lateinit var range: TextView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.price_card)
        findViewById<TextView>(R.id.commodity).text = "Onion"
        modalPrice = findViewById(R.id.modalPrice)
        range = findViewById(R.id.range)
        showPrice(modal = 2450, min = 1200, max = 2851)
    }

    private fun showPrice(modal: Long, min: Long, max: Long) {
        modalPrice.text = "₹$modal / quintal"
        range.text = "Range ₹$min – ₹$max"
    }

    fun onNewModalPrice(modal: Long) {
        modalPrice.text = "₹$modal / quintal"
    }
}

This style is called imperative: you give step-by-step instructions to change existing objects. setContentView inflates the XML into real TextView objects, findViewById hands you references to them, and from then on every change to the screen is a line of code that mutates one of them.

Look at onNewModalPrice. Someone added it later for a price refresh, updated the modal price and forgot the range. The screen now reads ₹2,600 with a range of ₹1,200 – ₹2,851, which is impossible, and nothing in the compiler or the tests will tell you. In the imperative model the screen is a second copy of your data, and keeping the two copies in step is your job, in every code path, forever. Real apps have dozens of these paths: a refresh, a filter, an error, a rotation, a language change. Each one is a chance to leave one view behind.

The same card in Compose

@Composable
fun SimplePriceCard(commodity: String, modal: Long, min: Long, max: Long) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(text = commodity, fontSize = 22.sp)
        Text(text = "₹$modal / quintal")
        Text(text = "Range ₹$min – ₹$max")
    }
}

There is no XML, no id, no findViewById and no stored reference. The function takes the data and describes the card for that data. Column stacks its children vertically, the way the vertical LinearLayout did. Text is a piece of text on screen.

To show a new modal price you do not find the price text and change it. You call SimplePriceCard again with the new numbers, and the whole description is produced again from the same data. There is no second copy to drift, so the "forgot the range" bug cannot be written. This style is called declarative: you declare what the screen is for a given state, and the framework works out what on the real screen has to change.

data (today's prices) → composable functions → what you see on screen

an event (tap, refresh, new prices) → the data changes → the functions run again

The flow goes one way. Events change data; data produces UI. The UI never changes itself.

"Calls the function again" sounds expensive

It would be, if Compose rebuilt every pixel each time. It does not. When the data changes, Compose re-runs only the composable functions that read the changed data, compares what they describe with what is already on screen, and updates the difference. A card whose inputs did not change is skipped. That process is recomposition, and it has a whole lesson to itself two lessons from now, because it is where Compose either flies or crawls depending on what you put in your functions.

Translating what you already know

If you have read any XML-era Android code, this table maps the ideas across. Come back to it when you meet an old screen in the module on XML layouts.

View system (XML) Compose Note
TextView Text
Button Button Takes an onClick lambda instead of a listener
ImageView Image, Icon
LinearLayout vertical Column
LinearLayout horizontal Row
FrameLayout Box Children stacked on top of each other
RecyclerView + adapter LazyColumn No adapter, no view holder; module 6
android:padding, layout_width Modifier.padding, Modifier.fillMaxWidth Lesson 4 of this module
findViewById, view binding nothing You never hold a reference to a piece of UI
textView.text = … call the composable with new data
styles.xml, themes MaterialTheme Module 4
onSaveInstanceState for a field rememberSaveable, ViewModel Module 5

What Compose is not

It is not a web view. Compose UI is native Kotlin code that measures and draws onto the same Android canvas that views draw onto. There is no HTML, no JavaScript bridge, and no browser engine.

It is not automatically faster. A badly written composable that sorts a list or parses a date every time it runs will be slower than a carefully written RecyclerView. Compose makes correct UI easier to write; it does not make expensive work free. Debug builds also run Compose noticeably slower than release builds, because the code is not optimised, so never judge scrolling smoothness from a debug build on the emulator.

It does not replace XML in apps that already exist. Most production apps you join will mix both for years. Module 12 teaches you to read layouts and put Compose inside an XML screen and the other way round. New screens are written in Compose.

What it costs you. A new mental model, which is the subject of this module, and a habit you must unlearn: wanting to grab a piece of UI and change it.

The trap: trying to hold on to a piece of UI

The old instinct is to keep a reference so you can update it later. In Compose it fails quietly:

@Composable
fun HoldingOnToText() {
    val label = Text(text = "Onion")
    // label.text = "Tomato"   does not compile: label is Unit
}

Text does not return a text object. Like every composable that emits UI, it returns Unit, which is Kotlin's "nothing useful". The function's job is to describe UI while it runs, not to give you a handle on it afterwards. If you find yourself wanting to change a piece of UI, change the data it was built from instead. That sentence is most of this module and all of module 5.

Check your work

What findViewById does in the old model. Returns a reference to a View object that setContentView built from the XML, so your code can mutate it.

Why onNewModalPrice was a bug, though it compiled. It updated one view and left the range showing old numbers. In the imperative model every code path must update every affected view, and nothing checks that it did.

What "declarative" means here. You describe the UI for the current data; the framework works out what to change on screen.

Why the "forgot the range" bug cannot happen in SimplePriceCard. The whole card is described from one set of inputs every time. There is no stored view holding an old value.

What recomposition is, in one sentence. Compose re-running the composables whose inputs changed and applying the difference to the screen.

What Text(...) returns. Unit. You cannot keep a reference to UI in Compose.

The Compose equivalent of RecyclerView. LazyColumn, covered in module 6.

Practice

  1. Open MainActivity.kt in your Mandi Bhav project. Find Greeting from module 1 and say out loud which line describes UI and which data it reads.
  2. Copy SimplePriceCard into MainActivity.kt (you will need the imports Android Studio offers with Alt + Enter on Windows and Linux, Option + Return on macOS). Call it from setContent with Lasalgaon's onion numbers and run it.
  3. Change the call to pass a modal price of 2600 and run again. Notice you changed only the data.
  4. Write down three events in a mandi price app that would change what the price card shows. For each, say which piece of data changes.
  5. Break it on purpose: inside SimplePriceCard, write val t = Text("x") and then t.text = "y". Read the compiler error. It is telling you exactly what this lesson said.
  6. Harder: take the XML version and list every code path that would need to update range if the app also supported a "show prices in ₹ per kg" switch. Then say what would change in the Compose version.

Official documentation

  • Thinking in Compose — The declarative model from the Compose team, including the rules composables must follow.
  • Compose tutorial — A short guided build if you want a second explanation of the basics.
  • Lifecycle of composables — How composition and recomposition work, which the recomposition lesson builds on.

Next: composable functions, and the first real piece of Mandi Bhav, the PriceCard.

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