RizTech Academy logo
RizTech Academy
Jetpack Compose FundamentalsLesson 6 of 630 min

Material 3 components you will use daily

Material 3 is Google's design system, and the material3 library in your project is its Compose implementation: buttons, cards, text fields, app bars and the scaffolding that holds a screen together. You will not use most of its components in any one app. You will use about a dozen in every app. This lesson covers those, with the traps each one sets, and ends with the first real Mandi Bhav screen: an app bar and five price cards you can scroll.

Text

Text(
    text = price.commodity,
    style = MaterialTheme.typography.titleLarge,
    color = MaterialTheme.colorScheme.onSurfaceVariant,
    maxLines = 1,
    overflow = TextOverflow.Ellipsis,
)

Take the style and color from MaterialTheme rather than writing fontSize = 22.sp and a hex colour, so that module 4's theme, dark mode included, reaches every text at once. maxLines with TextOverflow.Ellipsis cuts a long text with "…". Use it for lists where every row must be the same height, and never for prices: a truncated price is a wrong price.

The type scale has display, headline, title, body and label styles, each in large, medium and small. The ones you will use most:

Style Use it for
headlineSmall / headlineMedium The one number a screen exists to show, such as a modal price
titleLarge / titleMedium Card and section titles, app bar titles
bodyLarge / bodyMedium Normal text
bodySmall Secondary detail
labelLarge Text inside buttons (buttons apply it for you)

Buttons, by emphasis

Material 3 has five common buttons. They differ in how loudly they ask to be pressed:

Button(onClick = {}) { Text("Button") }
FilledTonalButton(onClick = {}) { Text("FilledTonalButton") }
OutlinedButton(onClick = {}) { Text("OutlinedButton") }
ElevatedButton(onClick = {}) { Text("ElevatedButton") }
TextButton(onClick = {}) { Text("TextButton") }
Button Looks like Use it for
Button Solid primary colour The one main action on a screen: "Set price alert"
FilledTonalButton Softer filled tint An important but secondary action
OutlinedButton Outline only A secondary action next to a Button: "Cancel"
ElevatedButton Light fill with a shadow Rare; when a tonal button would disappear into a coloured background
TextButton Text only Low-priority actions, actions inside dialogs and cards

The content of a button is a lambda, so it can hold an icon and a text, not only a string. Two filled Buttons side by side is the common mistake: if everything shouts, the farmer cannot tell which action matters.

Cards

Card, ElevatedCard and OutlinedCard all take a modifier and a content lambda. Card is a tinted surface, ElevatedCard adds a shadow, and OutlinedCard is a bordered surface without a fill. PriceCard uses Card, the quietest of the three, because five shadows down a screen look busy. Cards can also take an onClick, which gives them a ripple and accessibility semantics for you, in the right order.

Icons, and the library that is not there

Most tutorials, and most code you will copy, write Icon(Icons.Default.Search, …). In this project it does not compile:

Unresolved reference 'icons'.

The Icons object lives in a separate library, material-icons-core (and a much larger material-icons-extended), which current Material 3 no longer brings in for you. The Compose documentation now says that library is no longer maintained or recommended: it uses the older Material Icons look and the extended version slows builds down a lot. The recommended route is the newer Material Symbols: on the Google Fonts icons site, choose an icon, choose the Android format, and download its XML. Put the file in res/drawable/, for example ic_search.xml, and draw it with painterResource:

Icon(
    painter = painterResource(R.drawable.ic_search),
    contentDescription = "Search",
    modifier = Modifier.size(24.dp),
)

Icon tints the drawing with the current content colour, so a black icon file still matches the theme. contentDescription is what TalkBack reads aloud. For an icon that only decorates a text that already says the same thing, pass null so TalkBack does not read it twice; for an icon that is the only label on a button, it is required.

Mandi Bhav does not need an icon yet, so the app has none. The networking module adds a refresh action and its icon.

Text fields, and the field you cannot type into

var query by remember { mutableStateOf("") }

OutlinedTextField(
    value = query,
    onValueChange = { query = it },
    label = { Text("Commodity") },
    singleLine = true,
)

A text field does not keep its own text. You pass the current value in, and every key press calls onValueChange with what the text would become. If you do not store that new value somewhere the field reads from, nothing changes. To prove it, this field was run on the emulator:

OutlinedTextField(
    value = "",
    onValueChange = { Log.d("Modifiers", "typed: $it") },
    label = { Text("Stuck") },
)

Typing "onion" logged typed: o, then typed: on, and the field stayed empty: each new value was offered and thrown away, so the next key started again from an empty field. That is not a bug in Compose; it is the declarative model from lesson 1 taken literally. The field shows the data you give it. remember { mutableStateOf("") } is the state module 5 explains properly; for now, it is "a value Compose watches and keeps across recompositions". TextField is the filled version; OutlinedTextField has a border and reads better on a card.

Scaffold and TopAppBar

Scaffold lays out the standard parts of a screen: a top bar, a bottom bar, a floating action button and a snackbar area, with your content in the space left over. It hands your content an innerPadding value: the space taken by the bars, plus the status and navigation bars that enableEdgeToEdge() lets the app draw behind.

TopAppBar is the bar itself. In the Material 3 version in this project it is still marked experimental, and using it without saying so fails the build:

This material API is experimental and is likely to change or to be removed in the future.

You accept that with @OptIn(ExperimentalMaterial3Api::class) on the function that uses it. What that costs: a future library update may change its parameters, and you will fix the call when you upgrade. The bar is used in nearly every Material app, so the risk is small; but the annotation is a note to your future self about where to look first when an update breaks the build.

Mandi Bhav's prices screen

Create ui/PricesScreen.kt:

package com.riztechacademy.mandibhav.ui

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.riztechacademy.mandibhav.R
import com.riztechacademy.mandibhav.data.SamplePrices
import com.riztechacademy.mandibhav.model.CommodityPrice

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PricesScreen(prices: List<CommodityPrice>, modifier: Modifier = Modifier) {
    Scaffold(
        modifier = modifier,
        topBar = {
            TopAppBar(title = { Text(text = stringResource(R.string.app_name)) })
        },
    ) { innerPadding ->
        Column(
            modifier = Modifier
                .padding(innerPadding)
                .verticalScroll(rememberScrollState())
                .padding(horizontal = 16.dp, vertical = 8.dp),
            verticalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            for (price in prices) {
                PriceCard(price = price, modifier = Modifier.fillMaxWidth())
            }
        }
    }
}

@Preview(showBackground = true, showSystemUi = true)
@Composable
private fun PricesScreenPreview() {
    MaterialTheme {
        PricesScreen(prices = SamplePrices.all)
    }
}

Everything here is from this module:

  • It takes prices as a parameter, so it previews with sample data and will later take real data unchanged (lesson 5).
  • The title comes from stringResource(R.string.app_name), the string resource from module 2, not a second copy of "Mandi Bhav" in Kotlin.
  • for (price in prices) emits one PriceCard per price. A composable body is Kotlin, so loops and if work as you expect.
  • Modifier.fillMaxWidth() at the call site; PriceCard applies it to its Card (lesson 4).
  • Arrangement.spacedBy(12.dp) puts 12 dp between cards, and none before the first or after the last.

The modifier chain on the Column is lesson 4 in practice, and each position matters:

  1. padding(innerPadding) first: keeps the whole scrolling area below the app bar and above the navigation bar.
  2. verticalScroll(rememberScrollState()) next: makes everything after it scroll.
  3. padding(horizontal = 16.dp, vertical = 8.dp) last: because it is inside the scroll, it scrolls with the cards, so the last card ends 8 dp above the bottom instead of sticking to it.

Move verticalScroll before padding(innerPadding) and the cards slide underneath the app bar as you scroll.

Why the scroll is there at all

The first version of this screen had no verticalScroll. On a 1080 × 2400 emulator, four cards fitted and the fifth was squashed into the space left, its range line cut in half. A Column measures its children in order and gives the last ones whatever is left; it does not scroll on its own.

verticalScroll fixes it for five cards, at a cost you should know: it composes and lays out every card, including those off screen. For five prices, that is nothing. For the 300 commodity prices of a busy day at Vashi it means 300 cards built before the user sees the first, on a phone with 3 GB of RAM. Module 6 replaces this Column with a LazyColumn, which builds only the rows on screen. rememberScrollState() is state again (module 5): it remembers how far you have scrolled.

Finally, MainActivity becomes as small as it should be:

package com.riztechacademy.mandibhav

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Modifier
import com.riztechacademy.mandibhav.data.SamplePrices
import com.riztechacademy.mandibhav.ui.PricesScreen

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            MaterialTheme {
                PricesScreen(prices = SamplePrices.all, modifier = Modifier.fillMaxSize())
            }
        }
    }
}

Greeting from module 1 is gone; delete it and its preview. Run the app. You should see "Mandi Bhav" in the app bar, then Lasalgaon, Pune and Nashik onion and Pune and Nashik tomato, each with its market, "7 Nov 2026", a large modal price such as ₹2,450 / quintal and its range, and the list scrolls to show the last tomato card in full.

The trap: ignoring innerPadding

Write the Scaffold's content without using innerPadding and the first card is drawn underneath the app bar, with the last one under the navigation bar. Android Studio flags an unused innerPadding in the editor, but the build still succeeds and the screen half-works, which is how it ships. Every Scaffold content lambda applies innerPadding to its outermost element, every time.

Check your work

Which button for the single main action on a screen. Button.

Why Icons.Default.Search does not compile here. The icons library is not a dependency of current Material 3, and the documentation no longer recommends it.

How to add an icon instead. Download the Material Symbol as Android XML into res/drawable and use Icon(painter = painterResource(R.drawable.…), …).

When contentDescription should be null. When the icon is decorative and nearby text already says the same thing.

Why a text field with value = "" cannot be typed into. It shows the value you pass; each new value goes to onValueChange and is discarded.

What @OptIn(ExperimentalMaterial3Api::class) accepts. That the API may change in a future release.

Why padding(innerPadding) comes before verticalScroll. So the scrolling area itself sits between the bars, and cards do not scroll under the app bar.

What verticalScroll costs. Every child is composed and laid out, including those off screen.

Practice 3. Either placement can be argued. Inside the Column, before the loop, it scrolls away with the cards and gives the prices more room; that is the better choice on a small phone. Fixed above the scroll, it needs its own Column round the text and the scrolling part, which module 4 covers.

Practice 4. Inside each card, under the range: TextButton(onClick = { Log.d("Prices", price.commodity) }) { Text("Compare markets") }. A filled Button on every card would put five equally loud actions on one screen, louder than the prices themselves.

Practice 6. The core of one answer, inside PricesScreen's Column:

var query by remember { mutableStateOf("") }
OutlinedTextField(
    value = query,
    onValueChange = { query = it },
    label = { Text("Commodity") },
    singleLine = true,
    modifier = Modifier.fillMaxWidth(),
)
for (price in prices.filter { it.commodity.contains(query, ignoreCase = true) }) {
    PriceCard(price = price, modifier = Modifier.fillMaxWidth())
}

Every key press changes query, so the Column's content re-runs and filter builds a new list of up to five items. For five prices that costs nothing. For thousands, you would filter outside the composable, which is what module 5's ViewModel is for. The cards whose price did not change are skipped, because each is the same instance as before.

Practice

  1. Build the screen and run it. Scroll to the Nashik tomato card.
  2. Remove .verticalScroll(rememberScrollState()), run it, and see the squashed fifth card for yourself. Put it back.
  3. Add a bodySmall line under the app bar content, above the cards: "Prices in ₹ per quintal (100 kg)". Decide whether it should scroll with the cards, and place it accordingly.
  4. Add a TextButton("Compare markets") at the bottom of each card that logs the commodity when tapped. Then say why a filled Button would be the wrong choice there.
  5. Break it on purpose: delete @OptIn(ExperimentalMaterial3Api::class) and build. Read the message. Then remove .padding(innerPadding) and see where the first card goes.
  6. Harder: add an OutlinedTextField above the cards that filters the cards by commodity as you type ("on" shows only onions). Use remember { mutableStateOf("") } for the query and prices.filter { … } for the cards. Then look back at lesson 3 and say what runs on every key press, and whether it matters for five prices.

Official documentation

  • Material components in Compose — Every Material 3 component with examples, for when you need one this lesson skipped.
  • Scaffold — Slots, innerPadding, and adding bottom bars and snackbars.
  • App bars — The top app bar variants and their scrolling behaviours.
  • Material icons in Compose — Why the icons library is no longer recommended, and the Material Symbols route.
  • Material 3 components — The design guidance itself: when to use each component, not only how.

Next: module 4 opens with Row, Column and Box, and how Compose measures a layout.


That is module 3. You can now:

  • explain the difference between describing a screen and mutating one, and why it removes a whole class of out-of-date-UI bugs;
  • write composables that take data and a modifier, and keep money in Long, formatted the same way on every phone and in every test;
  • predict what re-runs when data changes, read the compiler's stability report, and keep expensive work out of composable bodies;
  • order a modifier chain so that margins, tap areas and shapes come out right;
  • iterate with previews fed by sample data, and design screens that can be previewed;
  • assemble a real screen from Scaffold, TopAppBar, Card and Text.

Mandi Bhav now shows five prices in a scrolling column, with a unit test guarding the rupee format. It is also plain: default purple, no dark mode, tested on one phone size. Module 4, Layout and Theming, gives it its own colours and type and makes it work edge to edge, in the dark, on a foldable, with TalkBack, and at the large font sizes many of its users have chosen.

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