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 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);
    }
}

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.

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

    val weblinks = listOf(
        "Euler's rotation theorem",
        "Walter K. Singleton",
        "Hokutoumi Nobuyoshi",
        "Powmill",
        "2007 Maldivian constitutional referendum",
        "Greg Tagert",
    )

    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ť 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.

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])
    }
	...
}

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(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()
            }
        })

    }
}

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 doreč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).

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 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, ale potrebujeme vedieť zabaliť objekt do intentu.

Aplikujeme Serializable. Použitá funkcia je deprecated. V nových verziách sa tam uvádza aj typ avšak na to by sme museli meniť verzie Androidu, čo teraz ignorujeme. 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) {
                ...
            }

            override fun onWeblinkLongClick(weblink: Weblink) {
                Intent(this@MainActivity, DetailActivity::class.java)
                    .apply {
                        putExtra("weblink", weblink)
                        startActivity(this)
                    }
            }
        })

    }
}

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

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 = intent.getSerializableExtra("weblink") as Weblink
        binding.textViewDetail.text = weblink.url
    }
}

Do pôvodnej aktivity sa vieme dostať cez Back button na telefóne. Nasledovným kódom pridáme šípku späť do ActionBar - čo je ten horný panel, kde je názov aplikácie. V ďalšej aplikácii v tomto priestore urobíme viac vecí.

DetailActivity.kt

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

        supportActionBar?.setDisplayHomeAsUpEnabled(true)

        ...
    }
}

V manifeste musíme označiť rodičovskú aktivitu (android:parentActivityName), aby sme dodali význam šípke.

manifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <application
        ...>
        <activity
            android:name=".DetailActivity"
            android:parentActivityName=".MainActivity"
            android:exported="false" />
        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

Môžete si vyskúšať pridať Log do onCreate v hlavnej aktivite. Je rozdiel ak použijeme back button na telefóne a na action bare?

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.

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.

Android používa SQLite databázu a prístup k nej zabezpečuje cez Room knižnicu. V ďalšej aplikácii bude použité dependency injection Hilt na redukciu boilerplate kódu.

Stručný popis k jednotlivým commitom:

Odkazy