005 ‐ Objednávka v2 ‐ více aktivit, constraint layout, scrollview - zizanek/-github-tz-pma-2026-priprava GitHub Wiki

Budeme pokračovat s objednávkou... ale víceméně začneme znovu....

MyApp005ObjednavkaV2

Tvorba ConstraintLayoutu od nuly: Krok za krokem

Při tvorbě moderního uživatelského rozhraní v Androidu je doporučeno využívat ConstraintLayout. Následující návod popisuje, jak krok za krokem vystavět obrazovku z prázdného rozvržení a jak správně definovat vazby (constraints) mezi jednotlivými prvky.


1. Příprava projektu (Zkopírování zdrojů)

Než se začne se samotnou tvorbou vizuální části, je nutné do nového projektu přenést zdroje (resources) z předchozích úkolů:

  • Obrázky: Soubory (např. fotografie kol) jsou zkopírovány do složky res/drawable.
  • Texty a barvy: Je přenesen obsah souborů strings.xml a colors.xml.
  • Logika aplikace: Je připraven kód s posluchači událostí (setOnClickListener) z předchozího MainActivity.kt.

2. Postup skládání prvků na obrazovku

Při vkládání komponent na plochu se v Android Studiu (v záložce Design) uplatňují následující pravidla a postupy.

A. Chybějící vazba (Červená chyba)

  • Situace: Na prázdnou plochu je přetažen první prvek (např. TextView pro nadpis).
  • Problém: Prvek sice na ploše zůstane, ale ve stromu komponent (Component Tree) je signalizována červená chyba.
  • Vysvětlení: Systém nezná pozici prvku. Pokud není definována alespoň jedna vodorovná a jedna svislá vazba, prvek je při spuštění na reálném zařízení automaticky přesunut do levého horního rohu (na souřadnice [0,0]).

B. Vytvoření prvních vazeb (Kotevní body a centrování)

  • Postup: Kotevní body (kolečka) na hranách prvku jsou tažením myši připojeny k hornímu, levému a pravému okraji obrazovky.
  • Výsledek: V zobrazení kódu (Split) jsou automaticky vygenerovány atributy jako app:layout_constraintTop_toTopOf="parent". Nastavením protichůdných vazeb (tahem zleva i zprava k okrajům displeje) je dosaženo automatického vycentrování prvku.

C. Zlaté pravidlo ConstraintLayoutu: Použití 0dp

  • Postup: Na plochu je vložen obrázek kola (ImageView) a je ukotven k okrajům.
  • Zásada: Pokud je vyžadováno dynamické roztažení prvku mezi dvěma kotevními body, nesmí být použita hodnota match_parent. Namísto toho je šířka (nebo výška) nastavena na hodnotu 0dp (v grafickém editoru označováno jako Match Constraint). Prvek se následně chová elasticky a přesně vyplní vymezený prostor.

D. Vazby mezi prvky navzájem (Řazení pod sebe)

  • Postup: Pod nadpis je vložen prvek RadioGroup pro výběr kola.
  • Princip: Horní hrana bloku s přepínači není vázána na okraj obrazovky, ale na spodní hranu nadpisu (v kódu zapsáno jako app:layout_constraintTop_toBottomOf="@id/tvOrderTitle"). Tím je zajištěno, že při změně velikosti nebo posunutí nadpisu jsou přepínače automaticky posunuty spolu s ním.

E. Plochá struktura (Umístění prvků vedle sebe)

  • Postup: Obrázek kola je umístěn vizuálně napravo od přepínačů.
  • Princip: Tohoto rozložení je dosaženo pouhým definováním levé vazby obrázku na pravou hranu prvku RadioGroup (atribut app:layout_constraintStart_toEndOf="@id/rgBikes").
  • Výhoda: Na rozdíl od staršího LinearLayoutu, kde by bylo nutné vytvářet další obalující kontejnery, je v ConstraintLayoutu veškeré rozložení řešeno v jedné rovině (tzv. plochá hierarchie). To eliminuje složité vnořování a výrazně zrychluje vykreslování uživatelského rozhraní.

Oživení formuláře: Výpočet ceny, odeslání dat (Intent), příjem dat v jiné aktivitě

Když je vizuální část (Layout) hotová, je potřeba obrazovce vdechnout život pomocí kódu v MainActivity.kt. Cílem této části je zajistit dvě věci:

  1. Dynamický výpočet ceny: Kdykoliv uživatel přepne model kola nebo zaklikne příplatek, cena v modrém poli se musí okamžitě přepočítat.
  2. Odeslání objednávky: Po kliknutí na tlačítko "Objednat" se vybraná data zabalí a odešlou do nové obrazovky (aktivity) pomocí tzv. Intentu.

1. Průběžný výpočet ceny

Aby nebylo nutné psát výpočet ceny pro každé tlačítko zvlášť, je nejlepší vytvořit si univerzální metodu updatePrice(). Ta se jednoduše zavolá pokaždé, když uživatel na cokoliv klikne.

Metoda pro výpočet (vložit do třídy MainActivity, mimo onCreate)

Tato metoda zkontroluje, co je aktuálně vybráno, sečte částky a vypíše výsledek do textového pole tvTotalPrice.

private fun updatePrice() {
    var totalPrice = 0

    // 1. Zjištění základní ceny podle vybraného kola
    totalPrice += when (binding.rgBikes.checkedRadioButtonId) {
        binding.rbBike1.id -> 95000 // Oiz M10
        binding.rbBike2.id -> 115000 // Oiz M20
        binding.rbBike3.id -> 135000 // Oiz M30
        else -> 0
    }

    // 2. Přičtení příplatkové výbavy (každý prvek např. +5000 Kč)
    if (binding.cbFork.isChecked) totalPrice += 5000
    if (binding.cbSaddle.isChecked) totalPrice += 5000
    if (binding.cbHandleBar.isChecked) totalPrice += 5000

    // 3. Zobrazení výsledku v modrém poli
    binding.tvTotalPrice.text = "Celkem: $totalPrice Kč"
}

Zprovoznění ViewBindingu a posluchačů (vložit do onCreate)

V metodě onCreate se nejprve nastaví View Binding a následně se přidají posluchače na všechny interaktivní prvky.

private lateinit var binding: ActivityMainBinding

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    binding = ActivityMainBinding.inflate(layoutInflater)
    setContentView(binding.root)

    // Zavoláme výpočet hned po startu, aby se zobrazila výchozí cena (jinak by tam bylo 0 Kč)
    updatePrice()

    // Nastavení posluchačů změn pro RadioGroup (výběr kola)
    binding.rgBikes.setOnCheckedChangeListener { _, _ -> updatePrice() }

    // Nastavení posluchačů kliknutí pro CheckBoxy (příplatky)
    binding.cbFork.setOnClickListener { updatePrice() }
    binding.cbSaddle.setOnClickListener { updatePrice() }
    binding.cbHandleBar.setOnClickListener { updatePrice() }
    
    // ... Logika pro tlačítko Objednat následuje v dalším kroku
}

2. Vytvoření nové aktivity (SummaryActivity)

Abychom mohli odeslaná data z první obrazovky někde zobrazit, musíme vytvořit novou aktivitu (novou obrazovku). Každá vizuální aktivita v Androidu se skládá ze dvou částí, které musíme vytvořit:

  1. Logika: Kotlin třída (např. SummaryActivity.kt)
  2. Vzhled: XML layout (např. activity_summary.xml)

Průvodce v Android Studiu naštěstí umí vygenerovat obě tyto části současně.


Postup vytvoření

  1. V levém panelu Project si rozbalte složku app -> java a najděte název vašeho balíčku (např. com.example.myapp005objednavkav2).
  2. Klikněte na tento balíček pravým tlačítkem myši.
  3. V kontextové nabídce zvolte New -> Activity.
  4. Z nabídky šablon vyberte Empty Views Activity.

    Důležité upozornění pro novější verze Android Studia: Vybírejte vždy variantu obsahující slovo "Views". Volba pouhé "Empty Activity" by v moderních verzích vytvořila obrazovku pro technologii Jetpack Compose (bez XML souboru).

  5. Objeví se dialogové okno k nastavení nové aktivity. Vyplňte jej takto:
    • Activity Name: SummaryActivity (Název naší Kotlin třídy)
    • Layout Name: activity_summary (Doplní se automaticky, název XML souboru)
    • Source Language: Kotlin
  6. Klikněte na tlačítko Finish.

Co se stalo na pozadí?

Android Studio právě automaticky vykonalo tři nezbytné kroky:

  1. Vytvořilo soubor SummaryActivity.kt s prázdnou metodou onCreate.
  2. Ve složce res/layout vytvořilo prázdný soubor activity_summary.xml.
  3. Zapsalo novou aktivitu do AndroidManifest.xml. Tento krok je kritický – pokud by systém o aktivitě v manifestu nevěděl, při kliknutí na tlačítko "Objednat" by aplikace okamžitě spadla.

3. Odeslání dat do druhé aktivity (Intent)

Tlačítko btnOrder již nebude vypisovat text na stejnou obrazovku. Místo toho se vytvoří Intent (úmysl), do kterého se data vloží přes metodu putExtra a následně se spustí nová aktivita.

Tento kód se vloží zpět do metody onCreate (pod výše uvedené posluchače):

    binding.btnOrder.setOnClickListener {
        // 1. Zjištění názvu vybraného kola
        val bikeName = when (binding.rgBikes.checkedRadioButtonId) {
            binding.rbBike1.id -> binding.rbBike1.text.toString()
            binding.rbBike2.id -> binding.rbBike2.text.toString()
            binding.rbBike3.id -> binding.rbBike3.text.toString()
            else -> "Neznámé kolo"
        }

        // 2. Extrakce výsledné ceny (pro zjednodušení bereme rovnou hotový text z TextView)
        val currentPriceText = binding.tvTotalPrice.text.toString()

        // 3. Vytvoření Intentu směřujícího na novou aktivitu (SummaryActivity)
        val intent = Intent(this, SummaryActivity::class.java)

        // 4. Přibalení dat jako klíč-hodnota
        intent.putExtra("EXTRA_BIKE_NAME", bikeName)
        intent.putExtra("EXTRA_TOTAL_PRICE", currentPriceText)
        
        // Přidání informace o nadstandardní výbavě (boolean hodnoty)
        intent.putExtra("EXTRA_HAS_FORK", binding.cbFork.isChecked)
        intent.putExtra("EXTRA_HAS_SADDLE", binding.cbSaddle.isChecked)
        intent.putExtra("EXTRA_HAS_HANDLEBAR", binding.cbHandleBar.isChecked)

        // 5. Spuštění druhé aktivity
        startActivity(intent)
    }

Nyní můžeme přejít do souboru activity_summary.xml a začít tvořit vzhled druhé obrazovky.

4. Vzhled druhé obrazovky a použití ScrollView (activity_summary.xml)

Na telefonech se velmi často stává, že se veškerý obsah obrazovky (například dlouhá rekapitulace objednávky, dlouhý článek nebo obchodní podmínky) nevejde na displej. Pokud bychom použili pouze běžný ConstraintLayout, text by byl na konci displeje nekompromisně oříznut a uživatel by nikdy neviděl tlačítko umístěné dole.

Tento problém řeší komponenta ScrollView. Vytvoří na obrazovce "rolovací okno", které uživateli umožní posouvat obsah prstem nahoru a dolů.

Zlaté pravidlo ScrollView:
ScrollView smí obsahovat pouze jednoho přímého potomka. Nelze do něj vložit přímo pět různých textů. Vždy do něj musíte vložit jeden hlavní "obalový" kontejner (např. ConstraintLayout) a až do tohoto kontejneru vkládat jednotlivé vizuální prvky.

Pro demonstraci scrollování v našem projektu jsme pod rekapitulaci ceny přidali záměrně dlouhý text fiktivních obchodních podmínek. Ten vytlačí tlačítko "Zpět" mimo viditelnou oblast displeje a donutí vás k posouvání.

Následující kód vložte do souboru activity_summary.xml:

<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fillViewport="true"
    tools:context=".SummaryActivity">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="16dp">

        <TextView
            android:id="@+id/tvSummaryHeader"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="32dp"
            android:text="Rekapitulace objednávky"
            android:textSize="28sp"
            android:textStyle="bold"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/tvSelectedBike"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginTop="42dp"
            android:text="Vybrané kolo: "
            android:textSize="20sp"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/tvSummaryHeader" />

        <TextView
            android:id="@+id/tvExtras"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginTop="24dp"
            android:text="Příplatková výbava:\n- žádná"
            android:textSize="18sp"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/tvSelectedBike" />

        <TextView
            android:id="@+id/tvFinalPrice"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginTop="42dp"
            android:background="#81D4FA"
            android:gravity="center"
            android:padding="16dp"
            android:text="Celková cena: 0 Kč"
            android:textSize="24sp"
            android:textStyle="bold"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/tvExtras" />

        <!-- NOVÁ ČÁST PRO DEMONSTRACI SCROLLOVÁNÍ -->
        <TextView
            android:id="@+id/tvTermsTitle"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="42dp"
            android:text="Obchodní podmínky"
            android:textSize="20sp"
            android:textStyle="bold"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/tvFinalPrice" />

        <TextView
            android:id="@+id/tvTermsText"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:text="1. ÚVODNÍ USTANOVENÍ\nTyto obchodní podmínky upravují vzájemná práva a povinnosti smluvních stran vzniklé v souvislosti nebo na základě kupní smlouvy uzavírané mezi prodávajícím a kupujícím prostřednictvím internetového obchodu.\n\n2. UZAVŘENÍ KUPNÍ SMLOUVY\nVeškerá prezentace zboží umístěná ve webovém rozhraní obchodu je informativního charakteru a prodávající není povinen uzavřít kupní smlouvu ohledně tohoto zboží. Kupní smlouva vzniká odesláním objednávky kupujícím a jejím přijetím prodávajícím.\n\n3. CENA ZBOŽÍ A PLATEBNÍ PODMÍNKY\nCenu zboží a případné náklady spojené s dodáním zboží dle kupní smlouvy může kupující uhradit prodávajícímu bezhotovostně převodem na účet prodávajícího, nebo dobírkou při převzetí.\n\n4. ODSTOUPENÍ OD KUPNÍ SMLOUVY\nKupující bere na vědomí, že dle ustanovení občanského zákoníku nelze mimo jiné odstoupit od kupní smlouvy o dodávce zboží, které bylo upraveno podle přání kupujícího nebo pro jeho osobu.\n\n5. PRÁVA Z VADNÉHO PLNĚNÍ\nPráva a povinnosti smluvních stran ohledně práv z vadného plnění se řídí příslušnými obecně závaznými předpisy.\n\n6. OCHRANA OSOBNÍCH ÚDAJŮ\nOchrana osobních údajů kupujícího, který je fyzickou osobou, je poskytována v souladu s nařízením GDPR. Kupující souhlasí se zpracováním těchto svých osobních údajů: jméno a příjmení, adresa bydliště, identifikační číslo, daňové identifikační číslo, adresa elektronické pošty a telefonní číslo.\n\n7. ZÁVĚREČNÁ USTANOVENÍ\nPokud vztah založený kupní smlouvou obsahuje mezinárodní (zahraniční) prvek, pak strany sjednávají, že vztah se řídí českým právem. Tímto nejsou dotčena práva spotřebitele vyplývající z obecně závazných právních předpisů."
            android:textSize="14sp"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/tvTermsTitle" />
        <!-- KONEC NOVÉ ČÁSTI -->

        <Button
            android:id="@+id/btnBack"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="32dp"
            android:layout_marginBottom="32dp"
            android:text="Zpět k úpravě"
            android:textSize="20sp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/tvTermsText" />

    </androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>

Poznámka: K čemu slouží android:fillViewport="true"? Tento atribut říká komponentě ScrollView, aby její vnitřní obsah "vyplnil minimálně celou viditelnou oblast displeje".

  • Bez tohoto nastavení: Pokud je na obrazovce málo textu, vnitřní ConstraintLayout se na výšku "scvrkne" jen na nezbytné minimum. Tlačítko zarovnané ke spodnímu okraji (toBottomOf="parent") by pak nezůstalo na dně displeje, ale "viselo" by nehezky uprostřed obrazovky hned pod textem.
  • S tímto nastavením: Vnitřní kontejner se vždy roztáhne minimálně přes celý displej. Tlačítko díky tomu zůstane bezpečně ukotvené úplně dole. Pokud je obsahu naopak moc, obrazovka se chová jako klasické rolovací plátno.

5. Přijetí dat a zobrazení rekapitulace (SummaryActivity.kt)

Když máme hotový vzhled, musíme druhé obrazovce vdechnout život. Úkolem souboru SummaryActivity.kt je zachytit data, která nám poslal Intent z první obrazovky, poskládat z nich srozumitelný text a vypsat ho do připravených textových polí.

Zároveň zde využijeme moderní přístup Edge-to-Edge, který zajistí, že se náš rolovací obsah nebude schovávat pod horní systémovou lištu (s hodinami) nebo pod spodní ovládací tlačítka telefonu.

package com.example.myapp005objednavkav2 // Zkontrolujte si svůj název balíčku

import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.example.myapp005objednavkav2.databinding.ActivitySummaryBinding


class SummaryActivity : AppCompatActivity() {

    private lateinit var binding: ActivitySummaryBinding



    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 1. Aktivace moderního roztažení aplikace přes celou obrazovku
        //enableEdgeToEdge()

        // Inicializace ViewBindingu
        binding = ActivitySummaryBinding.inflate(layoutInflater)
        setContentView(binding.root)


        // 2. Ochrana před překryvem systémovými lištami (využívá naše nové ID "main")
        ViewCompat.setOnApplyWindowInsetsListener(binding.main) { v, insets ->
            val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
            // Přidá vnitřní okraj (padding) odpovídající velikosti horní a spodní systémové lišty
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
            insets
        }

        // 3. Přijmutí dat z Intentu
        val bikeName = intent.getStringExtra("EXTRA_BIKE_NAME") ?: "Neznámé kolo"
        val totalPrice = intent.getStringExtra("EXTRA_TOTAL_PRICE") ?: "Celkem: 0 Kč"

        val hasFork = intent.getBooleanExtra("EXTRA_HAS_FORK", false)
        val hasSaddle = intent.getBooleanExtra("EXTRA_HAS_SADDLE", false)
        val hasHandlebar = intent.getBooleanExtra("EXTRA_HAS_HANDLEBAR", false)

        // 4. Sestavení textu pro příplatkovou výbavu
        var extrasText = "Příplatková výbava:\n"
        var hasAnyExtra = false

        if (hasFork) {
            extrasText += "- Lepší vidlice\n"
            hasAnyExtra = true
        }
        if (hasSaddle) {
            extrasText += "- Lepší sedlo\n"
            hasAnyExtra = true
        }
        if (hasHandlebar) {
            extrasText += "- Lepší řídítka\n"
            hasAnyExtra = true
        }

        // Pokud uživatel nezaškrtl nic
        if (!hasAnyExtra) {
            extrasText += "- žádná"
        }

        // 5. Zobrazení dat v textových polích
        binding.tvSelectedBike.text = "Vybrané kolo: $bikeName"
        binding.tvExtras.text = extrasText
        binding.tvFinalPrice.text = totalPrice

        // 6. Tlačítko Zpět pro návrat na úpravu objednávky
        binding.btnBack.setOnClickListener {
            finish()
        }


    }
}

Co se v kódu děje:

  • Ochrana okrajů (Edge-to-Edge): Funkce enableEdgeToEdge() roztáhne aplikaci přes celou plochu displeje. Následný blok kódu ViewCompat.setOnApplyWindowInsetsListener zjistí, jak vysoké jsou systémové lišty (baterie, signál, navigační tlačítka), a přidá našemu hlavnímu ScrollView (proto to ID main) přesně tak velký vnitřní okraj (padding), aby text nebyl těmito lištami překryt.
  • Rozbalení Intentu: Metoda intent.getStringExtra("KLÍČ") vytáhne textová data. Operátor ?: (tzv. Elvis operátor) určuje výchozí hodnotu pro případ, že by pod tímto klíčem žádná data nedorazila. Pro CheckBoxy používáme getBooleanExtra().
  • Skládání textu (String manipulation): Na základě proměnných typu Boolean (pravda/nepravda) programově rozhodneme, které řádky se přidají do finálního textu pro příplatkovou výbavu.
  • Ukončení aktivity: Metoda finish() nedělá nic jiného, než že aktuální obrazovku "zavře". Android si pamatuje historii (tzv. Back Stack), takže nás automaticky vrátí zpět na předchozí MainActivity s nezměněným původním výběrem.