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
pricesas 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 onePriceCardper price. A composable body is Kotlin, so loops andifwork as you expect.Modifier.fillMaxWidth()at the call site;PriceCardapplies it to itsCard(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:
padding(innerPadding)first: keeps the whole scrolling area below the app bar and above the navigation bar.verticalScroll(rememberScrollState())next: makes everything after it scroll.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
- Build the screen and run it. Scroll to the Nashik tomato card.
- Remove
.verticalScroll(rememberScrollState()), run it, and see the squashed fifth card for yourself. Put it back. - Add a
bodySmallline 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. - Add a
TextButton("Compare markets")at the bottom of each card that logs the commodity when tapped. Then say why a filledButtonwould be the wrong choice there. - Break it on purpose: delete
@OptIn(ExperimentalMaterial3Api::class)and build. Read the message. Then remove.padding(innerPadding)and see where the first card goes. - Harder: add an
OutlinedTextFieldabove the cards that filters the cards by commodity as you type ("on" shows only onions). Useremember { mutableStateOf("") }for the query andprices.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,CardandText.
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