Aplikácia bude uchovávať zoznam linkov na wikipédiu a po kliknutí bude možné otvoriť danú stránku v prehliadači.

Ukážeme si vytvorenie novej aktivity a prepojenie na databázu. Okrem toho bude viacero nových vecí z kotlinu a funkcionálneho prístupu k programovaniu.

Na začiatok si vytvoríme nový projekt s empty views aktivitou.

RecyclerView združuje viaceré View do jedného ViewGroup a poskytuje zobrazenie dynamického zoznamu. Aj keď máme veľký zoznam nejakých položiek, tak sa vytvorí len zopár widgetov (View), ktoré sa potom pri scrollovaní recyklujú.

res/layout/activity_main.xml

<androidx.constraintlayout.widget.ConstraintLayout 
	...
>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

V hlavnej aktivite je potrebné nastaviť pre RecyclerView dve veci:

MainActivity.kt

override fun onCreate(savedInstanceState: Bundle?) {
	...
    val recyclerView = findViewById<RecyclerView>(R.id.recycler_view)
    recyclerView.layoutManager = LinearLayoutManager(this)
    recyclerView.adapter = WeblinksAdapter()
}

Adaptér je spojený s triedou ViewHolder. Trieda ViewHolder určuje zobrazenie jednej položky v zozname. Túto triedu zvykneme vytvárať ako vnútornú triedu v adaptéri, ktorý na nej závisí (všimnite si <> zátvorky v hlavičke triedy). Pre lepšiu čitateľnosť zatiaľ nechávame neimplementované metódy.

Adaptér ma tri funkcie:

Dáta si v tejto aplikácii najprv vytvoríme manuálne a neskôr budú vytiahnuté z databázy.

WeblinksAdapter.kt

import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView

class WeblinksAdapter :
    RecyclerView.Adapter<WeblinksAdapter.WeblinksViewHolder>() {

    class WeblinksViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {

    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WeblinksViewHolder {
        TODO("Not yet implemented")
    }

    override fun onBindViewHolder(holder: WeblinksViewHolder, position: Int) {
        TODO("Not yet implemented")
    }

    override fun getItemCount(): Int {
        TODO("Not yet implemented")
    }

}

Ak by stále nebol jasný zápis tried v Kotline - tu je prepísaná trieda WeblinksViewHolder v jave:

class WeblinksViewHolder extends RecyclerView.ViewHolder {
    public WeblinksViewHolder(View itemView) {
    	super(itemView);
    }
}

NotImplementedError

Skúsme si spustiť aplikáciu. Spadne s výnimkou (resp. chybou), ktorú si vieme pozrieť v logcat.

Kotlin umožňuje kompilovať kód, aj keď niektoré funkcie ešte nie sú doprogramované a nepotrebujeme pri tom dávať defaultný return 0 ,return null a pod. Namiesto toho vieme použiť TODO a následne si tieto TODO vyhľadávať s pomocou nástroja v Android Studio (nájdeme to v ponuke vľavo ako je prehliadač súborov).

Dáta budú názvy stránok na wikipédii. Na vytvorenie vlastného listu (zatiaľ listOf stringov) môžete kliknúť na Random article na wikipédii a skopírovať niekoľko názvov.

import android.widget.TextView
import android.view.LayoutInflater

class WeblinksAdapter :
    RecyclerView.Adapter<WeblinksAdapter.WeblinksViewHolder>() {

    val weblinks = listOf(
        "André Planson",
        "Elena Zamura",
        "Walter K. Singleton",
        "1980 Major League Baseball draft",
        "Ōi Junction",
        "Pixham",
        "Hergenroth",
        "2009 in Azerbaijan",
        "Pujan Uparkoti",
        "Cambria, Minnesota"
    )

    class WeblinksViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val textView: TextView = itemView.findViewById(android.R.id.text1)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WeblinksViewHolder {
        val singleItemLayout = LayoutInflater.from(parent.context)
            .inflate(
                android.R.layout.simple_list_item_1,
                parent, false
            )
        return WeblinksViewHolder(singleItemLayout)
    }

    override fun onBindViewHolder(holder: WeblinksViewHolder, position: Int) {
        holder.textView.text = weblinks[position]
    }

    override fun getItemCount() = weblinks.size

}

Vysvetlenie jednotlivých implementácii:

Spustenie

Aplikáciu spustíme a uvidíme zoznam. Môžete si vyskúšať otočiť obrazovku alebo pridať výrazne viac stringov (môžu sa opakovať) - všimnite si, že sa dá zoznam scrollovať. Ak vidíte na obrazovke naraz 10 položiek a máte ich v zozname 1000, tak reálne máte v aplikácii zhruba do 15 view a pri posúvaní sa iba nastavujú hodnoty jednotlivých widgetov (cez viewHolder).

Slovom data prispôsobím triedu, ktorej hlavnou úlohou je držať dáta. Podmienkou je mať aspoň jeden parameter v konštruktore a všetky parametre musia byť aj property (povinné val alebo var).

V kotline získame v data class okrem properties deklarovaných už v konštruktore aj:

Úprava weblinku je veľmi jednoduchá. Zatiaľ iba s jednou premennou.

Weblink.kt

data class Weblink(val title: String)

Takéto triedy môžeme pripojiť k iným a zahrnúť ich do súboru s inou triedou. V našom prípade k adaptéru alebo aktivite. Tentokrát ale nechajme veci osobitne pre lepšiu prehľadnosť - 1 trieda 1 súbor.

Record class v jave

Trieda Record v Jave existuje od verzie 14. Umožňuje jednoduchší zápis pre triedy, ktoré obaľujú dáta. Deklaruje sa napr. takto:

public record Point(int x, int y){}

Tento kód automatický zahŕňa inštančné premenné, konštruktor, hashCode, equals a toString. Viac napr. tu alebo tu. Record v Jave je cielený iba na immutable premenné. Data class v kotline umožňuje aj val aj var premenné. Okrem toho je tam viac rozdielov (pre nás menej podstatných).

Transformácie

Extension funkcie

Member funkcie - to čo už je definované, napr. isEmpty(), get(i). Môžeme ich nazvať inštančné metódy.

Extension funkcie:

Vyskúšajme si

O high-order funkciách a lambdách si môžete prečítať viac. Budeme sa tomu venovať v ďalšej aplikácii. Namiesto venovania sa teórii, skúsme experimentálne.

Zoznam stringov linkov wikipédie premenujme na weblinkStrings. Pomocou funkcie map vieme vytvoriť novú kolekciu, kde každý prvok vznikne transformáciou prvku z inej kolekcie. Teda na vstupe funkcie (to medzi kučeravými zátvorkami) je string a na výstupe je nový objekt. Ak má lambda funkcia iba jeden parameter, tak ho písať nemusíme (ani tú šípku ->) a použijeme premennú it. Ak píšeme lambda funkcie v novom riadku, tak android studio nam poradí, aký typ je it.

WeblinksAdapter.kt

val weblinks = weblinkStrings.map {
	Weblink(title = it)
}

Alternatívny zápis:

val weblinks = weblinkStrings.map {
    nazov -> Weblink(title = nazov)
}

Môžete si skúsiť na to ešte aplikovať nejakú ďalšiu funkciu, napr. .sortedBy{ it.title }.

LayoutInflater môžeme prepísať pomocou scope funkcie let. Všimnime si, že sa tam tiež používa it. Scope funkcie boli v predošlej aplikácii popísané.

WeblinksAdapter.kt

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WeblinksViewHolder {
	return LayoutInflater.from(parent.context)
            .inflate(
                android.R.layout.simple_list_item_1,
                parent, false
            ).let {
                WeblinksViewHolder(it)
	}
}

Ešte jednu drobnosť - minimalizujeme množstvo kódu vo funkcii onBindViewHolder a presunieme zodpovednosť na ViewHolder:

WeblinksAdapter.kt

class WeblinksAdapter ...

	class WeblinksViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val textView: TextView = itemView.findViewById(android.R.id.text1)

        fun bind(weblink: Weblink) {
            textView.text = weblink.title
        }
    }
	...
	override fun onBindViewHolder(holder: WeblinksViewHolder, position: Int) {
        holder.bind(weblinks[position])
    }
	...
}

Môžeme si všimnúť, že Android Studio nás upozorní, že premenná weblinksStrings môže byť private. Modifikátor private v Kotline neslúži iba na zapúzdrenie ako v Jave, ale vieme ním oddeliť pomocné premenné.

Tiež si môžeme všimnúť, že zoznam (list) vieme dopytovať s použitím zátvoriek ako v Jave [].

Chceme reagovať na kliknutie na nejakú položku v RecyclerView - tentokrát zobraziť toast, ale potom aj pracovať s databázou, resp. otvoriť wikipédiu. Kliknutie vieme zaznamenať v WeblinksViewHolder triede, čo je súčasť WeblinksAdapter, ale na toast potrebujeme tzv. context, čo je v našom prípade MainActivity.

Nasledujúci koncept si v ďalšej aplikácii urobíme funkcionálne s lambda výrazom. Teraz pre lepšie pochopenie po starom pomocou rozhrania:

OnWeblinkClickListener.kt

interface OnWeblinkClickListener {

    fun onWeblinkClick(weblink: Weblink)

}

Je potrebné vyriešiť nasledovné veci:

WeblinksAdapter.kt

...
class WeblinksAdapter(private val listener: OnWeblinkClickListener) :
    RecyclerView.Adapter<WeblinksAdapter.WeblinksViewHolder>() {

    ...

    class WeblinksViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val textView: TextView = itemView.findViewById(android.R.id.text1)

        fun bind(weblink: Weblink, listener: OnWeblinkClickListener) {
            textView.text = weblink.title
            textView.setOnClickListener {
                listener.onWeblinkClick(weblink)
            }
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WeblinksViewHolder {
        return LayoutInflater.from(parent.context)
            .inflate(
                android.R.layout.simple_list_item_1,
                parent, false
            ).let {
                WeblinksViewHolder(it)
            }
    }

    override fun onBindViewHolder(holder: WeblinksViewHolder, position: Int) {
        holder.bind(weblinks[position], listener)
    }
	...

}

MainActivity.kt

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        recyclerView.adapter = WeblinksAdapter(object : OnWeblinkClickListener {
            override fun onWeblinkClick(weblink: Weblink) {
                Toast.makeText(this@MainActivity, weblink.toString(), Toast.LENGTH_SHORT).show()
            }
        })

    }
}

Pri vytvorení Toast je this označením inštancie vnútornej anonymnej triedy. Avšak inštancia aktivity je viditeľná a takýto this je dostupný cez this@MainActivity.

Rozhranie je možné implementovať aj iným spôsobom, napr. vytvorením samostatnej triedy alebo tým, že aktivita bude implementovať to rozhranie. Pri iných spôsoboch netreba zabúdať, že potrebujeme mať referenciu na aktivitu, aby sme mali context pomocou ktorého sa vytvára toast.

Intent v androide je objekt, ktorý obsahuje správu pre systém, aby vykonal nejakú akciu v inom komponente. Reálne to znamená, že ide o spustenie inej aktivity, ale aj service alebo doručenie broadcastu. V tejto aplikácii budeme spúšťať nové aktivity. Žiadna aktivita nemá právo spúšťať inú - iba požiada Android o spustenie.

Intent obsahuje dáta a akciu. Môže byť explicitný (povieme akú triedu spúšťame) alebo implicitný (popíše akciu a nechá systém nech vyberie - ten to môže nechať na používateľa). V tejto aplikácii si ukážeme obidva spôsoby. Začneme implicitným.

Najprv upravme triedu Weblink, aby obsahovala url na wikipediu.

Weblink.kt

data class Weblink(
    val title: String,
    val url: String = "https://en.wikipedia.org/wiki/" + title.replace(' ', '_')
)

V hlavnej aktivite vyrobíme intent, ktorému nastavíme akciu ACTION_VIEW a dáta budú URL. Toto je implicitný intent, kde sa systém rozhodne ako urobí zobrazenie = spustí prehliadač.

startActivity pošle intent systému, ktorý spustí danú akciu.

MainActivity.kt

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
 		...
        recyclerView.adapter = WeblinksAdapter(object : OnWeblinkClickListener {
            override fun onWeblinkClick(weblink: Weblink) {
                with(Intent()) {
                    action = Intent.ACTION_VIEW
                    data = Uri.parse(weblink.url)
                    startActivity(this)
                }
            }
        })

    }
}

Novú aktivitu DetailActivity.kt si vytvorme cez File -> New -> Activity -> Empty Views Activity. Dostaneme automaticky aj zápis v manifest.xml a layout k tomu.

Layout si upravme tak, aby sme mali jediný TextView, ktorý bude mať ID android:id="@+id/text_view_detail"

ViewBinding je spôsob ako urobiť prepojenie medzi widgetmi z XML (všetky, ktoré majú definované ID) a premennými v OOP. Potrebujeme upraviť gradle script. Po úprave klikneme hore na SYNC NOW.

build.gradle (module :app)

android {
	...
    buildFeatures {
        viewBinding = true
    }
}

V aktivite nastavujeme layout trochu iným spôsobom. Avšak máme cez binding k dispozícii v premenných všetky widgety daného layoutu. Trieda ActivityDetailBinding je generovaná automaticky na základe XML súboru.

DetailActivity.kt

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import sk.upjs.vma.weblinks.databinding.ActivityDetailBinding

class DetailActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val binding = ActivityDetailBinding.inflate(layoutInflater)
        setContentView(binding.root)

    }
}

Chceme využiť novú aktivitu, aby po kliknutí na položku v zozname MainActivity boli zobrazené informácie o danej položke v inej aktivite DetailActivity. Informáciu o weblinku budeme posielať v intente. Intent môže obsahovať dáta, avšak predtým potrebujeme vedieť zabaliť objekt do intentu. Aktivity si priamo nevedia posielať dáta (je možné využiť nejakú formu uloženia údajov - to bude neskôr).

Aplikujeme rozhranie Serializable. Upozornenie: data class nemá v tomto prípade {} (aj keď môže mať), preto pozor, kde dávame : Serializable.

Weblink.kt

import java.io.Serializable

data class Weblink(
    val title: String,
    val url: String = "https://en.wikipedia.org/wiki/" + title.replace(' ', '_')
) : Serializable

Upravíme rozhranie, aby sme rozlíšíli dve udalosti:

OnWeblinkClickListener.kt

interface OnWeblinkClickListener {

    fun onWeblinkClick(weblink: Weblink)

    fun onWeblinkLongClick(weblink: Weblink)
}

V adaptéri pridáme setOnLongClickListener, ktorý sleduje dlhý klik na TextView. Táto metóda potrebuje vrátiť boolean. Všimnite si v kóde, že v lambda výraze return nepíšeme, je tam iba true.

WeblinksAdapter.kt

fun bind(weblink: Weblink, listener: OnWeblinkClickListener) {
	textView.text = weblink.title
	textView.setOnClickListener {
		listener.onWeblinkClick(weblink)
	}
	textView.setOnLongClickListener {
		listener.onWeblinkLongClick(weblink)
		true
	}
}

V hlavnej aktivite doplníme implementáciu po dlhom kliknutí. Keďže sme v anonymnej triede, tak je pre nás daná trieda this. Táto trieda je vnorená do hlavnej aktivity a k nej sa vieme dostať cez this@MainActivity - takto rozlíšime, ktorý this potrebujeme.

Explicitný intent berie kontext a triedu, ktorú má spustiť (DetailActivity::class.java). Do intentu doplníme extra informáciu o weblinku. Extra je mapa, ktorú intent obsahuje - podobne ako SharedPreferences a Bundle.

Môžeme použiť ďalšiu scope funkciu apply. Podobá sa na with ale teraz nie je intent v oblých zátvorkach ako argument, ale na daný objekt ešte aplikujeme nasledovný kód.

MainActivity.kt

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
		...
        recyclerView.adapter = WeblinksAdapter(object : OnWeblinkClickListener {
            override fun onWeblinkClick(weblink: Weblink) {
                Intent(this@MainActivity, DetailActivity::class.java)
                    .apply {
                        putExtra("weblink", weblink)
                        startActivity(this)
                    }
            }

            override fun onWeblinkLongClick(weblink: Weblink) {
                with(Intent()) {
                    action = Intent.ACTION_VIEW
                    data = Uri.parse(weblink.url)
                    startActivity(this)
                }
            }
        })

    }
}

V druhej aktivite vytiahneme z intentu informáciu, ktorú zobrazíme. Využijeme viewBinding na získanie referencie na TextView.

String (kľúč v mape Extra) "weblink" by bolo vhodnejšie extrahovať do nejakej premennej.

DetailActivity.kt

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import sk.upjs.vma.weblinks.databinding.ActivityDetailBinding

class DetailActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val binding = ActivityDetailBinding.inflate(layoutInflater)
        setContentView(binding.root)

        val weblink = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            intent.getSerializableExtra("weblink", Weblink::class.java)
        } else {
            intent.getSerializableExtra("weblink") as Weblink
        }
        binding.textViewDetail.text = weblink?.url
    }
}

Použitá funkcia getSerializableExtra je deprecated, čo nám umožní pozrieť sa na niektoré zaujímavé veci.

Ak chceme použiť novú verziu tejto metódy (s dvoma parametrami), tak potrebujeme novšiu verziu Androidu.

V build.gradle (Module :app) resp. v nastaveniach projektu nájdeme 3 čísla:

Viac o verziách SDK nájdeme pri prvej aplikácii Hangman v Jave.

V kóde DetailActivity si všimneme:

Pred napojením na databázu ešte implementujeme odstraňovanie zo zoznamu. Pridávanie a úpravu nebudeme robiť.

Swipe gestá vieme implementovať pomocou ItemTouchHelper a príslušného Callback. Použijeme object ako anonymnú triedu. SimpleCallback vyžaduje v parametroch dragDirs a swipeDirs - umožňuje reagovať na presunutie a na potiahnutie. Chce to čísla - v jednom čísle sa kódujú smery pohybu. Ale nemusíme riešiť binárne čísla a na miesto toho dať 0 ak to nepotrebujeme a pomocou or pridať konštanty pre smery potiahnutia, ktoré nás zaujímajú. Chceme swipe doľava a doprava.

Vo funkcii onSwipe povieme adaptéru, že odstraňujeme položku na danom indexe.

MainActivity.kt

...
import androidx.recyclerview.widget.ItemTouchHelper

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
		...
        val adapter = WeblinksAdapter(object : OnWeblinkClickListener {
            ...
        })

        recyclerView.adapter = adapter
        val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
            0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
        ) {
            override fun onMove(
                recyclerView: RecyclerView,
                viewHolder: RecyclerView.ViewHolder,
                target: RecyclerView.ViewHolder
            ) = false

            override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
                val index = viewHolder.adapterPosition
                adapter.remove(index)
            }

        })
        itemTouchHelper.attachToRecyclerView(recyclerView)

    }
}

V adaptéri implementujeme metódu remove, kde odstránime položku z kolekcie. Okrem toho musíme notifikovať RecyclerView, že sa zmenili dáta a je potrebné prekresliť zoznam. Môžete si vyskúšať tento riadok vynechať a sledovať správanie. Prípadne si pozrite aké iné funkcie začínajúce na notify... existujú. Ak neviem povedať konkrétnejšiu informáciu, urobíme notifyDataSetChanged(), ale vždy je lepšie volať viac špecifickú metódu.

Samozrejme, zoznam nie je možné meniť. Preto pri mapovaní stringov na objekty pretypujeme výsledok na MutableList, aby to povolilo meniť kolekciu.

Odstránime tiež funkciu sortedBy, ktorá spôsobuje problémy pri odstraňovaní.

WeblinksAdapter.kt

package sk.upjs.vma.weblinks

import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView

class WeblinksAdapter(val listener: OnWeblinkClickListener) :
    RecyclerView.Adapter<WeblinksAdapter.WeblinksViewHolder>() {

    ...
        
    val weblinks = weblinkStrings.map {
        Weblink(title = it)
    } as MutableList

	...

    fun remove(index: Int) {
        weblinks.removeAt(index)
        notifyItemRemoved(index)
    }
}

Gitlab kód.

V ďalšom samostatnom codelab-e bude aplikácia Weblink doplnená o databázu.