RizTech Academy logo
RizTech Academy
Meeting XML LayoutsLesson 2 of 335 min

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 in onCreateView, never nulled.
  • The binding is cleared, but the flow is collected in the fragment's scope: lifecycleScope.launch { repeatOnLifecycle(...) } instead of viewLifecycleOwner.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

  1. 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.
  2. Add the Log.d("Bind", ...) lines and count binds for one Refresh with ListAdapter. Then set binding.priceList.itemAnimator = null and count again.
  3. Break it on purpose: delete the else branch in showPrice and scroll until you see a label on Nashik onion or Vashi potato. Then make many() repeat the nine samples in order and try to find it again.
  4. Break it on purpose: switch the collection in PricesFragment to the fragment's own lifecycleScope, add a counter to the log line, open and close a price twice, and tap Refresh. Then run ./gradlew lintDebug and read the error.
  5. Convert NotifyPriceAdapter to a ListAdapter, keeping its call sites working, and run ./gradlew lintDebug before and after.
  6. Harder: add a "Watching" CheckBox to item_price.xml, with the watched ids kept in the ViewModel. 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

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