school-it.pl // android + kotlin // rozdział 7 // lekcja 27
Wbudowane listy wyboru
Lista nie musi tylko reagować na kliknięcie — może też pamiętać, co wybrano. Android ma gotowe układy wierszy z kółkiem (wybór jednej pozycji) i z kwadratem (wybór wielu pozycji). Wystarczy wskazać taki układ w adapterze i ustawić tryb wyboru listy. W tej lekcji zbudujesz planer wycieczki klasowej: jeden cel z jednej listy i dowolne atrakcje z drugiej.
Czego się dziś nauczysz
- Użyjesz wbudowanych układów
simple_list_item_single_choiceisimple_list_item_multiple_choice. - Ustawisz tryb wyboru listy:
android:choiceMode. - Odczytasz wybraną pozycję (
checkedItemPosition) i zaznaczone pozycje (isItemChecked). - Zaznaczysz i wyczyścisz wybór z kodu.
- Połączysz dwie listy na jednym ekranie i policzysz z nich wynik.
Wbudowane układy wyboru
W lekcji 25 używałeś układu wiersza android.R.layout.simple_list_item_1 — sam tekst. Android ma jeszcze dwa układy, które oprócz tekstu rysują znacznik wyboru po prawej stronie wiersza:
kółko — wybór jednej pozycji; kwadrat — wybór dowolnej liczby pozycji
Adapter tworzysz tak samo jak dotąd — zmienia się tylko układ wiersza:
val rozmiary = arrayOf("S", "M", "L", "XL")
listaRozmiarow.adapter = ArrayAdapter(this, android.R.layout.simple_list_item_single_choice, rozmiary)
val dodatki = arrayOf("Ser", "Szynka", "Pomidor", "Ogórek")
listaDodatkow.adapter = ArrayAdapter(this, android.R.layout.simple_list_item_multiple_choice, dodatki)
Tryb wyboru listy
Sam układ wiersza tylko rysuje kółko lub kwadrat. Żeby kliknięcie je zaznaczało, a lista pamiętała wybór, trzeba listę przełączyć w tryb wyboru. Robi się to atrybutem w XML:
<ListView
android:id="@+id/listaRozmiarow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:choiceMode="singleChoice" /> <!-- jedna pozycja -->
<ListView
android:id="@+id/listaDodatkow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:choiceMode="multipleChoice" /> <!-- wiele pozycji -->
| choiceMode | Zachowanie po kliknięciu |
|---|---|
none (domyślnie) | Lista niczego nie zaznacza. Tak było w lekcjach 25 i 26. |
singleChoice | Kliknięty wiersz zostaje zaznaczony, poprzedni się odznacza — jak RadioGroup z lekcji 10. Ponowne kliknięcie zaznaczonego wiersza nic nie zmienia. |
multipleChoice | Każde kliknięcie przełącza wiersz: zaznaczony ↔ niezaznaczony — jak CheckBox z lekcji 09. |
Kto pamięta wybór?
Lista (ListView), a nie adapter i nie wiersz. Wiersze są rysowane od nowa przy przewijaniu — gdyby to one pamiętały znacznik, ginąłby po przewinięciu. Dlatego wybór zawsze odczytujesz z listy.
Odczyt wyboru
Jedna pozycja — numer zaznaczonego wiersza albo ListView.INVALID_POSITION (czyli -1), gdy nic nie wybrano:
val nr = listaRozmiarow.checkedItemPosition
if (nr == ListView.INVALID_POSITION) {
tvInfo.text = "Wybierz rozmiar"
} else {
tvInfo.text = "Rozmiar: ${rozmiary[nr]}"
}
Wiele pozycji — pytasz listę o każdy wiersz po kolei: „czy ten jest zaznaczony?”
val wybrane = mutableListOf<String>()
for (i in dodatki.indices) { // 0, 1, 2, 3
if (listaDodatkow.isItemChecked(i)) wybrane.add(dodatki[i])
}
tvInfo.text = "Dodatki: ${wybrane.joinToString(", ")}" // Dodatki: Ser, Pomidor, Ogórek
val ile = listaDodatkow.checkedItemCount // ile zaznaczonych — bez pętli
Kiedy odczytywać? Najczęściej w obsłudze kliknięcia wiersza. Gdy wywoływany jest setOnItemClickListener, lista już zmieniła zaznaczenie — odczytujesz stan po kliknięciu:
listaDodatkow.setOnItemClickListener { _, _, position, _ ->
val stan = if (listaDodatkow.isItemChecked(position)) "dodano" else "usunięto"
tvInfo.text = "${dodatki[position]}: $stan"
}
Zaznaczanie z kodu
listaRozmiarow.setItemChecked(1, true) // domyślnie zaznaczone „M”
listaDodatkow.setItemChecked(0, true) // ser zaznaczony na starcie
listaDodatkow.clearChoices() // odznacz wszystko w pamięci listy…
adapterDodatkow.notifyDataSetChanged() // …i odśwież wiersze na ekranie
setItemChecked sama odświeża wiersze. clearChoices() czyści tylko pamięć listy — znaczki na ekranie zostałyby, dopóki czegoś nie przewiniesz. Dlatego po niej wywołujesz znane z lekcji 26 notifyDataSetChanged(), a adapter trzymasz w polu.
Planer wycieczki klasowej
Górna lista: cel wycieczki — dokładnie jeden. Dolna lista: atrakcje — dowolnie wiele. Pod listami napis podsumowania liczy koszt na osobę na bieżąco. Zatwierdź jest aktywny dopiero po wybraniu celu i pokazuje okno z planem. Wyczyść odznacza obie listy.
Basen,
Kręgle
Koszt na osobę: 200 zł
Przygotowanie projektu
- Nowy projekt Empty Views Activity, nazwa
Wycieczka, Kotlin, API 24. - Podmień
activity_main.xmliMainActivity.kt.
<?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="Wycieczka klasowa"
android:textSize="24sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Cel (jeden)"
android:textStyle="bold" />
<!-- obie listy dzielą wolne miejsce po połowie; każda przewija się sama -->
<ListView
android:id="@+id/listaCeli"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:choiceMode="singleChoice" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Atrakcje (dowolne)"
android:textStyle="bold" />
<ListView
android:id="@+id/listaAtrakcji"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:choiceMode="multipleChoice" />
<TextView
android:id="@+id/tvPodsumowanie"
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/btnZatwierdz"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Zatwierdź" />
</LinearLayout>
</LinearLayout>
package pl.schoolit.wycieczka
import android.os.Bundle
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.ListView
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
class MainActivity : AppCompatActivity() {
// ---------- kontrolki ----------
private lateinit var listaCeli: ListView
private lateinit var listaAtrakcji: ListView
private lateinit var tvPodsumowanie: TextView
private lateinit var btnWyczysc: Button
private lateinit var btnZatwierdz: Button
// ---------- dane ----------
private val cele = arrayOf("Kraków", "Zakopane", "Gdańsk", "Wrocław")
private val cenyCeli = intArrayOf(120, 150, 180, 130) // ten sam numer = ta sama pozycja
private val atrakcje = arrayOf("Muzeum", "Kino", "Basen", "Park linowy", "Kręgle")
private val cenyAtrakcji = intArrayOf(15, 25, 20, 45, 30)
private lateinit var adapterCeli: ArrayAdapter<String>
private lateinit var adapterAtrakcji: ArrayAdapter<String>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
initUI()
ustawListy()
ustawZdarzenia()
pokazPodsumowanie()
}
private fun initUI() {
listaCeli = findViewById(R.id.listaCeli)
listaAtrakcji = findViewById(R.id.listaAtrakcji)
tvPodsumowanie = findViewById(R.id.tvPodsumowanie)
btnWyczysc = findViewById(R.id.btnWyczysc)
btnZatwierdz = findViewById(R.id.btnZatwierdz)
}
private fun ustawListy() {
// napisy wierszy: „Kraków — 120 zł”
val opisyCeli = cele.mapIndexed { i, cel -> "$cel — ${cenyCeli[i]} zł" }
adapterCeli = ArrayAdapter(this, android.R.layout.simple_list_item_single_choice, opisyCeli)
listaCeli.adapter = adapterCeli
val opisyAtrakcji = atrakcje.mapIndexed { i, a -> "$a — ${cenyAtrakcji[i]} zł" }
adapterAtrakcji = ArrayAdapter(this, android.R.layout.simple_list_item_multiple_choice, opisyAtrakcji)
listaAtrakcji.adapter = adapterAtrakcji
}
private fun ustawZdarzenia() {
// lista sama zaznacza wiersz; my tylko odświeżamy podsumowanie
listaCeli.setOnItemClickListener { _, _, _, _ -> pokazPodsumowanie() }
listaAtrakcji.setOnItemClickListener { _, _, _, _ -> pokazPodsumowanie() }
btnWyczysc.setOnClickListener { wyczysc() }
btnZatwierdz.setOnClickListener { zatwierdz() }
}
// ---------- odczyt wyboru ----------
private fun wybraneAtrakcje(): List<Int> {
val numery = mutableListOf<Int>()
for (i in atrakcje.indices) {
if (listaAtrakcji.isItemChecked(i)) numery.add(i)
}
return numery
}
private fun koszt(): Int {
val nrCelu = listaCeli.checkedItemPosition
if (nrCelu == ListView.INVALID_POSITION) return 0
return cenyCeli[nrCelu] + wybraneAtrakcje().sumOf { cenyAtrakcji[it] }
}
// ---------- akcje ----------
private fun wyczysc() {
listaCeli.clearChoices()
listaAtrakcji.clearChoices()
adapterCeli.notifyDataSetChanged() // bez tego znaczki zostałyby na ekranie
adapterAtrakcji.notifyDataSetChanged()
pokazPodsumowanie()
}
private fun zatwierdz() {
val cel = cele[listaCeli.checkedItemPosition] // przycisk aktywny tylko z wybranym celem
val nazwy = wybraneAtrakcje().map { atrakcje[it] }
val tekstAtrakcji = if (nazwy.isEmpty()) "brak" else nazwy.joinToString(", ")
AlertDialog.Builder(this)
.setTitle("Wycieczka: $cel")
.setMessage("Atrakcje: $tekstAtrakcji\n\nKoszt na osobę: ${koszt()} zł")
.setPositiveButton("OK", null)
.show()
}
// ---------- dane → ekran ----------
private fun pokazPodsumowanie() {
val nrCelu = listaCeli.checkedItemPosition
val ileAtrakcji = listaAtrakcji.checkedItemCount
if (nrCelu == ListView.INVALID_POSITION) {
tvPodsumowanie.text = "Wybierz cel wycieczki"
} else {
tvPodsumowanie.text = "${cele[nrCelu]} + atrakcje: $ileAtrakcji · razem ${koszt()} zł"
}
btnZatwierdz.isEnabled = nrCelu != ListView.INVALID_POSITION
btnWyczysc.isEnabled = nrCelu != ListView.INVALID_POSITION || ileAtrakcji > 0
}
}
Co dzieje się po kolei
- Ceny są w osobnych tablicach
IntArrayo tej samej kolejności co nazwy:cele[1]to Zakopane,cenyCeli[1]to jego cena. W lekcji 28 zastąpisz takie pary tablic jedną klasą danych. mapIndexedzamienia każdą nazwę na napis wiersza; dostaje numer i element, więc dobiera właściwą cenę.- Tryb wyboru jest w XML (
choiceMode), układ wiersza — w adapterze. Potrzebne są oba. - Kliknięcie wiersza: lista sama zmienia zaznaczenie, a potem wywołuje nasz kod. My tylko liczymy podsumowanie od nowa.
koszt()zwraca 0, gdy nie ma celu; w przeciwnym razie dodaje cenę celu i sumę cen zaznaczonych atrakcji (sumOf).wyczysc():clearChoices()+notifyDataSetChanged()na obu listach.pokazPodsumowanie()jest jedynym miejscem, które zmienia napis i przyciski — tak jakpokazListe()w lekcji 26.
Sprawdź trzy rzeczy
- Usuń z XML
android:choiceMode="multipleChoice". Kwadraty nadal są rysowane, ale kliknięcie ich nie zaznacza — sam układ wiersza nie wystarczy. - W
wyczysc()zakomentuj obanotifyDataSetChanged(). Napis pokaże „Wybierz cel wycieczki”, a znaczki zostaną na ekranie. - Kliknij dwa razy ten sam cel. Pozostaje zaznaczony — w trybie
singleChoicenie da się odznaczyć wiersza kliknięciem.
Zestawienie elementów
| Element | Typ | Co robi |
|---|---|---|
android.R.layout.simple_list_item_single_choice | układ wiersza | Tekst i kółko po prawej. |
android.R.layout.simple_list_item_multiple_choice | układ wiersza | Tekst i kwadrat po prawej. |
android:choiceMode="singleChoice" | "multipleChoice" | atrybut XML | Tryb wyboru listy. Z kodu: lista.choiceMode = ListView.CHOICE_MODE_SINGLE. |
lista.checkedItemPosition | Int | Numer wybranego wiersza (singleChoice) albo ListView.INVALID_POSITION. |
lista.isItemChecked(nr) | Boolean | Czy wiersz jest zaznaczony. |
lista.checkedItemCount | Int | Liczba zaznaczonych wierszy. |
lista.setItemChecked(nr, true/false) | metoda | Zaznacza lub odznacza wiersz z kodu. |
lista.clearChoices() | metoda | Odznacza wszystko w pamięci listy; potem notifyDataSetChanged(). |
tablica.mapIndexed { i, e -> … } | List | Nowa lista z każdego elementu i jego numeru. |
lista.sumOf { … } | Int | Suma wartości policzonych dla każdego elementu. |
Zanim utkniesz
| Zapis / objaw | Problem |
|---|---|
| kółka widać, ale kliknięcie ich nie zaznacza | Brak android:choiceMode w XML listy. |
| zaznacza się, ale nie widać znaczka | Adapter z simple_list_item_1 — ten układ nie rysuje kółka ani kwadratu. |
cele[lista.checkedItemPosition] → awaria | Nic nie wybrano, numer to -1. Najpierw sprawdź INVALID_POSITION. |
checkedItemPosition przy multipleChoice | Ta właściwość jest dla jednej pozycji. Przy wielu użyj isItemChecked(i) w pętli. |
| po „Wyczyść” znaczki zostają | Po clearChoices() brak notifyDataSetChanged(). |
| zaznaczenie zapisane w polu klasy rozjeżdża się z ekranem | Nie przechowuj wyboru osobno — zawsze pytaj listę (isItemChecked). |
dwie listy w ScrollView pokazują po jednym wierszu | ListView sama się przewija i nie wkłada się jej do ScrollView. Podziel ekran wagami, jak w przykładzie. |
Zadania
Każde zadanie wykonaj w nowym projekcie Empty Views Activity, w układzie z lekcji 19. Wskazówkę otwórz dopiero wtedy, gdy utkniesz.
Lista jednego wyboru z klasami „1A”…„4C” (12 pozycji). Pod listą napis „Wybrana klasa: 2B” albo „Nie wybrano klasy”. Na starcie zaznaczona jest pierwsza klasa.
pokaż wskazówkę
- Na starcie:
lista.setItemChecked(0, true), a potem odśwież napis.
Lista wielokrotnego wyboru z 10 uczniami. Zaznaczony = obecny. Napis „Obecnych: 8 / 10” zmienia się po każdym kliknięciu. Przycisk Wszyscy obecni zaznacza całą listę.
pokaż wskazówkę
for (i in uczniowie.indices) lista.setItemChecked(i, true); liczba:lista.checkedItemCount.
Rozbuduj zadanie 2: przycisk Zapisz otwiera AlertDialog z listą nieobecnych (niezaznaczonych) uczniów, każdy w osobnej linii. Gdy wszyscy są obecni — okno z tekstem „Komplet!”.
pokaż wskazówkę
- Nieobecni:
uczniowie.filterIndexed { i, _ -> !lista.isItemChecked(i) }, tekst:joinToString("\n").
Dwie listy: danie główne (jeden wybór, 4 pozycje z cenami) i dodatki (wiele, 5 pozycji z cenami). Napis z sumą na bieżąco. Jeśli wybrano co najmniej 3 dodatki — najtańszy gratis; napis pokazuje wtedy „(najtańszy dodatek gratis)”.
pokaż wskazówkę
- Ceny zaznaczonych dodatków zbierz do listy; rabat to
ceny.min(), gdyceny.size >= 3.
Lista 8 przedmiotów rozszerzonych, z których można wybrać najwyżej 3. Próba zaznaczenia czwartego od razu go odznacza i pokazuje Toast „Możesz wybrać najwyżej 3 przedmioty”.
pokaż wskazówkę
- W
setOnItemClickListener: jeślicheckedItemCount > 3, tolista.setItemChecked(position, false)— cofa właśnie kliknięty wiersz.
Ekran z pytaniem (TextView) i listą jednego wyboru z czterema odpowiedziami. Przycisk Sprawdź pokazuje Snackbar „Dobrze!” albo „Źle — poprawna: …”, a potem Dalej przechodzi do kolejnego pytania (5 pytań w tablicach), czyści wybór i podmienia odpowiedzi w adapterze. Na końcu AlertDialog z wynikiem „4 / 5”.
pokaż wskazówkę
- Adapter na
mutableListOf<String>(). Nowe pytanie:odpowiedzi.clear(); odpowiedzi.addAll(…); lista.clearChoices(); adapter.notifyDataSetChanged().
Co trzeba zapamiętać
- Lista wyboru = układ wiersza z wyborem w adapterze i
android:choiceModew XML. - Jedna pozycja:
checkedItemPosition(uwaga naINVALID_POSITION). - Wiele pozycji:
isItemChecked(i)w pętli, liczba —checkedItemCount. - Wybór pamięta lista. Z kodu:
setItemChecked,clearChoices+notifyDataSetChanged. - Wbudowane układy pokazują tylko tekst. Obrazek, dwie linie czy cena w osobnym kolorze — to już własny wiersz i własny adapter (lekcja 28).
Dokumentacja: AbsListView.setChoiceMode, isItemChecked.