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.
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.
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.
<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>
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”.
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ść:
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.
Reakcja na zmianę
Listener ustawia się na grupie, a nie na każdym przycisku. Android przekazuje grupę i identyfikator nowo zaznaczonego przycisku:
rgRozmiar.setOnCheckedChangeListener { _, idZaznaczonego ->
if (idZaznaczonego == R.id.rbL) {
tvInfo.text = "Rozmiar L — największa pizza"
}
}
Zaznaczanie z kodu
| Zapis | Efekt |
|---|---|
rg.check(R.id.rbM) | Zaznacza wskazany przycisk (i odznacza inne w grupie). |
rg.clearCheck() | Odznacza wszystko; checkedRadioButtonId = -1. |
rbM.isChecked | Stan pojedynczego przycisku — działa jak w CheckBox. |
rbL.isEnabled = false | Wyłą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().
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.
Przygotowanie projektu
- Nowy projekt Empty Views Activity, nazwa
Kino, 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="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>
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
checkedRadioButtonIdto-1.przelicz()wtedy prosi o wybór, wyłącza zakup i kończy się przezreturn. whenzwraca od razu dwie wartości jakoPair(para). Zapisval (seans, cenaSeansu) = …rozkłada parę na dwie zmienne — nazwę do wyświetlenia i liczbę do obliczeń.- W pierwszym
whengałąźelseoznacza IMAX — wiemy, że coś jest zaznaczone, bo-1wykluczyliś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ęcrbUlgowysię włącza), a bilet wraca na normalny.
Sprawdź trzy rzeczy
- Wybierz 3D i ulgowy, potem przełącz na IMAX. Bilet sam zmieni się na normalny, a ulgowy będzie szary.
- Spróbuj odznaczyć zaznaczony seans, klikając go drugi raz. Nie da się — tylko Od nowa czyści wybór.
- Przenieś jeden
RadioButtonpozaRadioGroup(pod nią). Można go będzie zaznaczyć razem z opcją z grupy — grupa pilnuje tylko swoich dzieci.
Zestawienie elementów
| Element | Typ | Co robi |
|---|---|---|
<RadioGroup> | układ | Grupa — najwyżej jedna opcja zaznaczona. |
<RadioButton> | kontrolka | Przycisk opcji; musi mieć id. |
android:checked="true" | atrybut | Opcja zaznaczona na starcie. |
rg.checkedRadioButtonId | Int | id zaznaczonego przycisku albo -1. |
rg.setOnCheckedChangeListener { _, id -> } | metoda | Kod po zmianie wyboru w grupie. |
rg.check(R.id.…) | metoda | Zaznacza wskazaną opcję. |
rg.clearCheck() | metoda | Odznacza wszystko. |
when (x) { a -> …; else -> … } | wyrażenie | Wybór wartości według przypadków. |
Pair(a, b), val (x, y) = para | para | Dwie wartości razem i ich rozłożenie. |
Zanim utkniesz
| Zapis / objaw | Problem |
|---|---|
| można zaznaczyć kilka opcji naraz | Przyciski nie są w RadioGroup albo są w różnych grupach. |
RadioButton bez android:id | Grupa 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 wyboru | Awaria 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 RadioGroup | Grupa go nie pilnuje — dalej działa jak pole wyboru. |
listener na każdym RadioButton | Działa, ale wywołuje się dwa razy na zmianę (odznaczony i zaznaczony). Ustaw jeden listener na grupie. |
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.
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" … }, potemsetBackgroundColor(Color.parseColor(kolor)).
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.
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 = "…".
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
DoubleprzeztoDoubleOrNull(). Znak działania i wynik wybierz w jednymwhenzwracającymPair.
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 funkcjapokazPlan()wywoływana z listenerów trzech grup. Ustaw domyślne zaznaczenie w każdej grupie.
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 wintArrayOf(…). Numer zaznaczonej odpowiedzi torg.indexOfChild(findViewById(rg.checkedRadioButtonId)).
Co trzeba zapamiętać
RadioButtonwRadioGroup— wybór dokładnie jednej opcji; każdy przycisk maid.- Wybór odczytujesz z grupy:
checkedRadioButtonId;-1oznacza brak wyboru. whenzamienia identyfikator na wartość — pamiętaj oelse.- Listener ustawiaj na grupie;
check()iclearCheck()też go uruchamiają. - Zwykle zaznacz jedną opcję na starcie — użytkownik nie odznaczy jej ponownym kliknięciem.
Dokumentacja: Przyciski opcji, when w Kotlinie.