Lesson 17 of 83 intermediate

XML View System: Layouts, ConstraintLayout & RecyclerView

Master traditional Views — still dominant in 80%+ of production codebases

Open interactive version (quiz + challenge)

Real-world analogy

ConstraintLayout is like a sophisticated IKEA assembly system — every piece (View) needs constraints connecting it to walls (parent) or other furniture (sibling views), otherwise it falls to the top-left corner (0,0). RecyclerView is like a restaurant with a limited number of plates — instead of buying new plates for every dish, the waiter takes plates from finished diners (recycles ViewHolders) and brings them to new diners with fresh food (new data).

What is it?

The XML View system is Android's original UI framework — still the foundation of the vast majority of production apps. Views are inflated from XML, arranged by LayoutManagers, and bound to data via Adapters. ConstraintLayout enables complex flat layouts with chaining and barrier constraints. RecyclerView with ListAdapter + DiffUtil is the standard for displaying dynamic lists efficiently with smooth animations. View Binding provides type-safe, null-safe access to XML-defined Views.

Real-world relevance

In a field operations app, a WorkOrderListFragment uses RecyclerView with ListAdapter to display hundreds of work orders synced from the server. DiffUtil computes the minimal diff when orders are updated (status changes, new assignments) and animates only the changed items. Each item uses a ConstraintLayout with chains for the title/status/assignee layout. View Binding eliminates all findViewByIds. A ConcatAdapter adds a header showing active filter chips and a footer with 'Load more' pagination. The entire list updates without a full redraw when a single order's status changes.

Key points

Code example

// ListAdapter with DiffUtil — production pattern
class WorkOrderAdapter(
    private val onOrderClick: (WorkOrder) -> Unit,
    private val onStatusChange: (WorkOrder, WorkOrderStatus) -> Unit
) : ListAdapter<WorkOrder, WorkOrderAdapter.WorkOrderViewHolder>(WorkOrderDiffCallback()) {

    // DiffUtil — called on background thread
    class WorkOrderDiffCallback : DiffUtil.ItemCallback<WorkOrder>() {
        override fun areItemsTheSame(old: WorkOrder, new: WorkOrder): Boolean =
            old.id == new.id  // Same entity?

        override fun areContentsTheSame(old: WorkOrder, new: WorkOrder): Boolean =
            old == new  // Same display data? (data class equals checks all fields)
    }

    // ViewHolder with View Binding — inflated ONCE, reused
    class WorkOrderViewHolder(
        private val binding: ItemWorkOrderBinding
    ) : RecyclerView.ViewHolder(binding.root) {

        fun bind(order: WorkOrder, onClick: (WorkOrder) -> Unit, onStatus: (WorkOrder, WorkOrderStatus) -> Unit) {
            binding.orderTitle.text = order.title
            binding.assigneeChip.text = order.assignedTo?.name ?: "Unassigned"
            binding.statusBadge.text = order.status.label
            binding.statusBadge.setChipBackgroundColorResource(order.status.colorRes)
            binding.root.setOnClickListener { onClick(order) }
            binding.statusSpinner.setOnItemSelectedListener { _, _, position, _ ->
                onStatus(order, WorkOrderStatus.entries[position])
            }
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WorkOrderViewHolder {
        // Inflate ONCE — this is the expensive operation
        val binding = ItemWorkOrderBinding.inflate(
            LayoutInflater.from(parent.context), parent, false
        )
        return WorkOrderViewHolder(binding)
    }

    override fun onBindViewHolder(holder: WorkOrderViewHolder, position: Int) {
        // Bind OFTEN — this must be cheap
        holder.bind(getItem(position), onOrderClick, onStatusChange)
    }
}

// Fragment setup
class WorkOrderListFragment : Fragment(R.layout.fragment_work_order_list) {

    private var _binding: FragmentWorkOrderListBinding? = null
    private val binding get() = _binding!!
    private val viewModel: WorkOrderViewModel by activityViewModels()

    private val adapter = WorkOrderAdapter(
        onOrderClick = { order -> viewModel.selectOrder(order.id) },
        onStatusChange = { order, status -> viewModel.updateStatus(order, status) }
    )

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        _binding = FragmentWorkOrderListBinding.bind(view)

        binding.recyclerView.apply {
            this.adapter = this@WorkOrderListFragment.adapter
            layoutManager = LinearLayoutManager(requireContext())
            setHasFixedSize(true)
            addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
        }

        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.workOrders.collect { orders ->
                    adapter.submitList(orders)  // DiffUtil computes diff async
                }
            }
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        binding.recyclerView.adapter = null  // Prevent adapter leak
        _binding = null
    }
}

Line-by-line walkthrough

  1. 1. WorkOrderAdapter extends ListAdapter(WorkOrderDiffCallback()) — the DiffCallback is passed to super so ListAdapter handles all diffing internally.
  2. 2. areItemsTheSame compares old.id == new.id — this tells DiffUtil 'is this the same work order?' even if its fields changed. areContentsTheSame uses data class equals which auto-compares all fields — 'has anything displayed changed?'
  3. 3. WorkOrderViewHolder holds ItemWorkOrderBinding — inflated once in onCreateViewHolder. bind() is called repeatedly on the same ViewHolder instance as it is recycled for different orders.
  4. 4. onCreateViewHolder inflates the XML via View Binding — this is the expensive step (XML parsing, view tree construction). RecyclerView calls this only when no cached ViewHolder is available.
  5. 5. onBindViewHolder calls holder.bind(getItem(position), ...) — getItem() is provided by ListAdapter and returns the correct item accounting for DiffUtil's async updates.
  6. 6. adapter.submitList(orders) in the Fragment triggers DiffUtil comparison on a background thread, then posts AnimatedStateListDrawable updates to the RecyclerView on the main thread.
  7. 7. binding.recyclerView.adapter = null in onDestroyView is critical — RecyclerView holds a reference to the Adapter which holds lambdas that may close over Fragment/ViewModel references. Nulling prevents a retention chain.
  8. 8. setHasFixedSize(true) is safe here because the RecyclerView fills the screen — submitting a new list doesn't change its dimensions, so Android skips the expensive parent re-measure cycle.

Spot the bug

class StudentAdapter : RecyclerView.Adapter<StudentAdapter.StudentViewHolder>() {

    private val students = mutableListOf<Student>()

    fun updateStudents(newStudents: List<Student>) {
        students.clear()
        students.addAll(newStudents)
        notifyDataSetChanged()  // Bug 1
    }

    inner class StudentViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {  // Bug 2
        val nameText: TextView = itemView.findViewById(R.id.student_name)
        val gradeText: TextView = itemView.findViewById(R.id.student_grade)

        init {
            // Bug 3
            itemView.setOnClickListener {
                val student = students[adapterPosition]
                onStudentClick(student)
            }
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StudentViewHolder {
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.item_student, parent, false)
        return StudentViewHolder(view)
    }

    override fun onBindViewHolder(holder: StudentViewHolder, position: Int) {
        val student = students[position]
        holder.nameText.text = student.name
        holder.gradeText.text = student.grade
        // Bug 4
        val bitmap = BitmapFactory.decodeResource(
            holder.itemView.resources, student.avatarResId
        )
        holder.avatarImage.setImageBitmap(bitmap)
    }
}
Need a hint?
Think about animation/performance for updates, inner class memory leaks, deprecated adapterPosition, and expensive operations in onBindViewHolder.
Show answer
Bug 1: notifyDataSetChanged() forces a full rebind of all visible ViewHolders with no animations — poor UX and performance. Fix: extend ListAdapter and use submitList() with DiffUtil for animated, minimal updates. Bug 2: inner class StudentViewHolder holds an implicit reference to StudentAdapter (and its students list). This prevents garbage collection in edge cases. Fix: make it a static nested class (remove 'inner' keyword) and pass required references explicitly. Bug 3: adapterPosition is deprecated — it can return RecyclerView.NO_POSITION (-1) during animation. Fix: use bindingAdapterPosition or getBindingAdapterPosition() in click listeners, with a null/NO_POSITION check. Bug 4: BitmapFactory.decodeResource() in onBindViewHolder — called EVERY TIME a ViewHolder is bound (fast scrolling = dozens per second). Bitmap decoding is heavy and blocks the main thread causing dropped frames. Fix: use Glide or Coil for async image loading with caching: Glide.with(holder.itemView).load(student.avatarUrl).into(holder.avatarImage).

Explain like I'm 5

RecyclerView is like a magic whiteboard eraser. Instead of getting a new whiteboard for each lesson (new view for each item), you erase the old lesson when it scrolls off screen and write new content on the same board. DiffUtil is the teacher's assistant who only erases and rewrites the parts that actually changed — not the whole board. ConstraintLayout is like a web of rubber bands connecting everything to walls and each other so nothing falls out of place when you resize the classroom.

Fun fact

RecyclerView's recycling pool is shared between multiple RecyclerViews by default if they use the same view types. In a complex screen with nested RecyclerViews (like Instagram's story + feed layout), you can set a shared RecycledViewPool so ViewHolders from one list can be reused by another, significantly reducing inflation cost during fast scrolling.

Hands-on challenge

Build a StudentGradeListFragment with RecyclerView: (1) Create StudentGradeAdapter using ListAdapter with DiffUtil (compare by student ID, full equality for contents). (2) Use View Binding in the ViewHolder. (3) Support two view types: regular grade items and a 'section header' for each grade level. (4) Implement click listener via lambda in the Adapter constructor. (5) In the Fragment, null the adapter in onDestroyView and set hasFixedSize(true). (6) The layout XML for each item uses ConstraintLayout with a chain for name/grade/score.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery