school-it.pl // android + kotlin // rozdział 7 // lekcja 29
Własny adapter: obrazek i przycisk w wierszu
Wiersz z lekcji 28 tylko pokazywał dane. Teraz dostanie ikonę i przyciski − / +, które zmieniają dane bez wychodzenia z listy. Pojawią się trzy nowe rzeczy: klasa ViewHolder (wiersz pamięta swoje kontrolki), obsługa kliknięć wewnątrz wiersza i przekazanie do adaptera funkcji, którą ma wywołać po zmianie. Zbudujesz koszyk szkolnego sklepiku z sumą do zapłaty.
Czego się dziś nauczysz
- Pokażesz w każdym wierszu inny obrazek z
res/drawable. - Napiszesz klasę
ViewHolderi zapiszesz ją wtagwiersza. - Obsłużysz przyciski umieszczone w wierszu listy.
- Przekażesz do adaptera funkcję, która odświeży resztę ekranu.
- Sprawisz, że kliknięcie wiersza i przyciski w nim działają jednocześnie.
Obrazek w wierszu
Obrazek z res/drawable ma w kodzie numer: R.drawable.ic_kanapka to zwykła liczba Int. Taki numer można więc zapisać w klasie danych — każdy produkt zna swoją ikonę:
data class Produkt(
val nazwa: String,
val cena: Double,
val ikona: Int, // np. R.drawable.ic_kanapka
var ilosc: Int = 0 // var — zmienia się po kliknięciu + / −
)
// w adapterze:
ivIkona.setImageResource(produkt.ikona) // lekcja 07
Zwróć uwagę na var ilosc: pozostałe pola są stałe, a ilość w koszyku się zmienia. Wartość = 0 jest domyślna — przy tworzeniu produktu nie trzeba jej podawać.
ViewHolder — wiersz pamięta swoje kontrolki
W lekcji 28 getView za każdym razem szukał kontrolek przez findViewById. Przy sześciu kontrolkach w wierszu i szybkim przewijaniu to dużo niepotrzebnego szukania — przecież odzyskany wiersz ma te same kontrolki co poprzednio. ViewHolder to mała klasa, która je zapamiętuje:
// „pudełko” na kontrolki jednego wiersza
private class ViewHolder(wiersz: View) {
val ivIkona: ImageView = wiersz.findViewById(R.id.ivIkona)
val tvNazwa: TextView = wiersz.findViewById(R.id.tvNazwa)
}
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val wiersz: View
val holder: ViewHolder
if (convertView == null) {
// NOWY wiersz: nadmuchaj XML, znajdź kontrolki RAZ, schowaj pudełko w wierszu
wiersz = LayoutInflater.from(context).inflate(R.layout.wiersz_produkt, parent, false)
holder = ViewHolder(wiersz)
wiersz.tag = holder
} else {
// ODZYSKANY wiersz: wyjmij gotowe pudełko
wiersz = convertView
holder = convertView.tag as ViewHolder
}
holder.tvNazwa.text = produkty[position].nazwa // bez findViewById
return wiersz
}
→ wiersz.tag = holder
as ViewHolder
tag to „kieszeń” każdej kontrolki: można w niej schować dowolny obiekt i wyjąć go później. as ViewHolder mówi Kotlinowi, co w tej kieszeni leży.
Skąd ta nazwa
View holder = „przechowywacz widoków”. To wzorzec tak popularny, że w nowszej liście RecyclerView jest obowiązkowy. Jeśli kiedyś przejdziesz na RecyclerView, ta lekcja będzie dobrym wstępem.
Przycisk w wierszu
Przycisk w wierszu obsługujesz w getView, tak jak każdy przycisk — setOnClickListener. Ponieważ wiersze są odzyskiwane, obsługę ustawiasz za każdym razem od nowa: nowa obsługa zastępuje starą i działa na obecnym produkcie.
val produkt = produkty[position]
holder.btnPlus.setOnClickListener {
produkt.ilosc++ // zmieniamy DANE…
notifyDataSetChanged() // …i odświeżamy listę (adapter woła to na sobie)
}
Nie zmieniaj samego napisu w wierszu (holder.tvIlosc.text = …) bez zmiany danych. Po przewinięciu wiersz zostałby narysowany od nowa z danych — i zmiana by zniknęła. Zasada z lekcji 26 obowiązuje dalej: zmieniasz dane, potem odświeżasz.
Adapter powiadamia aktywność
Kliknięcie + obsługuje adapter. Ale suma do zapłaty jest w MainActivity — adapter nie ma do niej dostępu. Rozwiązanie: aktywność przekazuje adapterowi funkcję „wywołaj mnie po każdej zmianie”.
// adapter: trzeci parametr to funkcja bez argumentów, która nic nie zwraca
class ProduktAdapter(
context: Context,
private val produkty: List<Produkt>,
private val poZmianie: () -> Unit
) : ArrayAdapter<Produkt>(context, 0, produkty) { … }
// w adapterze, po kliknięciu:
poZmianie() // „aktywności, coś się zmieniło”
// w MainActivity — ostatni parametr-funkcję piszemy w klamrach za nawiasem:
adapter = ProduktAdapter(this, produkty) { pokazSume() }
() -> Unit czytaj: „funkcja, która nie przyjmuje nic i nic nie zwraca”. Znasz to już z setOnClickListener { … } — tam też przekazujesz kawałek kodu do wykonania później.
Przyciski a kliknięcie całego wiersza
Gdy w wierszu jest przycisk, ListView często przestaje reagować na kliknięcie samego wiersza: przycisk „przejmuje fokus” całego wiersza. Naprawia to jeden atrybut na głównym elemencie wiersz_produkt.xml:
<LinearLayout …
android:descendantFocusability="blocksDescendants"> <!-- dzieci nie zabierają fokusu wierszowi -->
Przyciski nadal reagują na dotyk, a kliknięcie w resztę wiersza trafia do setOnItemClickListener.
Sklepik szkolny
Lista sześciu produktów z ikoną, nazwą i ceną. Przyciski − / + zmieniają ilość; wiersz z produktem w koszyku ma jasnofioletowe tło, a − przy zerze jest przygaszony. Kliknięcie wiersza pokazuje Toast z wartością tej pozycji. Pod listą — liczba sztuk i suma. Zamów pokazuje okno z podsumowaniem; po potwierdzeniu koszyk się zeruje.
1 × Ciastko
Razem: 10,50 zł
Przygotowanie projektu
- Nowy projekt Empty Views Activity, nazwa
Sklepik, Kotlin, API 24. - Dodaj ikony: prawy klik na
res→ New → Vector Asset → Clip art. Nazwy plików i obrazki:ic_kanapka(lunch dining),ic_woda(water drop),ic_drozdzowka(bakery dining),ic_ciastko(cookie),ic_kakao(local cafe),ic_lody(icecream),ic_plus(add),ic_minus(remove). - Dodaj
Produkt.kt,wiersz_produkt.xmliProduktAdapter.kt. - Podmień
activity_main.xmliMainActivity.kt.
├── java/…/
│ ├── Produkt.ktklasa danych z var ilosc
│ ├── ProduktAdapter.ktadapter z ViewHolder i przyciskami
│ └── MainActivity.kt
└── res/
├── drawable/ic_kanapka.xml … ic_plus.xml, ic_minus.xml8 ikon
└── layout/
├── activity_main.xml
└── wiersz_produkt.xml
package pl.schoolit.sklepik
data class Produkt(
val nazwa: String,
val cena: Double,
val ikona: Int, // R.drawable.…
var ilosc: Int = 0 // ile sztuk w koszyku
)
<?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="wrap_content"
android:descendantFocusability="blocksDescendants"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="8dp">
<ImageView
android:id="@+id/ivIkona"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="Ikona produktu" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvNazwa"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvCena"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="13sp" />
</LinearLayout>
<ImageButton
android:id="@+id/btnMinus"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Odejmij"
android:src="@drawable/ic_minus" />
<TextView
android:id="@+id/tvIlosc"
android:layout_width="32dp"
android:layout_height="wrap_content"
android:gravity="center"
android:textSize="16sp"
android:textStyle="bold" />
<ImageButton
android:id="@+id/btnPlus"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Dodaj"
android:src="@drawable/ic_plus" />
</LinearLayout>
package pl.schoolit.sklepik
import android.content.Context
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
class ProduktAdapter(
context: Context,
private val produkty: List<Produkt>,
private val poZmianie: () -> Unit // wywołamy po każdym + / −
) : ArrayAdapter<Produkt>(context, 0, produkty) {
// ---------- pudełko na kontrolki jednego wiersza ----------
private class ViewHolder(wiersz: View) {
val ivIkona: ImageView = wiersz.findViewById(R.id.ivIkona)
val tvNazwa: TextView = wiersz.findViewById(R.id.tvNazwa)
val tvCena: TextView = wiersz.findViewById(R.id.tvCena)
val tvIlosc: TextView = wiersz.findViewById(R.id.tvIlosc)
val btnMinus: ImageButton = wiersz.findViewById(R.id.btnMinus)
val btnPlus: ImageButton = wiersz.findViewById(R.id.btnPlus)
}
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
// 1. wiersz i jego pudełko
val wiersz: View
val holder: ViewHolder
if (convertView == null) {
wiersz = LayoutInflater.from(context).inflate(R.layout.wiersz_produkt, parent, false)
holder = ViewHolder(wiersz)
wiersz.tag = holder
} else {
wiersz = convertView
holder = convertView.tag as ViewHolder
}
// 2. dane → kontrolki (każda rzecz za każdym razem)
val produkt = produkty[position]
val wKoszyku = produkt.ilosc > 0
holder.ivIkona.setImageResource(produkt.ikona)
holder.tvNazwa.text = produkt.nazwa
holder.tvCena.text = "%.2f zł".format(produkt.cena)
holder.tvIlosc.text = produkt.ilosc.toString()
holder.btnMinus.isEnabled = wKoszyku
holder.btnMinus.alpha = if (wKoszyku) 1f else 0.3f
wiersz.setBackgroundColor(if (wKoszyku) Color.parseColor("#EADDFF") else Color.TRANSPARENT)
// 3. przyciski w wierszu — zmieniają DANE tego produktu
holder.btnPlus.setOnClickListener {
produkt.ilosc++
notifyDataSetChanged()
poZmianie()
}
holder.btnMinus.setOnClickListener {
if (produkt.ilosc > 0) produkt.ilosc--
notifyDataSetChanged()
poZmianie()
}
return wiersz
}
}
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:layout_margin="16dp"
android:fitsSystemWindows="true">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Sklepik szkolny"
android:textSize="24sp"
android:textStyle="bold" />
<ListView
android:id="@+id/lista"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<TextView
android:id="@+id/tvSuma"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textSize="16sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<Button
android:id="@+id/btnWyczysc"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="8dp"
android:text="Wyczyść" />
<Button
android:id="@+id/btnZamow"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Zamów" />
</LinearLayout>
</LinearLayout>
package pl.schoolit.sklepik
import android.os.Bundle
import android.view.View
import android.widget.Button
import android.widget.ListView
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.snackbar.Snackbar
class MainActivity : AppCompatActivity() {
// ---------- kontrolki ----------
private lateinit var glownyUklad: View
private lateinit var lista: ListView
private lateinit var tvSuma: TextView
private lateinit var btnWyczysc: Button
private lateinit var btnZamow: Button
// ---------- dane ----------
private val produkty = listOf(
Produkt("Kanapka z serem", 4.50, R.drawable.ic_kanapka),
Produkt("Woda 0,5 l", 2.00, R.drawable.ic_woda),
Produkt("Drożdżówka", 3.00, R.drawable.ic_drozdzowka),
Produkt("Ciastko", 1.50, R.drawable.ic_ciastko),
Produkt("Kakao", 3.50, R.drawable.ic_kakao),
Produkt("Lody", 4.00, R.drawable.ic_lody)
)
private lateinit var adapter: ProduktAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
initUI()
// trzeci parametr — funkcja, którą adapter wywoła po każdej zmianie
adapter = ProduktAdapter(this, produkty) { pokazSume() }
lista.adapter = adapter
ustawZdarzenia()
pokazSume()
}
private fun initUI() {
glownyUklad = findViewById(R.id.main)
lista = findViewById(R.id.lista)
tvSuma = findViewById(R.id.tvSuma)
btnWyczysc = findViewById(R.id.btnWyczysc)
btnZamow = findViewById(R.id.btnZamow)
}
private fun ustawZdarzenia() {
// klik w wiersz (poza przyciskami) — działa dzięki blocksDescendants
lista.setOnItemClickListener { _, _, position, _ ->
val p = produkty[position]
val wartosc = "%.2f".format(p.cena * p.ilosc)
Toast.makeText(this, "${p.nazwa}: ${p.ilosc} szt. = $wartosc zł", Toast.LENGTH_SHORT).show()
}
btnWyczysc.setOnClickListener { wyczysc() }
btnZamow.setOnClickListener { zapytajOZamowienie() }
}
// ---------- akcje ----------
private fun wyczysc() {
for (p in produkty) p.ilosc = 0
adapter.notifyDataSetChanged()
pokazSume()
}
private fun zapytajOZamowienie() {
val pozycje = produkty
.filter { it.ilosc > 0 }
.joinToString("\n") { "${it.ilosc} × ${it.nazwa}" }
AlertDialog.Builder(this)
.setTitle("Twoje zamówienie")
.setMessage("$pozycje\n\nRazem: ${"%.2f".format(suma())} zł")
.setPositiveButton("Zamawiam") { _, _ ->
wyczysc()
Snackbar.make(glownyUklad, "Zamówienie przyjęte — odbiór na przerwie", Snackbar.LENGTH_LONG).show()
}
.setNegativeButton("Wróć", null)
.show()
}
// ---------- obliczenia ----------
private fun suma(): Double = produkty.sumOf { it.cena * it.ilosc }
// ---------- dane → ekran ----------
private fun pokazSume() {
val sztuk = produkty.sumOf { it.ilosc }
if (sztuk == 0) {
tvSuma.text = "Koszyk jest pusty"
} else {
tvSuma.text = "Sztuk: $sztuk · Do zapłaty: ${"%.2f".format(suma())} zł"
}
btnZamow.isEnabled = sztuk > 0
btnWyczysc.isEnabled = sztuk > 0
}
}
Co dzieje się po kolei
- Produkty mają ikonę jako numer zasobu (
R.drawable.ic_kanapka) i ilość0na starcie. - Adapter dostaje funkcję
{ pokazSume() }. Sam nie wie, co ona robi — wie tylko, że ma ją wywołać po zmianie. getView: nowy wiersz dostajeViewHolderwtag; odzyskany — wyjmuje go ztag. Potem wypełnia wszystkie kontrolki, także tło i przygaszenie −.- + zwiększa
iloscw obiekcie produktu, odświeża listę i wołapoZmianie()→pokazSume()w aktywności. "%.2f".format(…)formatuje liczbę z dwoma miejscami po przecinku; polski telefon wstawi przecinek: „10,50”.wyczysc()zeruje ilości w obiektach — lista produktów zostaje ta sama, zmienia się tylko jej zawartość.- Kliknięcie w nazwę lub ikonę trafia do
setOnItemClickListener, bo wiersz mablocksDescendants.
Sprawdź trzy rzeczy
- Usuń z
wiersz_produkt.xmlatrybutdescendantFocusability. Przyciski działają, ale kliknięcie w nazwę produktu może przestać pokazywać Toast. Przywróć atrybut. - W
getViewzakomentuj linię zsetBackgroundColor. Dodaj kanapkę, wyczyść koszyk. Kolor tła nie zmieni się, bo nikt go nie ustawia — każdą zmienną rzecz ustawiaj zawsze. - W obsłudze + zakomentuj
poZmianie(). Ilość w wierszu rośnie, a suma pod listą stoi — aktywność nie wie o zmianie.
Zestawienie elementów
| Element | Typ | Co robi |
|---|---|---|
private class ViewHolder(wiersz: View) | klasa | Pamięta kontrolki jednego wiersza. |
view.tag = obiekt, view.tag as Typ | właściwość | Schowanie i wyjęcie dowolnego obiektu z kontrolki. |
imageView.setImageResource(R.drawable.x) | metoda | Ustawia obrazek z zasobów. |
val f: () -> Unit | typ funkcji | Funkcja bez argumentów, nic nie zwraca. |
Klasa(a, b) { … } | składnia | Ostatni parametr-funkcję można podać w klamrach za nawiasem. |
notifyDataSetChanged() w adapterze | metoda | Adapter odświeża sam siebie po kliknięciu w wierszu. |
android:descendantFocusability="blocksDescendants" | atrybut | Przyciski w wierszu nie blokują kliknięcia wiersza. |
?attr/selectableItemBackgroundBorderless | tło | Przezroczyste tło przycisku z okrągłym „błyskiem” po dotknięciu. |
"%.2f".format(liczba) | String | Liczba z dwoma miejscami po przecinku. |
sumOf { … }, filter { … }, joinToString("\n") { … } | metody list | Suma, wybór elementów, sklejenie w tekst. |
Zanim utkniesz
| Zapis / objaw | Problem |
|---|---|
convertView.tag as ViewHolder → awaria NullPointerException | Przy tworzeniu nowego wiersza zabrakło wiersz.tag = holder. |
| ilość zmienia się w złym wierszu | Obsługa przycisku ustawiona tylko dla nowych wierszy (w if (convertView == null)). Ustawiaj ją przy każdym getView, poza if. |
| po przewinięciu ilość wraca do starej | Zmieniono tylko napis (tvIlosc.text), a nie dane (produkt.ilosc). |
val ilosc: Int i produkt.ilosc++ → błąd kompilacji | Pole zmieniane po kliknięciu musi być var. |
| suma się nie zmienia | Adapter nie wywołuje poZmianie() albo aktywność przekazała pustą funkcję { }. |
| kliknięcie wiersza nie działa, przyciski tak | Brak descendantFocusability="blocksDescendants". |
| ikona niewidoczna | W klasie danych zapisano 0 albo zły numer — podawaj R.drawable.nazwa. |
Zadania
Każde zadanie wykonaj w nowym projekcie Empty Views Activity: klasa danych, układ wiersza i adapter z ViewHolder w osobnych plikach. Wskazówkę otwórz dopiero wtedy, gdy utkniesz.
Klasa Zwierze(nazwa, ikona, var ulubione: Boolean). Wiersz: ikona, nazwa i ImageButton z serduszkiem (puste / pełne — dwie ikony favorite_border i favorite). Kliknięcie serduszka przełącza ulubione.
pokaż wskazówkę
holder.btnSerce.setImageResource(if (z.ulubione) R.drawable.ic_serce else R.drawable.ic_serce_puste)— w każdymgetView.
Klasa Kandydat(imie, klasa, var glosy: Int). Wiersz: imię, klasa, liczba głosów i przycisk Głosuj. Pod listą „Oddano głosów: 17”, a nad nią „Prowadzi: Ola (3B)”.
pokaż wskazówkę
- Lider:
kandydaci.maxByOrNull { it.glosy }. Napisy aktualizuj w funkcji przekazanej do adaptera.
Przerób przykład z lekcji 26: wiersz ma nazwę i ImageButton z koszem (delete). Kosz usuwa produkt i pokazuje Snackbar z cofnięciem. Adapter dostaje dwie funkcje: „usuń element nr …” i „odśwież licznik”.
pokaż wskazówkę
- Funkcja z argumentem:
private val usun: (Int) -> Unit; w adapterzeusun(position), w aktywnościZakupyAdapter(this, zakupy, { nr -> usunZCofnieciem(nr) }, { pokazLicznik() }).
Rozbuduj przykład: każdy produkt ma pole naStanie. Przy ilości równej stanowi + jest przygaszony, a pod nazwą pojawia się czerwony napis „ostatnie sztuki”, gdy na stanie zostały mniej niż 3. Budżet ucznia 15 zł — przycisk + nie pozwala go przekroczyć (Toast „Brak środków”).
pokaż wskazówkę
- Budżet zna aktywność — przekaż adapterowi funkcję
czyStac: (Double) -> Booleani zapytaj ją przedilosc++.
Klasa Utwor(tytul, wykonawca, czas). Wiersz ma przycisk ▶. Kliknięcie ▶ sprawia, że ten wiersz pokazuje ⏸ i pogrubiony tytuł, a wszystkie inne — ▶ i zwykły tytuł. Nad listą „Teraz gra: …”.
pokaż wskazówkę
- Numer grającego utworu trzymaj w adapterze:
var grany = -1. WgetView:val gra = position == granyi od tego ikona oraz styl tekstu.
Klasa Lekcja(nr, przedmiot, sala) z ikoną przedmiotu. Wiersz ma przyciski ✎ i 🗑 (ikony edit, delete). ✎ otwiera AlertDialog z dwoma polami (przedmiot i sala, lekcja 24) wypełnionymi obecnymi danymi; zapis zmienia obiekt i listę. 🗑 pyta o potwierdzenie. Przycisk pod listą dodaje nową lekcję z kolejnym numerem.
pokaż wskazówkę
- Okna otwiera aktywność, nie adapter: przekaż funkcje
edytuj: (Int) -> Unitiusun: (Int) -> Unit. Pola przedmiotu i sali w klasie danych muszą byćvar.
Co trzeba zapamiętać
- Obrazek w wierszu: numer
R.drawable.…jako pole klasy danych isetImageResource. ViewHolderszuka kontrolek raz na wiersz; trzymasz go wwiersz.tag.- Przyciski w wierszu: obsługa w każdym
getView, zmiana danych, potemnotifyDataSetChanged(). - Adapter informuje aktywność przez funkcję przekazaną w konstruktorze (
() -> Unit). descendantFocusability="blocksDescendants"— przyciski i kliknięcie wiersza działają razem.
To koniec rozdziału o listach. W następnym przejdziesz do wielu ekranów: intencje, przekazywanie danych i obiektów (np. kliknięty Produkt na ekran szczegółów).
Dokumentacja: View.setTag, descendantFocusability, Funkcje jako parametry w Kotlinie.