school-it.pl // android + kotlin // rozdział 7 // lekcja 28
Własny adapter i własny wiersz
Wbudowane układy wierszy pokazują jeden napis. Prawdziwe aplikacje mają w wierszu kilka rzeczy naraz: tytuł, opis, ocenę, kolorową etykietę. Do tego potrzebne są trzy elementy: klasa danych (co pokazujemy), własny układ wiersza (jak wygląda jeden wiersz) i własny adapter (kto wkłada dane do wiersza). Zbudujesz repertuar kina, w którym każdy film ma etykietę wieku, opis i gwiazdki.
Czego się dziś nauczysz
- Opiszesz jeden element listy klasą danych (
data class). - Zaprojektujesz własny układ wiersza w osobnym pliku XML.
- Napiszesz klasę adaptera dziedziczącą po
ArrayAdapteri metodęgetView. - Zrozumiesz, skąd bierze się
convertViewi dlaczego warto go używać. - Odczytasz cały obiekt po kliknięciu wiersza.
Trzy elementy własnej listy
Przypomnij sobie lekcję 25: ArrayAdapter brał napis z tablicy i wkładał go do gotowego wiersza simple_list_item_1. Teraz każdą z tych części robimy sami:
„co pokazujemy”
tytuł, gatunek, ocena…
„jak to wygląda”
do kontrolek wiersza
gotowe wiersze
| Lekcja 25 (wbudowany) | Ta lekcja (własny) | |
|---|---|---|
| dane | Array<String> | List<Film> |
| układ wiersza | android.R.layout.simple_list_item_1 | R.layout.wiersz_film |
| adapter | ArrayAdapter(this, …, tablica) | FilmAdapter(this, filmy) |
| kto wypełnia wiersz | adapter wywołuje toString() | nasza metoda getView |
Klasa danych
Jeden film to kilka informacji. Zamiast trzymać je w kilku tablicach (jak ceny w lekcji 27), zamykamy je w jednej klasie. Słowo data sprawia, że Kotlin sam dopisze przydatne metody, np. czytelne toString().
package pl.schoolit.repertuar
data class Film(
val tytul: String,
val gatunek: String,
val czas: Int, // w minutach
val ocena: Float, // 0.0 – 5.0, dla RatingBar
val wiek: Int // 0 = bez ograniczeń
)
Nowy plik: prawy klik na pakiet w java/… → New → Kotlin Class/File → nazwa Film, typ Data Class.
Układ wiersza
Układ wiersza to zwykły plik w res/layout — taki jak activity_main.xml, tylko opisuje jeden wiersz listy. Najważniejsza różnica: główny element ma wysokość wrap_content, a nie match_parent — inaczej jeden wiersz zająłby cały ekran.
Nowy plik: prawy klik na res/layout → New → Layout Resource File → nazwa wiersz_film, główny element LinearLayout.
Klasa adaptera
Nasz adapter dziedziczy po ArrayAdapter<Film> — dostaje od niego za darmo liczenie wierszy i getItem. Sami piszemy tylko jedną metodę: getView, czyli „zbuduj wiersz numer position”.
class FilmAdapter(context: Context, private val filmy: List<Film>) :
ArrayAdapter<Film>(context, 0, filmy) { // 0 — wbudowanego układu nie używamy
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
// 1. wiersz: stary do ponownego użycia albo nowy z pliku XML
val wiersz = convertView
?: LayoutInflater.from(context).inflate(R.layout.wiersz_film, parent, false)
// 2. dane tego wiersza
val film = filmy[position]
// 3. dane → kontrolki wiersza
wiersz.findViewById<TextView>(R.id.tvTytul).text = film.tytul
// 4. gotowy wiersz oddajemy liście
return wiersz
}
}
| Fragment | Znaczenie |
|---|---|
context: Context | Aktywność, która tworzy adapter. Potrzebna do wczytania pliku XML. W MainActivity przekazujesz this. |
private val filmy | Lista filmów jako pole adaptera — dostępna w getView. |
: ArrayAdapter<Film>(context, 0, filmy) | Dziedziczenie i wywołanie konstruktora rodzica. |
override fun getView | Podmieniamy metodę rodzica — wiersz budujemy po swojemu. |
LayoutInflater.from(context).inflate(…) | „Nadmuchuje” plik XML: zamienia jego opis na prawdziwe kontrolki. |
parent, false | Lista jest rodzicem (dla rozmiarów), ale wiersza nie dodajemy do niej sami — zrobi to ListView. |
wiersz.findViewById | Szukamy kontrolki w wierszu, nie w całym ekranie. |
Jak działa getView i skąd convertView
ListView nie tworzy wierszy dla wszystkich elementów. Na ekranie mieści się ich np. 7, więc powstaje około 7 wierszy. Gdy przewijasz, wiersz, który znika u góry, wraca na dół jako convertView — z nowymi danymi. To jak tablica ogłoszeń z kilkoma ramkami: nie kupujesz nowej ramki na każde ogłoszenie, tylko wymieniasz kartkę.
convertView ≠ null → użyj go
Z odzyskiem wiersza wiąże się jedna zasada
Odzyskany wiersz ma jeszcze dane i wygląd poprzedniego filmu. Dlatego w getView ustawiasz każdą zmienną rzecz za każdym razem. Jeśli np. kolor ustawiasz tylko dla filmów 18+, a dla pozostałych nie — po przewinięciu czerwone etykiety pojawią się przy bajkach.
Repertuar kina
Lista sześciu filmów. Wiersz ma kolorowe kółko z kategorią wieku (zielone do 11 lat, pomarańczowe 12–15, czerwone 16+), tytuł, gatunek z czasem trwania i małe gwiazdki oceny. Kliknięcie wiersza otwiera okno ze szczegółami i przyciskiem Kup bilet.
„Smocze jajo”
Czas: 1 h 50 min
Ocena widzów: 3.0 / 5
Od lat: 12
Przygotowanie projektu
- Nowy projekt Empty Views Activity, nazwa
Repertuar, Kotlin, API 24. - Dodaj klasę danych
Film.kt(kod wyżej, w sekcji „Klasa danych”). - Dodaj kształt
res/drawable/tlo_wiek.xmli układres/layout/wiersz_film.xml. - Dodaj klasę
FilmAdapter.kt(New → Kotlin Class/File → Class). - Podmień
activity_main.xmliMainActivity.kt.
├── java/…/
│ ├── Film.ktklasa danych
│ ├── FilmAdapter.ktwłasny adapter
│ └── MainActivity.kt
└── res/
├── drawable/tlo_wiek.xmlkółko etykiety wieku
└── layout/
├── activity_main.xml
└── wiersz_film.xmlukład jednego wiersza
<?xml version="1.0" encoding="utf-8"?>
<!-- kółko; kolor zmieniamy w adapterze -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#2E7D32" />
</shape>
<?xml version="1.0" encoding="utf-8"?>
<!-- JEDEN wiersz listy: wysokość wrap_content -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="12dp">
<TextView
android:id="@+id/tvWiek"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@drawable/tlo_wiek"
android:gravity="center"
android:textColor="#FFFFFF"
android:textSize="13sp"
android:textStyle="bold" />
<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/tvTytul"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textSize="17sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvOpis"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="13sp" />
</LinearLayout>
<!-- małe gwiazdki tylko do pokazywania oceny (lekcja 16) -->
<RatingBar
android:id="@+id/rbOcena"
style="?android:attr/ratingBarStyleSmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:isIndicator="true"
android:numStars="5"
android:stepSize="0.5" />
</LinearLayout>
package pl.schoolit.repertuar
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.RatingBar
import android.widget.TextView
class FilmAdapter(context: Context, private val filmy: List<Film>) :
ArrayAdapter<Film>(context, 0, filmy) {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
// 1. wiersz: odzyskany albo nowy
val wiersz = convertView
?: LayoutInflater.from(context).inflate(R.layout.wiersz_film, parent, false)
// 2. kontrolki wewnątrz wiersza
val tvWiek = wiersz.findViewById<TextView>(R.id.tvWiek)
val tvTytul = wiersz.findViewById<TextView>(R.id.tvTytul)
val tvOpis = wiersz.findViewById<TextView>(R.id.tvOpis)
val rbOcena = wiersz.findViewById<RatingBar>(R.id.rbOcena)
// 3. dane tego wiersza
val film = filmy[position]
// 4. dane → kontrolki (KAŻDA rzecz ustawiana za każdym razem)
tvTytul.text = film.tytul
tvOpis.text = "${film.gatunek} · ${film.czas / 60} h ${film.czas % 60} min"
rbOcena.rating = film.ocena
tvWiek.text = if (film.wiek == 0) "BO" else film.wiek.toString()
val kolor = when {
film.wiek < 12 -> "#2E7D32" // zielony
film.wiek < 16 -> "#EF6C00" // pomarańczowy
else -> "#C62828" // czerwony
}
val kolko = tvWiek.background.mutate() as GradientDrawable // lekcja 17
kolko.setColor(Color.parseColor(kolor))
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="Repertuar"
android:textSize="24sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvNaglowek"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp" />
<ListView
android:id="@+id/lista"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
package pl.schoolit.repertuar
import android.os.Bundle
import android.widget.ListView
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
class MainActivity : AppCompatActivity() {
// ---------- kontrolki ----------
private lateinit var tvNaglowek: TextView
private lateinit var lista: ListView
// ---------- dane ----------
private val filmy = listOf(
Film("Kosmiczna przygoda", "Animacja", 95, 4.5f, 0),
Film("Wakacje z dziadkiem", "Komedia", 102, 3.5f, 7),
Film("Smocze jajo", "Fantasy", 110, 3.0f, 12),
Film("Mistrzowie boiska", "Sportowy", 97, 4.0f, 12),
Film("Ostatni pociąg", "Thriller", 118, 4.0f, 15),
Film("Cień nad miastem", "Kryminał", 131, 4.5f, 18)
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
initUI()
lista.adapter = FilmAdapter(this, filmy)
tvNaglowek.text = "Dziś w kinie: ${filmy.size} filmów"
ustawZdarzenia()
}
private fun initUI() {
tvNaglowek = findViewById(R.id.tvNaglowek)
lista = findViewById(R.id.lista)
}
private fun ustawZdarzenia() {
lista.setOnItemClickListener { _, _, position, _ ->
pokazSzczegoly(filmy[position]) // cały obiekt, nie tylko napis
}
}
// ---------- akcje ----------
private fun pokazSzczegoly(film: Film) {
val wiek = if (film.wiek == 0) "bez ograniczeń" else "${film.wiek}"
val tresc = "Gatunek: ${film.gatunek}\n" +
"Czas: ${film.czas / 60} h ${film.czas % 60} min\n" +
"Ocena widzów: ${film.ocena} / 5\n" +
"Od lat: $wiek"
AlertDialog.Builder(this)
.setTitle(film.tytul)
.setMessage(tresc)
.setPositiveButton("Kup bilet") { _, _ ->
Toast.makeText(this, "Bilet na „${film.tytul}” w koszyku", Toast.LENGTH_SHORT).show()
}
.setNegativeButton("Zamknij", null)
.show()
}
}
Co dzieje się po kolei
filmyto lista obiektówFilm. Kolejność na liście = kolejność wierszy.FilmAdapter(this, filmy)—thisto kontekst (aktywność),filmyto dane. Adapter podłączamy jak w lekcji 25.- Lista pyta adapter o kolejne wiersze.
getViewbierze stary wiersz albo „nadmuchuje” nowy zwiersz_film.xml, znajduje w nim kontrolki i wpisuje dane filmu. - Czas 110 min zamienia się na „1 h 50 min”:
110 / 60 = 1(dzielenie liczb całkowitych),110 % 60 = 50(reszta). - Kolor kółka ustawiany jest w każdym wierszu — także zielony. Dzięki temu odzyskany wiersz nie zachowa koloru poprzedniego filmu.
- Po kliknięciu
filmy[position]daje cały obiekt — w oknie są wszystkie pola, nie tylko tytuł.
Sprawdź trzy rzeczy
- Zmień w
wiersz_film.xmlwysokość głównegoLinearLayoutnamatch_parent. Każdy film zajmie cały ekran. Przywróćwrap_content. - Dopisz do listy 15 kolejnych filmów (możesz kopiować). Dodaj w
getViewlinięif (convertView == null) Log.d("ADAPTER", "nowy wiersz $position")(importandroid.util.Log) i przewijaj, patrząc w Logcat. Nowych wierszy będzie tylko kilkanaście — reszta to odzysk. - Ustaw kolor tylko w
if (film.wiek >= 16)i usuń pozostałe gałęzie. Przy wielu filmach przewiń w dół i w górę — czerwone kółka „przeskoczą” na inne filmy.
Zestawienie elementów
| Element | Typ | Co robi |
|---|---|---|
data class Film(val …) | klasa | Jeden element listy z kilkoma polami. |
class X(…) : ArrayAdapter<Film>(context, 0, lista) | klasa | Własny adapter oparty na ArrayAdapter. |
override fun getView(position, convertView, parent): View | metoda | Buduje i zwraca wiersz o podanym numerze. |
convertView | View? | Wiersz do ponownego użycia albo null. |
LayoutInflater.from(context).inflate(R.layout.x, parent, false) | metoda | Tworzy kontrolki z pliku XML. |
?: | operator | „Jeśli po lewej jest null, weź to, co po prawej”. |
wiersz.findViewById<T>(id) | metoda | Szuka kontrolki wewnątrz wiersza. |
context w adapterze | Context | Kontekst przekazany w konstruktorze — dostępny jako właściwość adaptera. |
android:maxLines="1" + android:ellipsize="end" | atrybuty | Za długi tytuł kończy się „…” zamiast łamać wiersz. |
style="?android:attr/ratingBarStyleSmall" | styl | Małe gwiazdki tylko do odczytu. |
a / b, a % b na Int | operatory | Dzielenie całkowite i reszta z dzielenia. |
Zanim utkniesz
| Zapis / objaw | Problem |
|---|---|
| każdy wiersz na cały ekran | W wiersz_film.xml główny element ma layout_height="match_parent". |
inflate(R.layout.wiersz_film, parent, true) → awaria | Ostatni argument musi być false: wiersz do listy dodaje ListView, nie ty. |
findViewById(R.id.tvTytul) bez wiersz. → błąd kompilacji | Adapter nie jest aktywnością. Szukaj w wierszu: wiersz.findViewById. |
| kolory lub ikony „przeskakują” przy przewijaniu | Jakąś rzecz ustawiasz tylko w części przypadków (if bez else). Odzyskany wiersz pamięta stary wygląd. |
| każdy wiersz pokazuje coś w rodzaju „Film(tytul=…, gatunek=…)” | Użyto zwykłego ArrayAdapter(this, simple_list_item_1, filmy) — wypisuje toString(). Podłącz FilmAdapter. |
rbOcena.rating = film.ocena nie pasuje typem | rating to Float. W klasie danych Float, w liście 4.5f. |
zawsze tworzysz nowy wiersz (bez convertView) | Działa, ale przy długiej liście przewijanie zacina się. Zawsze convertView ?: inflate(…). |
Zadania
Każde zadanie wykonaj w nowym projekcie Empty Views Activity, w układzie z lekcji 19: klasa danych, układ wiersza i adapter w osobnych plikach. Wskazówkę otwórz dopiero wtedy, gdy utkniesz.
Klasa Kontakt(imie, nazwisko, telefon). Wiersz: pogrubione „Imię Nazwisko”, pod spodem numer telefonu szarym, mniejszym tekstem. Kliknięcie — Toast z numerem.
pokaż wskazówkę
- Wiersz: pionowy
LinearLayoutz dwomaTextView; wgetView:"${k.imie} ${k.nazwisko}".
Klasa Ocena(przedmiot, ocena: Int). Wiersz: nazwa przedmiotu po lewej, duża cyfra oceny po prawej. Ocena 1 — czerwona, 2–3 — pomarańczowa, 4–6 — zielona. Pod listą średnia z dwoma miejscami po przecinku.
pokaż wskazówkę
tvOcena.setTextColor(Color.parseColor(kolor))w każdej gałęziwhen. Średnia:oceny.map { it.ocena }.average()i"%.2f".format(…).
Klasa Odjazd(godzina, linia, kierunek, opoznienie: Int). Wiersz: godzina (duża), numer linii w zaokrąglonym prostokącie (kształt z lekcji 17), kierunek. Gdy opoznienie > 0 — czerwony dopisek „+5 min”, w przeciwnym razie dopisek ukryty.
pokaż wskazówkę
tvOpoznienie.visibility = if (o.opoznienie > 0) View.VISIBLE else View.GONE— ustawiaj w obu przypadkach.
Rozbuduj przykład: nad listą Spinner „Sortuj: tytuł / ocena / czas”. Zmiana wyboru układa filmy i odświeża listę. Lista filmy musi być teraz mutableListOf.
pokaż wskazówkę
filmy.sortByDescending { it.ocena }itp., potemadapter.notifyDataSetChanged()— adapter trzymaj w polu (lekcja 26).
Klasa Zadanie(tytul, priorytet: Int, var zrobione: Boolean). Wiersz: pasek z lewej (szerokość 6dp) w kolorze priorytetu i tytuł. Kliknięcie przełącza zrobione: tytuł staje się szary i przekreślony.
pokaż wskazówkę
- Przekreślenie:
tv.paintFlags = tv.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG, zdjęcie:and Paint.STRIKE_THRU_TEXT_FLAG.inv(). Ustawiaj zawsze, w obu stanach.
Rozbuduj przykład: nad listą SeekBar „Mam lat: 13” (zakres 3–20). Lista pokazuje tylko filmy, na które widz może wejść, a nagłówek — „Dla Ciebie: 4 z 6 filmów”. Kliknięcie wiersza ma otwierać właściwy film także po filtrowaniu.
pokaż wskazówkę
- Dwie listy:
wszystkie(stała) iwidoczne(adapter). Filtrowanie:widoczne.clear(); widoczne.addAll(wszystkie.filter { it.wiek <= lata }). Po kliknięciu bierzwidoczne[position], niewszystkie[position].
Co trzeba zapamiętać
- Własna lista = klasa danych + układ wiersza (
wrap_content) + klasa adaptera. - Adapter dziedziczy po
ArrayAdapter<T>i nadpisujegetView. convertView ?: LayoutInflater.from(context).inflate(…, parent, false)— odzysk albo nowy wiersz.- Kontrolki szukasz w wierszu:
wiersz.findViewById. - W
getViewustawiaj każdą zmienną rzecz za każdym razem — wiersze wracają z wyglądem poprzedniego elementu.
W kolejnej lekcji wiersz dostanie obrazek i przyciski, a adapter — klasę ViewHolder, która oszczędza szukania kontrolek.
Dokumentacja: ArrayAdapter, LayoutInflater, Klasy danych w Kotlinie.