Przejdź do treści
School IT / Przestrzeń do nauki

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.

Kotlin XML choiceMode simple_list_item_single_choice simple_list_item_multiple_choice isItemChecked 2 × 45 min
CEL LEKCJI

Czego się dziś nauczysz

  • Użyjesz wbudowanych układów simple_list_item_single_choice i simple_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.
TEORIA

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:

10:00
Rozmiar koszulki
S
M
L
XL
simple_list_item_single_choice
10:00
Dodatki do kanapki
Ser
Szynka
Pomidor
Ogórek
simple_list_item_multiple_choice

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:

adaptery z układami wyboru
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)
TEORIA

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:

tryb wyboru w activity_main.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 -->
choiceModeZachowanie po kliknięciu
none (domyślnie)Lista niczego nie zaznacza. Tak było w lekcjach 25 i 26.
singleChoiceKliknięty wiersz zostaje zaznaczony, poprzedni się odznacza — jak RadioGroup z lekcji 10. Ponowne kliknięcie zaznaczonego wiersza nic nie zmienia.
multipleChoiceKaż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.

TEORIA

Odczyt wyboru

Jedna pozycja — numer zaznaczonego wiersza albo ListView.INVALID_POSITION (czyli -1), gdy nic nie wybrano:

singleChoice — odczyt
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?”

multipleChoice — odczyt
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:

reakcja na zmianę wyboru
listaDodatkow.setOnItemClickListener { _, _, position, _ ->
    val stan = if (listaDodatkow.isItemChecked(position)) "dodano" else "usunięto"
    tvInfo.text = "${dodatki[position]}: $stan"
}
TEORIA

Zaznaczanie z kodu

ustawianie i czyszczenie wyboru
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.

PRZYKŁAD

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.

8:10
Wycieczka klasowa
Cel (jeden)
Kraków — 120 zł
Zakopane — 150 zł
Gdańsk — 180 zł
Wrocław — 130 zł
Atrakcje (dowolne)
Muzeum — 15 zł
Kino — 25 zł
Basen — 20 zł
Park linowy — 45 zł
Kręgle — 30 zł
Wybierz cel wycieczki
Wyczyść
Zatwierdź
start — nic nie wybrano
→Zakopane,
Basen,
Kręgle
8:12
Wycieczka klasowa
Cel (jeden)
Kraków — 120 zł
Zakopane — 150 zł
Gdańsk — 180 zł
Wrocław — 130 zł
Atrakcje (dowolne)
Muzeum — 15 zł
Kino — 25 zł
Basen — 20 zł
Park linowy — 45 zł
Kręgle — 30 zł
Zakopane + atrakcje: 2 · razem 200 zł
Wyczyść
Zatwierdź
podsumowanie liczone na bieżąco
8:12
Wycieczka klasowa
Cel (jeden)
Kraków — 120 zł
Zakopane — 150 zł
Gdańsk — 180 zł
Wrocław — 130 zł
Atrakcje (dowolne)
Muzeum — 15 zł
Kino — 25 zł
Basen — 20 zł
Park linowy — 45 zł
Kręgle — 30 zł
Zakopane + atrakcje: 2 · razem 200 zł
Wyczyść
Zatwierdź
Wycieczka: Zakopane
Atrakcje: Basen, Kręgle

Koszt na osobę: 200 zł
OK
Zatwierdź — okno z planem

Przygotowanie projektu

  1. Nowy projekt Empty Views Activity, nazwa Wycieczka, Kotlin, API 24.
  2. Podmień activity_main.xml i MainActivity.kt.
res/layout/activity_main.xml — pełny plik
<?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>
MainActivity.kt — pełny plik
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 IntArray o 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.
  • mapIndexed zamienia 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 jak pokazListe() w lekcji 26.

Sprawdź trzy rzeczy

  1. Usuń z XML android:choiceMode="multipleChoice". Kwadraty nadal są rysowane, ale kliknięcie ich nie zaznacza — sam układ wiersza nie wystarczy.
  2. W wyczysc() zakomentuj oba notifyDataSetChanged(). Napis pokaże „Wybierz cel wycieczki”, a znaczki zostaną na ekranie.
  3. Kliknij dwa razy ten sam cel. Pozostaje zaznaczony — w trybie singleChoice nie da się odznaczyć wiersza kliknięciem.
ELEMENTY WBUDOWANE

Zestawienie elementów

ElementTypCo robi
android.R.layout.simple_list_item_single_choiceukład wierszaTekst i kółko po prawej.
android.R.layout.simple_list_item_multiple_choiceukład wierszaTekst i kwadrat po prawej.
android:choiceMode="singleChoice" | "multipleChoice"atrybut XMLTryb wyboru listy. Z kodu: lista.choiceMode = ListView.CHOICE_MODE_SINGLE.
lista.checkedItemPositionIntNumer wybranego wiersza (singleChoice) albo ListView.INVALID_POSITION.
lista.isItemChecked(nr)BooleanCzy wiersz jest zaznaczony.
lista.checkedItemCountIntLiczba zaznaczonych wierszy.
lista.setItemChecked(nr, true/false)metodaZaznacza lub odznacza wiersz z kodu.
lista.clearChoices()metodaOdznacza wszystko w pamięci listy; potem notifyDataSetChanged().
tablica.mapIndexed { i, e -> … }ListNowa lista z każdego elementu i jego numeru.
lista.sumOf { … }IntSuma wartości policzonych dla każdego elementu.
CZĘSTE BŁĘDY

Zanim utkniesz

Zapis / objawProblem
kółka widać, ale kliknięcie ich nie zaznaczaBrak android:choiceMode w XML listy.
zaznacza się, ale nie widać znaczkaAdapter z simple_list_item_1 — ten układ nie rysuje kółka ani kwadratu.
cele[lista.checkedItemPosition] → awariaNic nie wybrano, numer to -1. Najpierw sprawdź INVALID_POSITION.
checkedItemPosition przy multipleChoiceTa 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 ekranemNie przechowuj wyboru osobno — zawsze pytaj listę (isItemChecked).
dwie listy w ScrollView pokazują po jednym wierszuListView sama się przewija i nie wkłada się jej do ScrollView. Podziel ekran wagami, jak w przykładzie.
ZADANIA

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.

ZAD 1Wybór klasy★☆☆

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.
ZAD 2Lista obecności★☆☆

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.
ZAD 3Nieobecni do dziennika★★☆

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").
ZAD 4Zamówienie w barze★★☆

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(), gdy ceny.size >= 3.
ZAD 5Limit wyboru★★☆

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śli checkedItemCount > 3, to lista.setItemChecked(position, false) — cofa właśnie kliknięty wiersz.
ZAD 6Quiz z jedną odpowiedzią★★★

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().
PODSUMOWANIE

Co trzeba zapamiętać

  • Lista wyboru = układ wiersza z wyborem w adapterze i android:choiceMode w XML.
  • Jedna pozycja: checkedItemPosition (uwaga na INVALID_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.

Postęp zapisuje się w tej przeglądarce.