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

school-it.pl // android + kotlin // rozdział 3 // lekcja 10

RadioButton i RadioGroup — jedna opcja z kilku

Rozmiar koszulki, sposób dostawy, poziom trudności — tu wybiera się dokładnie jedną opcję. Służą do tego przyciski opcji RadioButton zebrane w grupę RadioGroup: zaznaczenie jednego automatycznie odznacza pozostałe. W tej lekcji zbudujesz kasę kina, w której rodzaj seansu i rodzaj biletu wybiera się z dwóch niezależnych grup.

Kotlin XML RadioButton RadioGroup checkedRadioButtonId when 1 × 45 min
CEL LEKCJI

Czego się dziś nauczysz

  • Zbudujesz grupę przycisków opcji w pionie i w poziomie.
  • Odczytasz, która opcja w grupie jest zaznaczona.
  • Przypiszesz każdej opcji wartość instrukcją when.
  • Zareagujesz na zmianę wyboru w grupie.
  • Zaznaczysz opcję i wyczyścisz grupę z kodu.
TEORIA

Grupa przycisków opcji

RadioGroup to układ podobny do LinearLayout (ma orientation), który dodatkowo pilnuje, żeby zaznaczony był najwyżej jeden przycisk w środku. Każdy RadioButton musi mieć id — po nim rozpoznasz wybór.

grupa w poziomie, pierwsza opcja zaznaczona
<RadioGroup
    android:id="@+id/rgRozmiar"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal">

    <RadioButton android:id="@+id/rbS"
        android:layout_width="wrap_content" android:layout_height="wrap_content"
        android:checked="true" android:text="S" />
    <RadioButton android:id="@+id/rbM"
        android:layout_width="wrap_content" android:layout_height="wrap_content"
        android:text="M" />
    <RadioButton android:id="@+id/rbL"
        android:layout_width="wrap_content" android:layout_height="wrap_content"
        android:text="L" />
</RadioGroup>
12:30
orientation=”vertical”
Kurier
Paczkomat
Odbiór osobisty
orientation=”horizontal”
S
M
L
dwie grupy — w każdej jedna opcja

CheckBox czy RadioButton?

Pytanie kontrolne: czy można wybrać dwie opcje naraz? Dodatki do pizzy — tak, więc CheckBox (lekcja 09). Rozmiar pizzy — nie, więc RadioButton. Okrągły kształt mówi użytkownikowi „tylko jedno”, a kwadratowy — „ile chcesz”.

TEORIA

Który jest zaznaczony?

Nie trzeba sprawdzać każdego przycisku po kolei. Grupa zna odpowiedź: checkedRadioButtonId to identyfikator zaznaczonego przycisku, a gdy nic nie jest zaznaczone — -1. Instrukcja when zamienia identyfikator na potrzebną wartość:

identyfikator → wartość
val rgRozmiar = findViewById<RadioGroup>(R.id.rgRozmiar)

val cena = when (rgRozmiar.checkedRadioButtonId) {
    R.id.rbS -> 25
    R.id.rbM -> 32
    R.id.rbL -> 39
    else -> 0                  // -1: nic nie zaznaczono
}

when porównuje wartość w nawiasie z kolejnymi gałęziami i zwraca wartość z pierwszej pasującej. Gałąź else jest obowiązkowa, gdy when zwraca wynik.

Gdy potrzebujesz napisu z zaznaczonego przycisku, znajdź go po tym identyfikatorze: findViewById<RadioButton>(rg.checkedRadioButtonId).text — ale tylko wtedy, gdy identyfikator nie jest -1.

TEORIA

Reakcja na zmianę

Listener ustawia się na grupie, a nie na każdym przycisku. Android przekazuje grupę i identyfikator nowo zaznaczonego przycisku:

listener grupy
rgRozmiar.setOnCheckedChangeListener { _, idZaznaczonego ->
    if (idZaznaczonego == R.id.rbL) {
        tvInfo.text = "Rozmiar L — największa pizza"
    }
}
TEORIA

Zaznaczanie z kodu

ZapisEfekt
rg.check(R.id.rbM)Zaznacza wskazany przycisk (i odznacza inne w grupie).
rg.clearCheck()Odznacza wszystko; checkedRadioButtonId = -1.
rbM.isCheckedStan pojedynczego przycisku — działa jak w CheckBox.
rbL.isEnabled = falseWyłącza jedną opcję, np. gdy rozmiar jest niedostępny.

Użytkownik nie może odznaczyć przycisku opcji, klikając go ponownie — jedyna droga to zaznaczenie innego. Dlatego zwykle jedna opcja jest zaznaczona na starcie, a do czyszczenia dodaje się przycisk z clearCheck().

PRZYKŁAD

Kasa kina

Dwie grupy: rodzaj seansu (2D 22 zł, 3D 28 zł, IMAX 35 zł) i rodzaj biletu (normalny, ulgowy −30%, senior −40%). Rodzaj biletu jest zaznaczony od startu, seansu — nie. Cena pojawia się dopiero po wyborze seansu i zmienia się przy każdej zmianie. Na seanse IMAX nie ma biletów ulgowych — przy IMAX ta opcja się wyłącza.

17:40
Kino Pracownia · bilet
Seans
2D — 22 zł
3D — 28 zł
IMAX — 35 zł
Bilet
normalny
ulgowy
senior
Wybierz rodzaj seansu
Od nowa
Kup bilet
start: seans niewybrany
17:41
Kino Pracownia · bilet
Seans
2D — 22 zł
3D — 28 zł
IMAX — 35 zł
Bilet
normalny
ulgowy
senior
3D, ulgowy: 19,60 zł
Od nowa
Kup bilet
3D + ulgowy: 28 zł − 30%
17:41
Kino Pracownia · bilet
Seans
2D — 22 zł
3D — 28 zł
IMAX — 35 zł
Bilet
normalny
ulgowy
senior
IMAX, normalny: 35,00 zł
Od nowa
Kup bilet
IMAX: ulgowy wyłączony, wybór przeszedł na normalny

Przygotowanie projektu

  1. Nowy projekt Empty Views Activity, nazwa Kino, 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="Kino Pracownia · bilet"
        android:textSize="22sp"
        android:textStyle="bold" />

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="12dp"
        android:text="Seans"
        android:textStyle="bold" />

    <!-- grupa 1: pionowa, nic nie zaznaczone na starcie -->
    <RadioGroup
        android:id="@+id/rgSeans"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">

        <RadioButton android:id="@+id/rb2d"
            android:layout_width="wrap_content" android:layout_height="wrap_content"
            android:text="2D — 22 zł" />
        <RadioButton android:id="@+id/rb3d"
            android:layout_width="wrap_content" android:layout_height="wrap_content"
            android:text="3D — 28 zł" />
        <RadioButton android:id="@+id/rbImax"
            android:layout_width="wrap_content" android:layout_height="wrap_content"
            android:text="IMAX — 35 zł" />
    </RadioGroup>

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="12dp"
        android:text="Bilet"
        android:textStyle="bold" />

    <!-- grupa 2: pozioma, „normalny” zaznaczony na starcie -->
    <RadioGroup
        android:id="@+id/rgBilet"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <RadioButton android:id="@+id/rbNormalny"
            android:layout_width="wrap_content" android:layout_height="wrap_content"
            android:checked="true" android:text="normalny" />
        <RadioButton android:id="@+id/rbUlgowy"
            android:layout_width="wrap_content" android:layout_height="wrap_content"
            android:text="ulgowy" />
        <RadioButton android:id="@+id/rbSenior"
            android:layout_width="wrap_content" android:layout_height="wrap_content"
            android:text="senior" />
    </RadioGroup>

    <TextView
        android:id="@+id/tvCena"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:textSize="22sp"
        android:textStyle="bold" />

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

        <Button
            android:id="@+id/btnOdNowa"
            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="Od nowa" />

        <Button
            android:id="@+id/btnKup"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="Kup bilet" />
    </LinearLayout>

    <TextView
        android:id="@+id/tvPotwierdzenie"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="8dp"
        android:textColor="#2E7D32" />

</LinearLayout>
MainActivity.kt — pełny plik
package pl.schoolit.kino

import android.os.Bundle
import android.widget.Button
import android.widget.RadioButton
import android.widget.RadioGroup
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val rgSeans = findViewById<RadioGroup>(R.id.rgSeans)
        val rgBilet = findViewById<RadioGroup>(R.id.rgBilet)
        val rbUlgowy = findViewById<RadioButton>(R.id.rbUlgowy)
        val tvCena = findViewById<TextView>(R.id.tvCena)
        val tvPotwierdzenie = findViewById<TextView>(R.id.tvPotwierdzenie)
        val btnKup = findViewById<Button>(R.id.btnKup)
        val btnOdNowa = findViewById<Button>(R.id.btnOdNowa)

        fun przelicz() {
            tvPotwierdzenie.text = ""

            // nic nie wybrano w grupie seansów: -1
            if (rgSeans.checkedRadioButtonId == -1) {
                tvCena.text = "Wybierz rodzaj seansu"
                btnKup.isEnabled = false
                return
            }

            val (seans, cenaSeansu) = when (rgSeans.checkedRadioButtonId) {
                R.id.rb2d -> Pair("2D", 22.0)
                R.id.rb3d -> Pair("3D", 28.0)
                else -> Pair("IMAX", 35.0)
            }

            val (bilet, znizka) = when (rgBilet.checkedRadioButtonId) {
                R.id.rbUlgowy -> Pair("ulgowy", 30)
                R.id.rbSenior -> Pair("senior", 40)
                else -> Pair("normalny", 0)
            }

            val cena = cenaSeansu * (100 - znizka) / 100
            tvCena.text = "$seans, $bilet: ${String.format("%.2f", cena)} zł"
            btnKup.isEnabled = true
        }

        przelicz()

        rgSeans.setOnCheckedChangeListener { _, idZaznaczonego ->
            // na IMAX nie ma biletów ulgowych
            if (idZaznaczonego == R.id.rbImax) {
                if (rbUlgowy.isChecked) {
                    rgBilet.check(R.id.rbNormalny)
                }
                rbUlgowy.isEnabled = false
            } else {
                rbUlgowy.isEnabled = true
            }
            przelicz()
        }

        rgBilet.setOnCheckedChangeListener { _, _ ->
            przelicz()
        }

        btnOdNowa.setOnClickListener {
            rgSeans.clearCheck()
            rgBilet.check(R.id.rbNormalny)
        }

        btnKup.setOnClickListener {
            tvPotwierdzenie.text = "Sprzedano bilet: ${tvCena.text}"
        }
    }
}

Co dzieje się po kolei

  • Pierwsza grupa nie ma zaznaczenia na starcie, więc checkedRadioButtonId to -1. przelicz() wtedy prosi o wybór, wyłącza zakup i kończy się przez return.
  • when zwraca od razu dwie wartości jako Pair (para). Zapis val (seans, cenaSeansu) = … rozkłada parę na dwie zmienne — nazwę do wyświetlenia i liczbę do obliczeń.
  • W pierwszym when gałąź else oznacza IMAX — wiemy, że coś jest zaznaczone, bo -1 wykluczyliśmy wcześniej.
  • Listener grupy seansów obsługuje regułę „IMAX bez ulgowych”: przełącza bilet na normalny (gdy był ulgowy) i wyłącza opcję ulgową.
  • rgBilet.check(…) z kodu też uruchamia listener grupy biletów — cena przelicza się bez dodatkowego wywołania.
  • Od nowa: clearCheck() czyści seans (listener dostaje identyfikator -1, więc rbUlgowy się włącza), a bilet wraca na normalny.

Sprawdź trzy rzeczy

  1. Wybierz 3D i ulgowy, potem przełącz na IMAX. Bilet sam zmieni się na normalny, a ulgowy będzie szary.
  2. Spróbuj odznaczyć zaznaczony seans, klikając go drugi raz. Nie da się — tylko Od nowa czyści wybór.
  3. Przenieś jeden RadioButton poza RadioGroup (pod nią). Można go będzie zaznaczyć razem z opcją z grupy — grupa pilnuje tylko swoich dzieci.
ELEMENTY WBUDOWANE

Zestawienie elementów

ElementTypCo robi
<RadioGroup>układGrupa — najwyżej jedna opcja zaznaczona.
<RadioButton>kontrolkaPrzycisk opcji; musi mieć id.
android:checked="true"atrybutOpcja zaznaczona na starcie.
rg.checkedRadioButtonIdIntid zaznaczonego przycisku albo -1.
rg.setOnCheckedChangeListener { _, id -> }metodaKod po zmianie wyboru w grupie.
rg.check(R.id.…)metodaZaznacza wskazaną opcję.
rg.clearCheck()metodaOdznacza wszystko.
when (x) { a -> …; else -> … }wyrażenieWybór wartości według przypadków.
Pair(a, b), val (x, y) = paraparaDwie wartości razem i ich rozłożenie.
CZĘSTE BŁĘDY

Zanim utkniesz

Zapis / objawProblem
można zaznaczyć kilka opcji narazPrzyciski nie są w RadioGroup albo są w różnych grupach.
RadioButton bez android:idGrupa nada mu numer sama, ale w kodzie nie da się go porównać z R.id.… — wyboru nie rozpoznasz w when.
findViewById<RadioButton>(rg.checkedRadioButtonId).text przy braku wyboruAwaria NullPointerException — identyfikator -1 nie wskazuje żadnej kontrolki. Najpierw sprawdź -1.
when bez else zwracające wartośćBłąd kompilacji: ’when’ expression must be exhaustive.
CheckBox w RadioGroupGrupa go nie pilnuje — dalej działa jak pole wyboru.
listener na każdym RadioButtonDziała, ale wywołuje się dwa razy na zmianę (odznaczony i zaznaczony). Ustaw jeden listener na grupie.
ZADANIA

Zadania

Każde zadanie wykonaj w nowym projekcie Empty Views Activity. Cały kod pisz w onCreate. Wskazówkę otwórz dopiero wtedy, gdy utkniesz.

ZAD 1Kolor tła★☆☆

Grupa pięciu przycisków opcji z nazwami kolorów. Wybór od razu zmienia tło karty (prostokątny TextView pod grupą) i jej napis na nazwę koloru.

pokaż wskazówkę
  • when (id) { R.id.rbCzerwony -> "#E53935" … }, potem setBackgroundColor(Color.parseColor(kolor)).
ZAD 2Jedno pytanie quizu★☆☆

Pytanie „Który język jest używany do aplikacji na Androida w tym kursie?” i cztery odpowiedzi w grupie. Przycisk Sprawdź pokazuje „Dobrze!” na zielono albo „Źle — poprawna odpowiedź: Kotlin” na czerwono i wyłącza grupę (wszystkie przyciski opcji). Brak wyboru — komunikat „Wybierz odpowiedź”.

pokaż wskazówkę
  • Wyłączenie grupy nie wyłącza dzieci. Wyłącz w pętli: for (i in 0 until rg.childCount) rg.getChildAt(i).isEnabled = false.
ZAD 3Kalkulator dostawy★★☆

Pole z wartością zamówienia i grupa sposobów dostawy: kurier 15 zł, paczkomat 10 zł, odbiór osobisty 0 zł. Przy zamówieniu od 200 zł kurier i paczkomat są darmowe — wtedy ich napisy zmieniają się na „kurier — 0 zł (gratis)”. Na dole łączna kwota.

pokaż wskazówkę
  • Przelicz po zmianie grupy i po kliknięciu przycisku Przelicz. Napis opcji zmienisz przez rbKurier.text = "…".
ZAD 4Kalkulator działań★★☆

Dwa pola liczbowe i pozioma grupa działań: + − × ÷. Wynik przelicza się przy zmianie działania. Dzielenie przez zero — błąd przy drugim polu. Pod wynikiem pełny zapis: „12 ÷ 5 = 2,4”.

pokaż wskazówkę
  • Liczby jako Double przez toDoubleOrNull(). Znak działania i wynik wybierz w jednym when zwracającym Pair.
ZAD 5Plan treningowy★★☆

Trzy grupy: poziom (początkujący / średni / zaawansowany), cel (kondycja / siła / gibkość), czas (15 / 30 / 45 min). Na dole plan, np. „Średni · siła · 30 min: 3 serie po 12 powtórzeń, przerwy 60 s”. Liczba serii zależy od poziomu, powtórzenia od celu, a przerwy od czasu.

pokaż wskazówkę
  • Trzy osobne when, jedna funkcja pokazPlan() wywoływana z listenerów trzech grup. Ustaw domyślne zaznaczenie w każdej grupie.
ZAD 6Quiz z pięcioma pytaniami★★★

Jedno pytanie na ekranie, cztery odpowiedzi w grupie, przycisk Dalej (aktywny dopiero po wybraniu odpowiedzi). Pytania i odpowiedzi w tablicach. Przy przejściu do następnego pytania napisy przycisków opcji się zmieniają, a grupa czyści. Na końcu: „Wynik: 4/5” i lista pytań z błędną odpowiedzią.

pokaż wskazówkę
  • Odpowiedzi trzymaj w tablicy tablic: arrayOf(arrayOf("a", "b", "c", "d"), …), a numer poprawnej w intArrayOf(…). Numer zaznaczonej odpowiedzi to rg.indexOfChild(findViewById(rg.checkedRadioButtonId)).
PODSUMOWANIE

Co trzeba zapamiętać

  • RadioButton w RadioGroup — wybór dokładnie jednej opcji; każdy przycisk ma id.
  • Wybór odczytujesz z grupy: checkedRadioButtonId; -1 oznacza brak wyboru.
  • when zamienia identyfikator na wartość — pamiętaj o else.
  • Listener ustawiaj na grupie; check() i clearCheck() też go uruchamiają.
  • Zwykle zaznacz jedną opcję na starcie — użytkownik nie odznaczy jej ponownym kliknięciem.

Dokumentacja: Przyciski opcji, when w Kotlinie.

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