Reading a layout and a RecyclerView
Module 6 replaced a Column of 500 prices with a LazyColumn and three lines of Kotlin: items(prices) { PriceCard(it) }. The View system's answer to the same problem is RecyclerView, and it takes an adapter, a view holder, a diff callback, a layout manager and a fragment to host them. That is a lot of ceremony, and almost every piece of it is a place a real bug lives. This lesson builds the XML Demo price list the way a pre-2021 codebase would have it, then reproduces three of those bugs on the emulator and measures them: a list that redraws everything when one price changes, a "price up" label that appears on a price that did not move, and a screen that keeps its entire old view tree in memory after you leave it.
From one card to an item layout
A list needs the card as a layout of its own. Create res/layout/item_price.xml: cut the whole MaterialCardView out of activity_main.xml and paste it in, so the card is the root. A root element must declare the namespaces, and the 16 dp all round becomes 16 dp at the sides and 6 dp above and below, so cards in a list do not sit 32 dp apart:
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView 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:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginVertical="6dp">
The ConstraintLayout and its seven TextViews are unchanged. Add an eighth after perQuintal, for how the modal price moved since yesterday. It starts hidden: gone takes up no space at all, where invisible would leave a hole.
<TextView
android:id="@+id/trend"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceLabelLarge"
android:visibility="gone"
app:layout_constraintBaseline_toBaselineOf="@id/modalPrice"
app:layout_constraintEnd_toEndOf="parent"
tools:text="Up ₹150"
tools:visibility="visible" />
Delete res/layout-land/ if you made it in lesson 1, and add the new strings to strings.xml:
<string name="refresh">Refresh</string>
<string name="detail_title">%1$s at %2$s</string>
<string name="trend_up">Up %1$s</string>
<string name="trend_down">Down %1$s</string>
Fragments, the screens of a View app
Before Compose and Navigation 3, a screen was usually a Fragment: a piece of UI with its own layout and lifecycle, hosted in an activity, swapped in and out by a FragmentManager that keeps a back stack. One activity, many fragments, is the single-activity architecture module 2 described, done with Views. activity_main.xml becomes a container that creates the first fragment by class name:
<?xml version="1.0" encoding="utf-8"?>
<androidx.fragment.app.FragmentContainerView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/fragmentContainer"
android:name="com.riztechacademy.xmldemo.PricesFragment"
android:layout_width="match_parent"
android:layout_height="match_parent" />
MainActivity shrinks to the edge-to-edge set-up; it no longer knows about prices:
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
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val 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
}
}
}
The one fact about fragments that every bug in this lesson grows from: a fragment outlives its view. When you open a detail screen with addToBackStack, the list fragment stays alive on the back stack, and its view is destroyed. Come back and it builds a new view. The callbacks you will read in every fragment:
| Callback | When | What goes here |
|---|---|---|
onCreate |
The fragment is created | Read arguments; nothing about views |
onCreateView |
It needs a view | Inflate the layout (or the binding) and return it |
onViewCreated |
The view exists | Adapters, click listeners, start collecting state |
onStart / onStop |
Visible / not visible | Usually handled for you by repeatOnLifecycle |
onDestroyView |
The view is being thrown away; the fragment may live on | Drop every reference to the view |
onDestroy |
The fragment itself is finished | Rarely needed |
A fragment has two lifecycles: its own (this, lifecycleScope) and its view's (viewLifecycleOwner). Anything that touches views belongs to the second.
The data
The list experiments need a busy day, as module 6 did. Add to SamplePrices (with import kotlin.random.Random):
/**
* [count] prices for list experiments, each with its own id: the nine
* samples first, then the samples in a random (but repeatable) order.
*/
fun many(count: Int): List<CommodityPrice> {
val random = Random(2026)
return List(count) { i ->
val p = if (i < all.size) all[i] else all[random.nextInt(all.size)]
p.copy(id = "${p.id}-$i")
}
}
The random order is not decoration; one of the bugs below hides when the list repeats exactly. PricesViewModel.kt is module 5's pattern with nothing new:
package com.riztechacademy.xmldemo
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
class PricesViewModel : ViewModel() {
// 500 prices: a busy day at Vashi, as in module 6.
private val _prices = MutableStateFlow(SamplePrices.many(500))
val prices: StateFlow<List<CommodityPrice>> = _prices.asStateFlow()
fun price(id: String): CommodityPrice? = _prices.value.find { it.id == id }
/**
* The 4 pm closing prices arrive: a brand-new list, as a server or a
* database would give you, in which only the first price has changed.
*/
fun refresh() {
_prices.update { prices ->
prices.mapIndexed { i, price ->
if (i == 0) price.copy(modalPrice = price.modalPrice + 150) else price.copy()
}
}
}
}
RecyclerView, piece by piece
| Piece | Its job | In Compose |
|---|---|---|
RecyclerView |
The scrolling container | LazyColumn |
LayoutManager |
Arranges items: a list, a grid | LazyColumn versus LazyVerticalGrid |
ViewHolder |
One inflated item layout, kept for reuse | A composition the lazy list reuses |
Adapter |
Creates holders and binds data into them | The items { } block |
DiffUtil.ItemCallback |
Says which items are the same, and whether they changed | Keys, and skipping unchanged inputs |
ListAdapter |
An adapter that diffs each new list for you, off the main thread | Recomposition |
res/layout/fragment_prices.xml: a Refresh button over the list. app:layoutManager sets the layout manager from XML, and tools:listitem makes the editor preview fill the list with cards.
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.button.MaterialButton
android:id="@+id/refresh"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:text="@string/refresh" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/priceList"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_price" />
</LinearLayout>
layout_height="0dp" with layout_weight="1" is LinearLayout's way of saying "take the space left over", the Modifier.weight(1f) of XML.
PriceAdapter.kt, the heart of the lesson:
package com.riztechacademy.xmldemo
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.riztechacademy.xmldemo.databinding.ItemPriceBinding
import java.time.format.DateTimeFormatter
import java.util.Locale
private val priceDateFormat = DateTimeFormatter.ofPattern("d MMM yyyy", Locale.ENGLISH)
/** Fills one price card. Sets every view, every time: the card may be showing another price. */
fun ItemPriceBinding.showPrice(price: CommodityPrice) {
val context = root.context
commodity.text = price.commodity
market.text = price.market
date.text = priceDateFormat.format(price.date)
modalPrice.text = formatRupees(price.modalPrice)
minPrice.text = context.getString(R.string.min_price, formatRupees(price.minPrice))
maxPrice.text = context.getString(R.string.max_price, formatRupees(price.maxPrice))
val change = price.modalPrice - price.yesterdayModal
when {
change > 0 -> {
trend.visibility = View.VISIBLE
trend.text = context.getString(R.string.trend_up, formatRupees(change))
}
change < 0 -> {
trend.visibility = View.VISIBLE
trend.text = context.getString(R.string.trend_down, formatRupees(-change))
}
else -> trend.visibility = View.GONE
}
}
class PriceViewHolder(
private val binding: ItemPriceBinding,
) : RecyclerView.ViewHolder(binding.root) {
fun bind(price: CommodityPrice, onPriceClick: (CommodityPrice) -> Unit) {
binding.showPrice(price)
binding.root.setOnClickListener { onPriceClick(price) }
}
companion object {
fun create(parent: ViewGroup): PriceViewHolder {
val inflater = LayoutInflater.from(parent.context)
return PriceViewHolder(ItemPriceBinding.inflate(inflater, parent, false))
}
}
}
class PriceAdapter(
private val onPriceClick: (CommodityPrice) -> Unit,
) : ListAdapter<CommodityPrice, PriceViewHolder>(PriceDiff) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PriceViewHolder =
PriceViewHolder.create(parent)
override fun onBindViewHolder(holder: PriceViewHolder, position: Int) {
holder.bind(getItem(position), onPriceClick)
}
}
private object PriceDiff : DiffUtil.ItemCallback<CommodityPrice>() {
override fun areItemsTheSame(oldItem: CommodityPrice, newItem: CommodityPrice) =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: CommodityPrice, newItem: CommodityPrice) =
oldItem == newItem
}
Read it as two questions the list asks. onCreateViewHolder: "I need a new card; build one." onBindViewHolder: "Show price number position in this card, which may have shown a different price a second ago." The inflate(inflater, parent, false) matters: parent lets the card's margins and match_parent work, and false leaves adding the card to the RecyclerView. areItemsTheSame is the key from module 6; areContentsTheSame decides whether a card needs binding again.
PricesFragment.kt:
package com.riztechacademy.xmldemo
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.commit
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.riztechacademy.xmldemo.databinding.FragmentPricesBinding
import kotlinx.coroutines.launch
class PricesFragment : Fragment() {
private var _binding: FragmentPricesBinding? = null
// Only valid between onCreateView and onDestroyView.
private val binding get() = _binding!!
private val viewModel: PricesViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
_binding = FragmentPricesBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val adapter = PriceAdapter(onPriceClick = ::openDetail)
binding.priceList.adapter = adapter
binding.refresh.setOnClickListener { viewModel.refresh() }
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.prices.collect { prices -> adapter.submitList(prices) }
}
}
}
private fun openDetail(price: CommodityPrice) {
parentFragmentManager.commit {
replace(R.id.fragmentContainer, PriceDetailFragment.newInstance(price.id))
addToBackStack(null)
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
activityViewModels() shares one PricesViewModel between every fragment in the activity, so the detail screen can look prices up. repeatOnLifecycle(STARTED) is the View-world collectAsStateWithLifecycle: collect while visible, stop when not.
The detail screen gets a small layout, res/layout/fragment_price_detail.xml:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceHeadlineMedium" />
<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" />
</LinearLayout>
and PriceDetailFragment.kt, which receives the id as an argument, the fragment version of module 6's route carrying an id only:
package com.riztechacademy.xmldemo
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import com.riztechacademy.xmldemo.databinding.FragmentPriceDetailBinding
class PriceDetailFragment : Fragment() {
private var _binding: FragmentPriceDetailBinding? = null
private val binding get() = _binding!!
private val viewModel: PricesViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
_binding = FragmentPriceDetailBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val price = viewModel.price(requireArguments().getString(ARG_PRICE_ID)!!) ?: return
binding.title.text = getString(R.string.detail_title, price.commodity, price.market)
binding.modalPrice.text = formatRupees(price.modalPrice)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
companion object {
private const val ARG_PRICE_ID = "priceId"
fun newInstance(priceId: String) = PriceDetailFragment().apply {
arguments = bundleOf(ARG_PRICE_ID to priceId)
}
}
}
Run it: Refresh at the top, then cards, Lasalgaon onion first with "Up ₹150", Pune onion "Down ₹50", Nashik onion with no label because its price did not move. Tap a card for its detail; Back returns to the list.
Trap 1: notifyDataSetChanged
Older adapters are not ListAdapters. They hold a list and, when it changes, call notifyDataSetChanged(). To compare, put this one in PriceAdapter.kt and, in a scratch copy of PricesFragment, use it with notifyAdapter.prices = prices in place of submitList:
class NotifyPriceAdapter(
private val onPriceClick: (CommodityPrice) -> Unit,
) : RecyclerView.Adapter<PriceViewHolder>() {
var prices: List<CommodityPrice> = emptyList()
set(value) {
field = value
notifyDataSetChanged()
}
override fun getItemCount() = prices.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = PriceViewHolder.create(parent)
override fun onBindViewHolder(holder: PriceViewHolder, position: Int) {
holder.bind(prices[position], onPriceClick)
}
}
It works, which is why it is everywhere. To measure it, I added Log.d("Bind", ...) to onCreateViewHolder and onBindViewHolder in both adapters and counted the lines after one tap on Refresh, which changes one visible price. Three runs each, identical every time:
| Adapter | onBindViewHolder calls |
New view holders | What you see |
|---|---|---|---|
notifyDataSetChanged() |
5 (every card on screen) | 0 | The price jumps; no animation |
ListAdapter |
2 | 2 | Lasalgaon onion fades to ₹2,600 |
ListAdapter, item animator off |
1 (the changed card) | 0 | The price jumps |
notifyDataSetChanged tells the list "anything may have changed", so it rebinds every visible card. ListAdapter diffs the old and new lists on a background thread and rebinds only Lasalgaon onion; the default item animator asks for one extra holder so it can cross-fade the old card into the new one, and in these runs it also bound the card immediately below the bottom edge. Turn the animator off and the cost is exactly one bind. Five binds against one sounds small on a screen that shows five cards. It is not small when a bind loads an image, formats dates for a 30-card tablet screen, or happens on every price tick, and it throws away the change animation that tells a trader which number moved. Lint knows; ./gradlew lintDebug printed:
Warning: It will always be more efficient to use more specific change events if you can. Rely on notifyDataSetChanged as a last resort. [NotifyDataSetChanged]
When you meet notifyDataSetChanged, convert the adapter to ListAdapter and call submitList. ListAdapter costs one thing to remember: it compares the new list with the one it holds, so never change a list after submitting it; give it a new list each time, as a StateFlow of immutable lists does naturally.
Trap 2: a recycled card showing yesterday's label
A ViewHolder is reused. The card that showed Lasalgaon onion scrolls off the top and comes back at the bottom showing potato. Whatever onBindViewHolder does not set, the card keeps from its last price. Delete the else branch from showPrice, the line that hides the label, which looks harmless because the XML already says gone:
else -> trend.visibility = View.GONE // delete this line, and the bug appears
Then scroll. I drove the emulator with twelve swipes and read the screen after each with uiautomator dump. Vashi potato was ₹1,800 yesterday and today, so it must show no label. It showed one five times, a different one each time: "Up ₹150", "Up ₹50", "Down ₹150", "Up ₹150", "Down ₹50". Nashik onion, also unchanged, showed "Down ₹150". With the else back, the same twelve swipes found none. The labels were real labels from other prices; nothing on the screen looked broken.
There is a second lesson in how this was found. The first version of many() repeated the nine samples in order. The list created exactly nine view holders, so each holder always came back for the same commodity at the same market, with the same label, and the bug was invisible. Test data that repeats in step with the recycling hides recycling bugs; real prices do not repeat.
Every onBindViewHolder sets every view it ever changes, in every branch. Visibility, text, colour, click listener, checked state: if any branch sets it, every branch sets it.
Trap 3: the binding that outlives its view
The _binding and binding pair above looks like boilerplate, and the documentation itself says why it is not: fragments outlive their views. Two versions of PricesFragment you will find in real code:
- The binding is never cleared:
private lateinit var binding: FragmentPricesBinding, set inonCreateView, never nulled. - The binding is cleared, but the flow is collected in the fragment's scope:
lifecycleScope.launch { repeatOnLifecycle(...) }instead ofviewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(...) }.
To see what each holds on to, I used a small scratch-only checker. In onDestroyView it keeps a weak reference to the old root view, waits three seconds, forces garbage collection, and logs whether anything still holds the view:
object LeakWatch {
private val handler = Handler(Looper.getMainLooper())
fun watch(label: String, view: View) {
val ref = WeakReference(view)
handler.postDelayed({
repeat(3) {
Runtime.getRuntime().gc()
System.runFinalization()
Thread.sleep(100)
}
Log.d("Leak", "$label: ${if (ref.get() != null) "STILL REACHABLE" else "collected"}")
}, 3_000)
}
}
(Imports: android.os.Handler, android.os.Looper, android.util.Log, android.view.View, java.lang.ref.WeakReference. Forcing garbage collection in an app is for experiments only. My version also counted the views in the tree: 53, the list and its cards.) Each version then opened a price, went back, did that again, and tapped Refresh, with a log line inside the collect:
| Version | Old view after opening a price | Collectors running after two returns |
|---|---|---|
_binding cleared, viewLifecycleOwner |
collected, both times | 1 |
| Binding never cleared | STILL REACHABLE, both times | 1 |
Binding cleared, fragment's lifecycleScope |
STILL REACHABLE, both times | 3 |
The never-cleared binding keeps the whole old screen, 53 views, alive for as long as the fragment sits on the back stack. The wrong scope is worse. Each return to the list started another collector, and the old ones never stopped, because the fragment's own lifecycle never reaches DESTROYED while it is on the back stack. After two trips, one tap on Refresh logged:
D Leak : collector 1 received 500 prices
D Leak : collector 2 received 500 prices
D Leak : collector 3 received 500 prices
Three collectors diffing 500 prices into three adapters, two of them attached to RecyclerViews nobody can see, and every old one holding its old screen in memory through its adapter. On a 3 GB phone, a trader who opens twenty prices in a morning is carrying twenty copies of the list. Neither version crashes in this run; both make the app slower and heavier the longer it is used, which is the hardest kind of bug to get a report about.
In a fragment, clear the binding in onDestroyView, and collect with viewLifecycleOwner. Lint helps with half of this. Written the plain way, lifecycleScope.launch { repeatOnLifecycle(...) } inside a fragment fails ./gradlew lintDebug with:
PricesFragment.kt:38: Error: The repeatOnLifecycle API should be used with viewLifecycleOwner [UnsafeRepeatOnLifecycleDetector from androidx.fragment]
My experiment chose the owner through a variable, and lint said nothing; real code that hides the owner behind a helper function escapes it the same way. Lint has no check at all for a binding that is never cleared. For that one, review is the only defence.
Check your work
What does a ViewHolder save? Inflating a new card for every row: holders are created once for the visible cards and rebound as you scroll.
What does areContentsTheSame decide? Whether a card whose item is still there needs binding again.
How many cards rebound on one price change with notifyDataSetChanged, and with ListAdapter? All five visible; one changed card (plus one holder and one extra bind for the change animation).
Why did the potato card say "Up ₹150"? The holder last showed a price that rose, and the bind code had no branch to hide the label for an unchanged price.
Why did the bug disappear with a repeating sample list? Nine holders and a nine-price cycle meant every holder came back for the same price.
What is the difference between lifecycleScope and viewLifecycleOwner.lifecycleScope in a fragment? The first lives as long as the fragment, including on the back stack; the second ends with the view.
Practice 4. Two collectors after one return, three after two, each logging every refresh; and the old view stays reachable.
Practice
- Build the list, the fragment and the detail screen from this lesson. Open a price and come back, and check that the same cards are on screen.
- Add the
Log.d("Bind", ...)lines and count binds for one Refresh withListAdapter. Then setbinding.priceList.itemAnimator = nulland count again. - Break it on purpose: delete the
elsebranch inshowPriceand scroll until you see a label on Nashik onion or Vashi potato. Then makemany()repeat the nine samples in order and try to find it again. - Break it on purpose: switch the collection in
PricesFragmentto the fragment's ownlifecycleScope, add a counter to the log line, open and close a price twice, and tap Refresh. Then run./gradlew lintDebugand read the error. - Convert
NotifyPriceAdapterto aListAdapter, keeping its call sites working, and run./gradlew lintDebugbefore and after. - Harder: add a "Watching"
CheckBoxtoitem_price.xml, with the watched ids kept in theViewModel. Make it survive scrolling, recycling and rotation, and make sure the checkbox listener of a recycled card cannot change the wrong price. (Hint: set the checked state before you attach the listener, or clear the listener first.)
Official documentation
- Create dynamic lists with RecyclerView — Layout managers, adapters, view holders, and the guide's own sample.
- ListAdapter —
submitList, diffing on a background thread, and why the current list must not be mutated. - Fragment lifecycle — Both lifecycles, and the states a fragment on the back stack passes through.
- View binding: use view binding in fragments — The
_bindingpattern, and the note that fragments outlive their views.
Next: putting Compose into this app, and this app's views into Compose.
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