Free tutorials & notes in Hindi & English · Clean code examples 🧰 Free PDF & Image Tools →
📱 Android App (Kotlin + XML)  ·  Lesson 19

Product List, Search & Filter

The screen

9:41▮▮ 85%←Electronics⋮🔍 Search for products ✕Price: low to highNewest⚙ Filter (on)3 results🎧Earbuds X2₹1,099🔋Power Bank₹1,499⌚Smart Watch Pro₹1,8999:41▮▮ 85%←Electronics⋮🎧Earbuds X2₹1,099🔋Power Bank₹1,499Filter by price₹0 – ₹2,000●━━━━━━━━━━━━●─────────────ApplyReset9:41▮▮ 85%☰Categories⋮📱Mobiles3 products👗Fashion3 products⌚Electronics4 products🍳Home & Kitchen2 products📘Books2 products🏏Sports2 products🏠Home🗂️Categories♡Wishlist🛒Cart👤Account
Product list with sort chips, the price filter bottom sheet, and the Categories tab

The same screen is used for search, every category, and every "See all" link — only the arguments change (categoryId, title, sort, focusSearch).

Step 1: fragment_product_list.xml

At the top: a dense search field with a clear button, a horizontally scrolling ChipGroup (single selection, one always required) and the Filter button. Below: the result count and the grid with a small "loading more" spinner at the bottom.

XML – layout/fragment_product_list.xml
<?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">

    <!-- Search + sort + filter -->
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@color/surface"
        android:elevation="2dp"
        android:orientation="vertical"
        android:paddingTop="8dp">

        <com.google.android.material.textfield.TextInputLayout
            android:id="@+id/search_layout"
            style="@style/Widget.Material3.TextInputLayout.OutlinedBox.Dense"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginStart="16dp"
            android:layout_marginEnd="16dp"
            android:hint="@string/search_hint"
            app:boxCornerRadiusBottomEnd="12dp"
            app:boxCornerRadiusBottomStart="12dp"
            app:boxCornerRadiusTopEnd="12dp"
            app:boxCornerRadiusTopStart="12dp"
            app:endIconMode="clear_text"
            app:startIconDrawable="@drawable/ic_search">

            <com.google.android.material.textfield.TextInputEditText
                android:id="@+id/search_input"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:imeOptions="actionSearch"
                android:inputType="text"
                android:maxLines="1" />
        </com.google.android.material.textfield.TextInputLayout>

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center_vertical"
            android:orientation="horizontal">

            <HorizontalScrollView
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:scrollbars="none">

                <com.google.android.material.chip.ChipGroup
                    android:id="@+id/sort_chips"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:paddingStart="16dp"
                    android:paddingEnd="8dp"
                    app:selectionRequired="true"
                    app:singleLine="true"
                    app:singleSelection="true">

                    <com.google.android.material.chip.Chip
                        android:id="@+id/sort_newest"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:text="@string/sort_newest" />

                    <com.google.android.material.chip.Chip
                        android:id="@+id/sort_popular"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:text="@string/sort_popular" />

                    <com.google.android.material.chip.Chip
                        android:id="@+id/sort_price_low"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:text="@string/sort_price_low" />

                    <com.google.android.material.chip.Chip
                        android:id="@+id/sort_price_high"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:text="@string/sort_price_high" />

                    <com.google.android.material.chip.Chip
                        android:id="@+id/sort_rating"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:text="@string/sort_rating" />

                    <com.google.android.material.chip.Chip
                        android:id="@+id/sort_discount"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:text="@string/sort_discount" />
                </com.google.android.material.chip.ChipGroup>
            </HorizontalScrollView>

            <com.google.android.material.button.MaterialButton
                android:id="@+id/filter_button"
                style="@style/Widget.Material3.Button.TextButton.Icon"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginEnd="8dp"
                android:text="@string/filter"
                app:icon="@drawable/ic_filter" />
        </LinearLayout>
    </LinearLayout>

    <TextView
        android:id="@+id/result_count"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:paddingStart="16dp"
        android:paddingTop="8dp"
        android:paddingEnd="16dp"
        android:textColor="@color/text_secondary"
        android:textSize="13sp"
        tools:text="16 results" />

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1">

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/list"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:clipToPadding="false"
            android:paddingStart="8dp"
            android:paddingEnd="8dp"
            android:paddingBottom="56dp"
            tools:listitem="@layout/item_product" />

        <com.google.android.material.progressindicator.CircularProgressIndicator
            android:id="@+id/load_more"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="bottom|center_horizontal"
            android:layout_marginBottom="12dp"
            android:indeterminate="true"
            android:visibility="gone" />

        <include
            android:id="@+id/state"
            layout="@layout/layout_state" />
    </FrameLayout>
</LinearLayout>

Step 2: ProductListViewModel

Two StateFlows: filters (what the user asked for) and state (what is on screen: items, total, loading, loadingMore, error, hasMore). Any filter change calls reload(), which cancels a running request first, so results of an old search can never appear after a new one.

Kotlin – ui/product/ProductListViewModel.kt
package com.codingeasily.karteasy.ui.product

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.codingeasily.karteasy.data.api.Result
import com.codingeasily.karteasy.data.model.Product
import com.codingeasily.karteasy.util.kartApp
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch

/**
 * Search + filter + sort + "load more" paging.
 * Fragment arguments arrive automatically in SavedStateHandle.
 */
class ProductListViewModel(app: Application, args: SavedStateHandle) : AndroidViewModel(app) {

    data class Filters(
        val query: String = "",
        val categoryId: Int = 0,
        val minPrice: Int = 0,
        val maxPrice: Int = 0,
        val sort: String = "newest"
    )

    data class ListState(
        val items: List<Product> = emptyList(),
        val total: Int = 0,
        val loading: Boolean = false,
        val loadingMore: Boolean = false,
        val error: String? = null,
        val hasMore: Boolean = true
    )

    private val repo = app.kartApp.repository

    private val _filters = MutableStateFlow(
        Filters(
            categoryId = args.get<Int>(ProductListFragment.ARG_CATEGORY_ID) ?: 0,
            sort = args.get<String>(ProductListFragment.ARG_SORT) ?: "newest"
        )
    )
    val filters: StateFlow<Filters> = _filters

    private val _state = MutableStateFlow(ListState())
    val state: StateFlow<ListState> = _state

    private val _messages = MutableSharedFlow<String>()
    val messages: SharedFlow<String> = _messages

    private var page = 1
    private var job: Job? = null
    private var searchJob: Job? = null

    init {
        reload()
    }

    /** Called on every key press: waits 400 ms so we don't call the API for each letter. */
    fun search(text: String) {
        if (text.trim() == _filters.value.query) return
        searchJob?.cancel()
        searchJob = viewModelScope.launch {
            delay(400)
            _filters.value = _filters.value.copy(query = text.trim())
            reload()
        }
    }

    fun setSort(sort: String) {
        if (sort == _filters.value.sort) return
        _filters.value = _filters.value.copy(sort = sort)
        reload()
    }

    fun setPrice(min: Int, max: Int) {
        _filters.value = _filters.value.copy(minPrice = min, maxPrice = max)
        reload()
    }

    fun reload() {
        job?.cancel()
        page = 1
        _state.value = ListState(loading = true)
        job = viewModelScope.launch { fetch(reset = true) }
    }

    /** Called by the RecyclerView scroll listener near the end of the list. */
    fun loadMore() {
        val s = _state.value
        if (s.loading || s.loadingMore || !s.hasMore || s.error != null) return
        _state.value = s.copy(loadingMore = true)
        page++
        job = viewModelScope.launch { fetch(reset = false) }
    }

    private suspend fun fetch(reset: Boolean) {
        val f = _filters.value
        val r = repo.products(
            query = f.query.ifEmpty { null },
            categoryId = f.categoryId.takeIf { it > 0 },
            minPrice = f.minPrice.takeIf { it > 0 },
            maxPrice = f.maxPrice.takeIf { it > 0 },
            sort = f.sort,
            page = page
        )
        val old = _state.value
        _state.value = when (r) {
            is Result.Success -> ListState(
                items = if (reset) r.data.items else old.items + r.data.items,
                total = r.data.total,
                hasMore = r.data.hasMore
            )
            is Result.Error -> {
                if (reset) ListState(error = r.message)
                else {
                    page--
                    _messages.emit(r.message)
                    old.copy(loadingMore = false)
                }
            }
        }
    }

    fun toggleWishlist(productId: Int) {
        viewModelScope.launch {
            when (val r = repo.toggleWishlist(productId)) {
                is Result.Success -> {
                    _state.value = _state.value.copy(items = _state.value.items.withWishlist(productId, r.data.inWishlist))
                    _messages.emit(r.message)
                }
                is Result.Error -> _messages.emit(r.message)
            }
        }
    }

    fun addToCart(productId: Int) {
        viewModelScope.launch {
            when (val r = repo.addToCart(productId)) {
                is Result.Success -> _messages.emit(r.message)
                is Result.Error -> _messages.emit(r.message)
            }
        }
    }
}

Live search with debounce

Typing "watch" fires five text changes. Calling the API five times is wasteful, so search() waits 400 ms: each new letter cancels the previous wait.

KeyboardViewModelAPI"w" → start 400 ms timer"wa" → cancel, restart timer"watch" → cancel, restart timer400 ms of silence…GET products.php?q=watch (only one call)
Debounce: one request after the user stops typing

Endless scrolling

onScrolledlast visible ≥ count − 4loadMore()skip if loading / no more / errorpage++GET …&page=2items = old + newhasMore from the API
Load the next 20 before the user reaches the end

If loading the next page fails, we go back one page and show a Snackbar; the items already loaded stay on screen.

Step 3: Price filter bottom sheet

A BottomSheetDialogFragment with a two-thumb RangeSlider (₹0 – ₹20,000, steps of ₹500). The result is sent back with the Fragment Result API — no interfaces or shared ViewModel needed. A maximum of ₹20,000 means "any price" and is sent as 0.

XML – layout/sheet_filter.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:padding="24dp">

    <com.google.android.material.bottomsheet.BottomSheetDragHandleView
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/filter_title"
        android:textAppearance="@style/TextAppearance.KartEasy.Section" />

    <TextView
        android:id="@+id/range_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="8dp"
        android:textColor="@color/brand"
        android:textStyle="bold"
        tools:text="₹0 – ₹5,000" />

    <!-- Two thumbs: minimum and maximum price -->
    <com.google.android.material.slider.RangeSlider
        android:id="@+id/price_slider"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="8dp" />

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:orientation="horizontal">

        <com.google.android.material.button.MaterialButton
            android:id="@+id/reset"
            style="@style/Widget.KartEasy.Button.Outlined"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginEnd="8dp"
            android:layout_weight="1"
            android:text="@string/reset" />

        <com.google.android.material.button.MaterialButton
            android:id="@+id/apply"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="8dp"
            android:layout_weight="1"
            android:text="@string/apply" />
    </LinearLayout>
</LinearLayout>
Kotlin – ui/product/FilterSheet.kt
package com.codingeasily.karteasy.ui.product

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.setFragmentResult
import com.codingeasily.karteasy.R
import com.codingeasily.karteasy.databinding.SheetFilterBinding
import com.codingeasily.karteasy.util.Format
import com.google.android.material.bottomsheet.BottomSheetDialogFragment

/** Bottom sheet with a price RangeSlider. Sends the result back with setFragmentResult. */
class FilterSheet : BottomSheetDialogFragment() {

    companion object {
        const val RESULT = "filter_result"
        const val KEY_MIN = "min"
        const val KEY_MAX = "max"
        private const val MAX_PRICE = 20000f

        fun newInstance(min: Int, max: Int) = FilterSheet().apply {
            arguments = bundleOf(KEY_MIN to min, KEY_MAX to max)
        }
    }

    private var _binding: SheetFilterBinding? = null
    private val binding get() = _binding!!

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
        _binding = SheetFilterBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        val min = requireArguments().getInt(KEY_MIN).toFloat()
        val max = requireArguments().getInt(KEY_MAX).toFloat().takeIf { it > 0 } ?: MAX_PRICE
        binding.priceSlider.valueFrom = 0f
        binding.priceSlider.valueTo = MAX_PRICE
        binding.priceSlider.stepSize = 500f
        binding.priceSlider.values = listOf(min.coerceIn(0f, MAX_PRICE), max.coerceIn(0f, MAX_PRICE))
        binding.priceSlider.setLabelFormatter { Format.price(it.toDouble()) }
        showRange()
        binding.priceSlider.addOnChangeListener { _, _, _ -> showRange() }

        binding.reset.setOnClickListener { send(0, 0) }
        binding.apply.setOnClickListener {
            val v = binding.priceSlider.values
            send(v[0].toInt(), if (v[1] >= MAX_PRICE) 0 else v[1].toInt())
        }
    }

    private fun showRange() {
        val v = binding.priceSlider.values
        val upper = if (v[1] >= MAX_PRICE) getString(R.string.price_any) else Format.price(v[1].toDouble())
        binding.rangeText.text = getString(R.string.price_range, Format.price(v[0].toDouble()), upper)
    }

    private fun send(min: Int, max: Int) {
        setFragmentResult(RESULT, bundleOf(KEY_MIN to min, KEY_MAX to max))
        dismiss()
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

Step 4: ProductListFragment

The sort chips are mapped to API values in one SORTS map. The title in the toolbar comes from the title argument (for example the category name). When opened from the Home search bar, the field gets focus and the keyboard opens.

Kotlin – ui/product/ProductListFragment.kt
package com.codingeasily.karteasy.ui.product

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.getSystemService
import androidx.core.os.bundleOf
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.codingeasily.karteasy.R
import com.codingeasily.karteasy.databinding.FragmentProductListBinding
import com.codingeasily.karteasy.util.collectWhenStarted
import com.codingeasily.karteasy.util.done
import com.codingeasily.karteasy.util.empty
import com.codingeasily.karteasy.util.error
import com.codingeasily.karteasy.util.loading
import com.codingeasily.karteasy.util.snack
import com.codingeasily.karteasy.util.visibleIf

class ProductListFragment : Fragment() {

    companion object {
        const val ARG_CATEGORY_ID = "categoryId"
        const val ARG_TITLE = "title"
        const val ARG_SORT = "sort"
        const val ARG_FOCUS_SEARCH = "focusSearch"

        /** Chip id -> API sort value */
        val SORTS = mapOf(
            R.id.sort_newest to "newest",
            R.id.sort_popular to "popular",
            R.id.sort_price_low to "price_asc",
            R.id.sort_price_high to "price_desc",
            R.id.sort_rating to "rating",
            R.id.sort_discount to "discount"
        )
    }

    private var _binding: FragmentProductListBinding? = null
    private val binding get() = _binding!!
    private val vm: ProductListViewModel by viewModels()

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
        _binding = FragmentProductListBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        arguments?.getString(ARG_TITLE)?.let { (requireActivity() as AppCompatActivity).supportActionBar?.title = it }

        val adapter = ProductAdapter(
            onClick = { findNavController().navigate(R.id.productDetailFragment, bundleOf(ProductDetailFragment.ARG_PRODUCT_ID to it.id)) },
            onWishlist = { vm.toggleWishlist(it.id) },
            onAddToCart = { vm.addToCart(it.id) }
        )
        val grid = GridLayoutManager(requireContext(), resources.getInteger(R.integer.product_columns))
        binding.list.layoutManager = grid
        binding.list.adapter = adapter

        // Endless scrolling: when 4 items are left, ask for the next page.
        binding.list.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
                if (dy > 0 && grid.findLastVisibleItemPosition() >= adapter.itemCount - 4) vm.loadMore()
            }
        })

        // Search box
        binding.searchInput.doAfterTextChanged { vm.search(it?.toString().orEmpty()) }
        binding.searchInput.setOnEditorActionListener { v, actionId, _ ->
            if (actionId == EditorInfo.IME_ACTION_SEARCH) {
                requireContext().getSystemService<InputMethodManager>()?.hideSoftInputFromWindow(v.windowToken, 0)
                true
            } else false
        }
        if (arguments?.getBoolean(ARG_FOCUS_SEARCH) == true && savedInstanceState == null) {
            binding.searchInput.requestFocus()
            binding.searchInput.post {
                requireContext().getSystemService<InputMethodManager>()?.showSoftInput(binding.searchInput, InputMethodManager.SHOW_IMPLICIT)
            }
        }

        // Sort chips: select the current one, then listen for changes.
        val current = SORTS.entries.firstOrNull { it.value == vm.filters.value.sort }?.key ?: R.id.sort_newest
        binding.sortChips.check(current)
        binding.sortChips.setOnCheckedStateChangeListener { _, ids ->
            ids.firstOrNull()?.let { id -> SORTS[id]?.let { vm.setSort(it) } }
        }

        binding.filterButton.setOnClickListener {
            val f = vm.filters.value
            FilterSheet.newInstance(f.minPrice, f.maxPrice).show(childFragmentManager, "filter")
        }
        childFragmentManager.setFragmentResultListener(FilterSheet.RESULT, viewLifecycleOwner) { _, b ->
            vm.setPrice(b.getInt(FilterSheet.KEY_MIN), b.getInt(FilterSheet.KEY_MAX))
        }

        collectWhenStarted(vm.filters) { f ->
            val active = f.minPrice > 0 || f.maxPrice > 0
            binding.filterButton.text = getString(if (active) R.string.filter_on else R.string.filter)
        }

        collectWhenStarted(vm.state) { s ->
            adapter.submitList(s.items)
            binding.loadMore.visibleIf(s.loadingMore)
            binding.resultCount.visibleIf(!s.loading && s.error == null)
            binding.resultCount.text = resources.getQuantityString(R.plurals.results_count, s.total, s.total)
            when {
                s.loading -> binding.state.loading()
                s.error != null -> binding.state.error(s.error) { vm.reload() }
                s.items.isEmpty() -> binding.state.empty(R.drawable.ic_search, R.string.no_products)
                else -> binding.state.done()
            }
        }
        collectWhenStarted(vm.messages) { snack(it) }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

Step 5: ProductAdapter

One adapter for every product list. fixedWidthDp gives horizontal rows equal card widths; in grids the card fills its column. The "+" button is hidden for out-of-stock items, and the heart shows the filled or outline icon with a matching content description for TalkBack.

Kotlin – ui/product/ProductAdapter.kt
package com.codingeasily.karteasy.ui.product

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.codingeasily.karteasy.R
import com.codingeasily.karteasy.data.model.Product
import com.codingeasily.karteasy.databinding.ItemProductBinding
import com.codingeasily.karteasy.util.Format
import com.codingeasily.karteasy.util.load
import com.codingeasily.karteasy.util.strike
import com.codingeasily.karteasy.util.visibleIf

/**
 * One adapter for every product list: home rows, category grid, search results, wishlist.
 * fixedWidthDp > 0 is used for horizontal rows so cards keep the same size.
 */
class ProductAdapter(
    private val fixedWidthDp: Int = 0,
    private val onClick: (Product) -> Unit,
    private val onWishlist: (Product) -> Unit,
    private val onAddToCart: ((Product) -> Unit)? = null
) : ListAdapter<Product, ProductAdapter.Holder>(DIFF) {

    inner class Holder(val b: ItemProductBinding) : RecyclerView.ViewHolder(b.root)

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
        val b = ItemProductBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        if (fixedWidthDp > 0) {
            b.root.layoutParams = b.root.layoutParams.apply {
                width = (fixedWidthDp * parent.resources.displayMetrics.density).toInt()
            }
        }
        return Holder(b)
    }

    override fun onBindViewHolder(holder: Holder, position: Int) {
        val p = getItem(position)
        val b = holder.b
        b.image.load(p.image)
        b.name.text = p.name
        b.price.text = Format.price(p.price)
        b.mrp.strike(Format.price(p.mrp))
        b.mrp.visibleIf(p.mrp > p.price)
        b.discount.text = b.root.context.getString(R.string.percent_off, p.discountPct)
        b.discount.visibleIf(p.discountPct > 0)
        b.rating.text = String.format("%.1f", p.rating)
        b.ratingCount.text = b.root.context.getString(R.string.rating_count, p.ratingCount)
        b.outOfStock.visibleIf(p.stock <= 0)
        b.wishlist.setImageResource(if (p.inWishlist) R.drawable.ic_heart else R.drawable.ic_heart_outline)
        b.wishlist.contentDescription = b.root.context.getString(
            if (p.inWishlist) R.string.remove_from_wishlist else R.string.add_to_wishlist
        )
        b.wishlist.setOnClickListener { onWishlist(p) }
        b.root.setOnClickListener { onClick(p) }
        b.addButton.visibleIf(onAddToCart != null && p.stock > 0)
        b.addButton.setOnClickListener { onAddToCart?.invoke(p) }
    }

    companion object {
        val DIFF = object : DiffUtil.ItemCallback<Product>() {
            override fun areItemsTheSame(a: Product, b: Product) = a.id == b.id
            override fun areContentsTheSame(a: Product, b: Product) = a == b
        }
    }
}

Step 6: Categories tab

A grid of category tiles (3 columns on phones, 5 on tablets) from categories.php. A tap opens the product list for that category. The small ViewModel lives in the same file because it is only used here.

XML – layout/fragment_categories.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/list"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:clipToPadding="false"
        android:padding="8dp"
        tools:listitem="@layout/item_category" />

    <include
        android:id="@+id/state"
        layout="@layout/layout_state" />
</FrameLayout>
Kotlin – ui/category/CategoriesFragment.kt
package com.codingeasily.karteasy.ui.category

import android.app.Application
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.viewModels
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.GridLayoutManager
import com.codingeasily.karteasy.R
import com.codingeasily.karteasy.data.api.Result
import com.codingeasily.karteasy.data.api.UiState
import com.codingeasily.karteasy.data.model.Category
import com.codingeasily.karteasy.databinding.FragmentCategoriesBinding
import com.codingeasily.karteasy.ui.product.ProductListFragment
import com.codingeasily.karteasy.util.collectWhenStarted
import com.codingeasily.karteasy.util.done
import com.codingeasily.karteasy.util.error
import com.codingeasily.karteasy.util.kartApp
import com.codingeasily.karteasy.util.loading
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch

class CategoriesViewModel(app: Application) : AndroidViewModel(app) {
    private val repo = app.kartApp.repository
    private val _state = MutableStateFlow<UiState<List<Category>>>(UiState.Loading)
    val state: StateFlow<UiState<List<Category>>> = _state

    init { load() }

    fun load() {
        _state.value = UiState.Loading
        viewModelScope.launch {
            _state.value = when (val r = repo.categories()) {
                is Result.Success -> UiState.Success(r.data)
                is Result.Error -> UiState.Error(r.message)
            }
        }
    }
}

class CategoriesFragment : Fragment() {

    private var _binding: FragmentCategoriesBinding? = null
    private val binding get() = _binding!!
    private val vm: CategoriesViewModel by viewModels()

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
        _binding = FragmentCategoriesBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        val adapter = CategoryAdapter(showCount = true) { c ->
            findNavController().navigate(
                R.id.productListFragment,
                bundleOf(ProductListFragment.ARG_CATEGORY_ID to c.id, ProductListFragment.ARG_TITLE to c.name)
            )
        }
        binding.list.layoutManager = GridLayoutManager(requireContext(), resources.getInteger(R.integer.category_columns))
        binding.list.adapter = adapter

        collectWhenStarted(vm.state) { state ->
            when (state) {
                is UiState.Loading -> binding.state.loading()
                is UiState.Error -> binding.state.error(state.message) { vm.load() }
                is UiState.Success -> {
                    binding.state.done()
                    adapter.submitList(state.data)
                }
            }
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

Frequently Asked Questions

Why not use the Paging 3 library?
Paging 3 is great for huge lists, but it adds many new classes. The manual approach here (page number + has_more + scroll listener) is about 30 lines, easy to debug, and works perfectly with our PHP API. You can move to Paging 3 later without changing the API.
How do arguments reach the ViewModel?
Navigation puts the fragment arguments into the ViewModel's SavedStateHandle automatically. ProductListViewModel(app, args: SavedStateHandle) reads categoryId and sort from it, and they survive process death too.
← E-commerce App Tutorial
🔗

Share this topic with a friend

यह topic किसी दोस्त को भेजें

Found it useful? Send it to a classmate learning the same thing.

अच्छा लगा? जो दोस्त यही सीख रहा है, उसे भेज दीजिए।

Next topicProduct Detail Screen Your progress: 19 / 28 (68%) · Lesson 20 / 28 Continue →

How was this lesson?

Your feedback helps us make the content better.

Write a review or suggestion (optional)

After approval, your name and city may appear publicly with your review. Please do not write your email or phone number.

▶ CodingEasily on YouTube

Learn with video lessons in easy Hindi

Featured video: RDBMS Part 3 – Class XI Commerce IT

▶ Subscribe – it’s free