Aplikácia predstavuje hru, kde bude používateľovi ponúknutý zoznam zopár mien a jeho úlohou je zoradiť mená podľa počtu výskytov daného krstného mena v meste Prešov (lebo máme dostupný dataset s týmito údajmi).

Dataset mien je dostupný:

Hlavnou myšlienkou je použiť veci, ktoré sme už videli v predošlej aplikácii, ale aplikované rozumnejšie (iba veci potrebné finálnemu výsledku).

Z pohľadu Androidu a Kotlinu si precvičíme:

Začíname s Basic Views Activity. Dostaneme viac kódu ako pri predošlých aplikáciach, ktoré používali Empty Views Activity.

Z vygenerovanej aplikácie odstránime veci týkajúce sa navigation a fragmentov (viď commit v gite).

Pozrieme si nasledovný kód:

Postačuje nakopírovať nižšie uvedené dependencies. V prípade, že niektorá z nich je označená žltou farbou - je indikované, že existuje novšia verzia, je možné použiť aj novšiu verziu.

build.gradle.kts (Project: OpenDataGame)

plugins {
	...
    id("com.google.devtools.ksp") version "1.9.22-1.0.17" apply false
    id("com.google.dagger.hilt.android") version "2.51" apply false
}

build.gradle.kts (Module :app)

plugins {
    ...
    id("com.google.devtools.ksp")
    id("com.google.dagger.hilt.android")
}

dependencies {

    implementation("com.google.dagger:hilt-android:2.51")
    ksp("com.google.dagger:hilt-android-compiler:2.51")

    implementation("androidx.preference:preference-ktx:1.2.1")

    implementation("com.squareup.retrofit2:retrofit:2.9.0")
    implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
    implementation("com.squareup.moshi:moshi-kotlin:1.15.1")

    val roomVersion = "2.6.1"
    implementation("androidx.room:room-runtime:$roomVersion")
    implementation("androidx.room:room-ktx:$roomVersion")
    annotationProcessor("androidx.room:room-compiler:$roomVersion")
    ksp("androidx.room:room-compiler:$roomVersion")

    implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
    implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0")
    implementation("androidx.activity:activity-ktx:1.8.2")

    api("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1")
    api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1")

    ...
}



Entity, Dao a Database sú tri potrebné komponenty pre prácu s databázou ak využívame knižnicu Room. V Kotline nie je striktná väzba medzi triedou a súborom, preto môžeme tieto tri súvisiace triedy (resp. rozhranie) vložiť do jedného súboru.

Rozdiel voči predošlej aplikácii je v tom, že abstraktnú triedu Database iba deklarujeme a nevytvárame inštanciu (možeme si pozrieť ako sa to robilo pomocou database buildera, s využitím návrhoveho vzoru singleton a thread-safe implementáciou s použitím synchronized a volatile).

Repository bude zatiaľ poskytovať manuálne naplnenie databázy konkrétnymi hodnotami. Toto bude neskôr prerobené, aby dáta prichádzali z internetu (rest api).

Database.kt

import androidx.room.Dao
import androidx.room.Database
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.RoomDatabase
import kotlinx.coroutines.flow.Flow

@Entity(tableName = "record_table")
data class Record(
    @PrimaryKey
    val name: String,
    val count: Int
)

@Dao
interface NamesDao {

    @Query("SELECT * FROM record_table")
    fun readRecords(): Flow<List<Record>>

    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insert(records: List<Record>)

    @Query("DELETE FROM record_table")
    suspend fun deleteAll()

}

@Database(entities = [Record::class], version = 1, exportSchema = false)
abstract class NamesDatabase : RoomDatabase() {

    abstract fun namesDao(): NamesDao
}

NamesRepository.kt

class NamesRepository(private val dao: NamesDao) {

    val names = dao.readRecords()

    suspend fun storeNames() {
        dao.deleteAll()

        val defaultNames = listOf(
            Record("Maria", 1000),
            Record("Jozef", 500),
            Record("Milan", 200),
            Record("Linda", 50),
        )
        dao.insert(defaultNames)
    }

}

Na začiatok prepojíme databázu a aktivitu pomocou ViewModel triedy. Všetky doteraz vytvorené triedy neobsahujú žiaden kód, ktorý by vytváral prepojenia medzi nimi. Napr. NamesViewModel pracuje s referenciou na NamesRepository, ale nikde v kóde sa táto referencia nepriradzuje, resp. nikde sa nevytvárajú nové objekty príslušných tried.

NamesViewModel.kt

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch

class NamesViewModel(private val repository: NamesRepository) : ViewModel() {

    fun storeNames() {
        viewModelScope.launch {
            repository.storeNames()
        }
    }

}

MainActivity.kt

class MainActivity : AppCompatActivity() {

	...
    private val viewModel: NamesViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        
        binding.fab.setOnClickListener {
            viewModel.storeNames()
        }
    }

Cannot create an instance of class sk.upjs.opendatagame.NamesViewModel - po spustení vidíme, že aplikácia nefunguje. Zatiaľ sme vytvorili všetky potrebné komponenty, ale neurobili sme prepojenie medzi nimi.

by viewModels()

Aktivity má referenciu na ViewModel, avšak aktivita si tento objekt - ViewModel nevytvára samostatne. ViewModel má dlhšiu životnosť ako aktivita a je to preferovaný spôsob, kde si aktivita (resp. UI komponent) uchováva svoje dáta.

Android má k dispozícii triedu ViewModelProvider, pomocou ktorej vie aktivita získať referenciu na ViewModel. Použijeme ale konštrukciu by viewModels(), ktorú máme k dispozícii vďaka dependency (androidx.activity:activity-ktx). Použitie slova by označuje delegáta - getter/setter (nazývané accessor methods) sú delegované na niekoho iného. V tomto prípade je implementácia urobená lazy prístupom - teda pri prvom prístupe sa získa referencia na daný objekt, pri každom ďalšom sa odovzdáva cachovaná verzia (môžete si pozrieť kód).

Odporúčam si pozrieť definíciu, čo je ViewModel a ako s ním pracovať (v dokumentácii sú aj best practices).

Dependency injection je princíp, ktorý je založený na tom, že objekty alebo funkcie, ktoré potrebujú iné objekty/funkcie si ich nevytvárajú samostatne, ale ich dostanú. Oddeľuje sa vytváranie objektu a jeho používanie.

V princípe objekt (funkcia), ktorý používa nejakú službu nemá vedomosť o spôsobe ako táto služba vzniká, iba s ňou pracuje. V tejto aplikácii je aktuálne implementované používanie takýchto služieb (napr. ViewModel využíva Repository a referenciu získava v konštruktore).

Výhodou tohto prístupu je, že vzniknutý kód je lepšie testovateľný a udržateľný, je vhodný na opakované použitie a často je aj stručnejší, lebo sa redukuje zbytočný kód.

Medzi nevýhody patrí závislosť na zvolenom frameworku a náročnejšie sledovanie správania aplikácie.

Knižnice, ktoré poskytujú dependency injection sa delia na dva typy:

Hilt je odporúčaná knižnica na dependency injection v Androide. Je postavená na knižnici Dagger.

Dagger je knižnica pre Javu, Kotlin (a Android), ktorá poskytuje dependency injection takým spôsobom, že vytvára a spravuje graf závislostí. Poskytuje statické závislosti v čase kompilácie.

Viac o dependency injection v kontexte Androidu:

Pri používaní dependency injection pomocou knižnice Hilt je potrebné jednotlivé komponenty aplikácie adresovať príslušnými anotáciami. Všimnime si najprv hotový kód:

NamesApplication.kt

import android.app.Application
import dagger.hilt.android.HiltAndroidApp

@HiltAndroidApp
class NamesApplication : Application()

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest ...>

    <application
        android:name=".NamesApplication"
        ...>
		...
    </application>

</manifest>

MainActivity.kt

...
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    ...
}

NamesRepository.kt

...
import javax.inject.Inject

class NamesRepository @Inject constructor(private val dao: NamesDao) {
    ...
}

NamesViewModel.kt

...
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject

@HiltViewModel
class NamesViewModel @Inject constructor(private val repository: NamesRepository) : ViewModel() {
    ...
}

Komentár k jednotlivým anotáciam:

Získanie závislosti z externej knižnice

Najprv si pozrime kód. Spôsob vytvárania inštancie databázy pomocou Room.databaseBuilder sme videli v predošlej aplikácii. Z pohľadu kódu by tam nemalo byť nič nové, dôležité pre nás sú tentokrát iba anotácie pridané z knižnice Hilt.

DatabaseModule.kt (vo vzorovom kóde súčasť NamesApplication.kt)

import android.content.Context
import androidx.room.Room
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@InstallIn(SingletonComponent::class)
@Module
class DatabaseModule {

    @Provides
    fun provideNamesDao(database: NamesDatabase): NamesDao {
        return database.namesDao()
    }

    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext appContext: Context): NamesDatabase {
        return Room.databaseBuilder(
            appContext,
            NamesDatabase::class.java,
            "names_database"
        ).build()
    }

}

Komentáre k tomuto kódu:

Aplikácie v Androide sú tvorené podľa MVVM vzoru (model-view-viewmodel). V našom prípade aktivita=view poskytuje používateľské rozhranie. Repository zastupuje model a poskytuje prístup k dátam. Viewmodel je vrstva medzi nimi. Na jednej strane je zviazaná s používateľským rozhraním. Aj keď neobsahuje referenciu na view, je s ňou prepojená a poskytuje aktualizáciu dát. Na druhej strane je prepojená s modelom a reprezentuje aktuálny stav dát.

Schéma aplikácie open data game

JSON súbor s menami, ktoré chceme použiť v aplikácii je dostupný na tomto odkaze. Aby sme si ale ukázali prístup k REST API, lokálna kópia sa nachádza tu: https://ics.upjs.sk/~opiela/rest/names.

Na prácu s REST API použijeme knižnicu Retrofit, ktorá je type-safe HTTP klient pre Javu a Android. Na parsovanie JSON objektov a polí na Java/Kotlin triedy použijeme knižnicu Moshi. Existujú aj iné knižnice na parsovanie JSON (viď stránka o retrofite).

Rest.kt

import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.GET

private const val BASE_URL = "https://ics.upjs.sk/~opiela/rest/"

private val MOSHI = Moshi.Builder()
    .add(KotlinJsonAdapterFactory())
    .build()

private val RETROFIT = Retrofit.Builder()
    .baseUrl(BASE_URL)
    .addConverterFactory(MoshiConverterFactory.create(MOSHI))
    .build()

interface NamesRestDao {

    @GET("names")
    suspend fun getNames(): List<Record>
}

object RestApi {
    val namesRestDao: NamesRestDao by lazy {
        RETROFIT.create(NamesRestDao::class.java)
    }
}

Vyššie napísaný kód vytvára všetko potrebné na prácu s REST API. Pri použití rovnakých knižníc na iný REST server je potrebné zmeniť premennú BASE_URL a dopísať funkcie v rozhraní NamesRestDao. Tieto funkcie sa budú podobať na DAO funkcie, ktoré sú robené pre databázu. Avšak musia byť označené príslušnými anotáciami z knižnice Retrofit zodpovedajúce HTTP volaniam (get, post, put, ...).

NamesRepository.kt

...
import android.util.Log

class NamesRepository @Inject constructor(private val dao: NamesDao) {

	...
    
    suspend fun storeNames() {
        dao.deleteAll()

        try {
            val records = RestApi.namesRestDao.getNames()
            dao.insert(records)
        } catch (e: Exception) {
            Log.e("REST", e.toString())
        }
    }
}

Repository teraz realizuje požiadavku na stiahnutie údajov z REST API a vloženie spracovaných údajov do databázy.

Database.kt

...
import com.squareup.moshi.Json

@Entity(tableName = "record_table")
data class Record(
    @PrimaryKey
    @Json(name = "Krstné_meno")
    val name: String,
    @Json(name = "Počet")
    val count: Int
)

Knižnica Moshi vykonáva parsovanie JSON súboru. V našom prípade sa JSON array transformuje na list objektov. Jeden JSON object reprezentujúci konkrétne meno sa transformuje na objekt triedy Record. Nezhoda medzi názvami premenných v triedeRecord a názvami atribútov v JSON object sa dá vyriešiť použitím anotácie @Json.

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest ...>

    <uses-permission android:name="android.permission.INTERNET"/>
    
    <application ...>
		...
    </application>

</manifest>

Na vykonávanie HTTP volaní je potrebné deklarovať povolenie android.permission.INTERNET. Toto povolenie nie je kritické a preto postačuje jeho zaznamenanie do manifestu. Prácu s inými povoleniami (dangerous), ktoré vyžadujú súhlas používateľa, si ukážeme v ďalšej aplikácii.

Tento krok priamo nesúvisí s aplikáciou, ktorú tvoríme, ale časť z toho využijeme. Pozrime sa na kód v jave, kde sa vytvára mapa a postupne sa vypisuje jej obsah.

Java

import java.util.HashMap;
import java.util.Map;

public class Demo {

    public static void main(String[] args) {
        Map<String, Integer> mapa = new HashMap<>();
        mapa.put("Peter", 2000);
        mapa.put("Maria", 100);
        mapa.put("Juraj", 20);

        for (String meno : mapa.keySet()) {
            System.out.println("Meno " + meno + " je " + mapa.get(meno) + " krat.");
        }
    }

}

Kotlin

fun main() {
    val mapa = hashMapOf(
        "Peter" to 2000,
        "Maria" to 100
    )
    mapa["Juraj"] = 20

    for (meno in mapa.keys) {
        println("Meno $meno je ${mapa[meno]}  krat.")
    }
}

V kotline sú niektoré veci elegantnejšie:

Do layoutu pre aktivitu pridáme widget RecyclerView. Aby bolo možné pristúpiť k tomuto widgetu pomocou view binding, je nutné, aby aj include, ktorý zahŕňa content_main bol označený svojim id.

content_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout ...>

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

</androidx.constraintlayout.widget.ConstraintLayout>

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout ...>

    ...

    <include
        android:id="@+id/included"
        layout="@layout/content_main" />

    ...

</androidx.coordinatorlayout.widget.CoordinatorLayout>

Layout jednej položky

Doteraz sme využívali predpripravený xml layout pre jednu položku, kde bol iba jediný textView. Tentokrát si vytvoríme vlastný layout. Nižšie je nákres ako sú jednotlivé prvky usporiadané s využitím ConstraintLayout. Všimnime si kód pre tento layout, kde je šírka CardView nastavená na match_parent, avšak zahrnutý je margin - aj na strane CardView, aj vo vnútri na strane jednotlivých widgetov. Výška je wrap_content, čo zabezpečí, že sa použije iba toľko miesta, koľko je potrebné. Ak by výška bola nastavená na match_parent, tak by v recyclerView bol viditeľný iba jeden item. Ďalšie položky by sa zobrazili až po scrollovaní.

item_layout.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginStart="10dp"
    android:layout_marginTop="5dp"
    android:layout_marginEnd="10dp"
    android:layout_marginBottom="5dp"
    android:background="@android:color/white"
    android:elevation="5dp"
    app:contentPadding="5dp">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:fadingEdge="vertical"
        android:orientation="horizontal">

        <TextView
            android:id="@+id/textViewName"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_margin="10dp"
            android:textAppearance="@style/TextAppearance.AppCompat.Large"

            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/textViewNumber"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_margin="10dp"
            android:textAppearance="@style/TextAppearance.AppCompat.Large"

            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toStartOf="@id/reorderIcon"
            app:layout_constraintTop_toTopOf="parent" />

        <ImageView
            android:id="@+id/reorderIcon"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"

            android:contentDescription="reorder"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:srcCompat="@android:drawable/ic_menu_sort_by_size" />


    </androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>

Adaptér poskytuje dáta, ktoré sú zobrazované v RecyclerView. Doteraz sme rozšírovali triedu Adapter, kde boli prekryté 3 funkcie:

V tejto aplikácii rozšírime triedu ListAdapter. Rozdiel je ten, že zoznam uchovávajúci hodnoty je interne schovaný v rodičovskej triede. Nepotrebujeme prekrývať tretiu funkciu, ktorá vracia počet položiek v zozname. Avšak je potrebné rozšíriť abstraktnú triedu ItemCallback, kde sa prekrývajú dve funkcie areItemsTheSame a areContentsTheSame. Tieto funkcie slúžia na to, aby RecyclerView vedel rozoznať, či sa zmenila nejaká položka a či je potrebné ju prekresliť.

NamesAdapter.kt

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import sk.upjs.opendatagame.databinding.ItemLayoutBinding

class NamesAdapter : ListAdapter<Record, NamesAdapter.NamesViewHolder>(DiffCallback) {

    class NamesViewHolder(val binding: ItemLayoutBinding) :
        RecyclerView.ViewHolder(binding.root) {

        fun bind(record: Record) {
            binding.textViewName.text = record.name
            "Count ${record.count}".also {
                binding.textViewNumber.text = it
            }
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NamesViewHolder {
        return ItemLayoutBinding
            .inflate(LayoutInflater.from(parent.context), parent, false)
            .let { NamesViewHolder(it) }
    }

    override fun onBindViewHolder(holder: NamesViewHolder, position: Int) {
        holder.bind(getItem(position))
    }

    object DiffCallback : DiffUtil.ItemCallback<Record>() {
        // ak obe polozky equals
        // ak dva objekty reprezentuju rovnaku polozku
        override fun areItemsTheSame(oldItem: Record, newItem: Record) = oldItem == newItem

        // ak dva objekty maju rovnaky obsah
        override fun areContentsTheSame(oldItem: Record, newItem: Record) = oldItem == newItem

    }

}

V predošlom kóde si môžeme všimnúť použitie scope funkcií:

NamesViewModel.kt

...
import androidx.lifecycle.asLiveData

@HiltViewModel
class NamesViewModel @Inject constructor(private val repository: NamesRepository) : ViewModel() {

    val allNames = repository.names.asLiveData()
    
    ...

}

NamesViewModel uchováva LiveData všetkých mien. Z pohľadu ViewModel nevieme, či berieme dáta z internetu alebo lokálnej databázy.

MainActivity.kt

...
import androidx.recyclerview.widget.LinearLayoutManager

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    ...
    private var list: List<Record> = emptyList()

    override fun onCreate(savedInstanceState: Bundle?) {
       	...
        
        val adapter = NamesAdapter()
        binding.included.recyclerViewNames.adapter = adapter
        binding.included.recyclerViewNames.layoutManager = LinearLayoutManager(this)


        binding.fab.setOnClickListener {
            adapter.submitList(getRandomList(4))
        }

        viewModel.allNames.observe(this) {
            list = it
        }

    }

    private fun getRandomList(count: Int): List<Record> {
        if (list.isEmpty()) return emptyList()
        var shuffledList = list.shuffled()
        return shuffledList.subList(0, count)
    }

    ...

}

V hlavnej aktivite je implementované nasledovné:

Vytvoríme novú aktivitu (Settings Views Activity). Settings aktivita je vytvorená na základe xml, kde definujeme ake nastavenia chceme použiť. Hodnoty týchto nastavení sa ukladajú do SharedPreferences - teda do mapy, kde jedinečnému klúču je priradená vybraná hodnota.

Doplníme spustenie aktivity po kliknutí v menu, spustíme aplikáciu a sledujeme vygenerovaný kód:

MainActivity.xml

...
import android.content.Intent

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    ...

    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        return when (item.itemId) {
            R.id.action_settings -> {
                startActivity(Intent(this, SettingsActivity::class.java))
                true
            }

            else -> super.onOptionsItemSelected(item)
        }
    }

}

Upravený kód v hlavnej aktivite iba spúšťa novú aktivitu s nastaveniami po kliknutí na položku Settings v menu. Ak ju v aplikácii nevidíme, je potrebné kliknúť na menu vpravo hore (kebab menu - tri bodky nad sebou).

Toolbar/appbar sa nezobrazí v Settings activity - neriešime to, postupne prechádzame na Jetpack compose, kde sa s UI pracuje iným spôsobom. Ak by sme ho potrebovali zobraziť, dá sa to nastaviť. V tom prípade odporúčam pozrieť do AndroidManifest.xml na atribút theme a kliknutím pozrieť, čo sa za tým skrýva. Ak toolbar nevidíme, tak je možné vrátiť sa do hlavnej aktivity použitím tlačidla späť priamo na telefóne (emulátor má toto tlačidlo v ponuke hore).

Teraz iba modifikujme nastavenia, aby sme si vedeli zvoliť, či zahrnieme do hry aj mená, ktoré majú iba jeden výskyt.

/res/xml/root_preferences.xml

<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">

    <SwitchPreferenceCompat
        app:key="includeSingle"
        app:title="Include single occurences" />

</PreferenceScreen>

MainActivity.xml

...
import androidx.preference.PreferenceManager

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    ...

    private fun getRandomList(count: Int): List<Record> {
        val pref = PreferenceManager.getDefaultSharedPreferences(this)
        val include = pref.getBoolean("includeSingle", false)

        if (list.isEmpty()) return emptyList()

        var shuffledList = list.shuffled()
        if (!include) {
            shuffledList = shuffledList.filter { it.count > 1 }
        }
        return shuffledList.subList(0, count)
    }

	...
}

Hodnotu získavame z mapy SharedPreferences, kde kľúč je reťazec definovaný v xml súbore. Aplikovanie pravidla, že neberieme mená s jedným výskytom do úvahy vykonáme pomocou funkcie filter, ktorú zapíšeme pomocou lambda výrazu (lambda výrazy si vysvetlíme detailnejšie čoskoro). Do zátvoriek {} vložíme výraz, ktorý je typu boolean. Hodnotait zodpovedá jednej položke v zozname. Ak je výraz true, tak sa príslušná položka použije do výsledného zoznamu. Ak je false, tak sa ignoruje.

Voliteľne odporúčam vyskúšať si pridať do nastavení možnosť výberu počtu mien, ktoré sa budú zobrazovať.

Menu sme dostali vygenerované pri vytvorení projektu s BasicActivity. Ak chceme definovať menu položky, potrebujeme:

Nižšie je kód, ktorým pridáme ďalšiu položku, ktorá bude iniciovať stiahnutie zoznamu mien z REST servra.

Jednotlivé položky item môžu mať priradenú aj ikonu (android:icon). Zároveň môžeme upraviť nastavenie app:showAsAction:

Pomocou atribútu orderInCategory vieme položky v menu usporiadať.

/res/menu/menu_main.xml

<menu ...>
    
    ...

    <item
        android:id="@+id/action_download"
        android:title="@string/download_names"
        app:showAsAction="never" />

</menu>

MainActivity.kt

...

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    ...

    override fun onOptionsItemSelected(item: MenuItem): Boolean {
         return when (item.itemId) {
            ...
             
            R.id.action_download -> {
                viewModel.storeNames()
                true
            }

			...
        }
    }

}

res/values/strings.xml

<resources>
    ...
    <string name="download_names">Download names</string>
</resources>

Nasledovné zmeny postačujú, aby bola aplikácia hrateľná.

V hlavnej aktivite sa udeje nasledovné:

MainActivity.kt

import android.widget.Toast
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
	
    ...
    private lateinit var adapter: NamesAdapter
    private var isGameOn = false

    private val itemTouchHelper by lazy {
        val callback = object : ItemTouchHelper.SimpleCallback(
            ItemTouchHelper.UP or ItemTouchHelper.DOWN
                    or ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT, 0
        ) {
            override fun onMove(
                recyclerView: RecyclerView,
                viewHolder: RecyclerView.ViewHolder,
                target: RecyclerView.ViewHolder
            ): Boolean {
                val from = viewHolder.adapterPosition
                val to = target.adapterPosition
                adapter.exchangeItems(from, to)
                return true
            }

            override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
                // nothing
            }

        }
        ItemTouchHelper(callback)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
       	...
        itemTouchHelper.attachToRecyclerView(binding.included.recyclerViewNames)

        binding.fab.setOnClickListener {
            if (isGameOn) {
                val text = if (adapter.win) "Vyhra" else "Prehra"
                Toast.makeText(this, text, Toast.LENGTH_LONG).show()
            } else {
                adapter.submitList(getRandomList(4))
            }
            adapter.visibleCounts = isGameOn
            isGameOn = !isGameOn
        }

     	...

    }

    ...

}

V adaptéri sa udeju nasledovné zmeny:

NamesAdapter.kt

...
import android.view.View
import java.util.Collections

class NamesAdapter() :
    ListAdapter<Record, NamesAdapter.NamesViewHolder>(DiffCallback) {

    var visibleCounts = false
        set(value) {
            field = value
            notifyItemRangeChanged(0, currentList.size)
        }

    val win: Boolean
        get() {
            currentList.zipWithNext { a, b -> if (a.count < b.count) return false }
            return true
        }

    class NamesViewHolder(val binding: ItemLayoutBinding) :
        RecyclerView.ViewHolder(binding.root) {

        fun bind(
            record: Record, visibleCounts: Boolean,
        ) {
           	...
            binding.textViewNumber.visibility = if (visibleCounts) View.VISIBLE else View.INVISIBLE

        }
    }

    fun exchangeItems(from: Int, to: Int) {
        val list = currentList.toMutableList()
        Collections.swap(list, from, to)
        submitList(list)
    }

    ...

    override fun onBindViewHolder(holder: NamesViewHolder, position: Int) {
        holder.bind(getItem(position), visibleCounts)
    }

    ...

}

Higher-order functions and lambdas - dokumentácia ku Kotlinu

Zopár citácii:

Kotlin functions are first-class, which means they can be stored in variables and data structures, and can be passed as arguments to and returned from other higher-order functions. You can perform any operations on functions that are possible for other non-function values.

A higher-order function is a function that takes functions as parameters, or returns a function.

Lambda expressions and anonymous functions are function literals. Function literals are functions that are not declared but are passed immediately as an expression.

Premenná, literál

Prezrime si nasledovný kód v Jave:

public class Trieda {
    
    public int funkcia(String s, int a) {
        // priradenie do premennej a
        a = a + 1;
        // použitie hodnoty premennej a
        if (a < 0) {
            return 0;
        }
        // volanie metódy nad objektom referencovanom premennou s
        return s.charAt(a);
    }

    public void main(String[] args) {
        Trieda objekt = new Trieda();
        // literál - konkrétna hodnota
        String s = "Android";
        // parametre - referenčná premenná s, literál (hodnota) 4 
        int vysledok = objekt.funkcia(s, 4)
    }
}

Zopár komentárov, ktoré by mali byť zrejmé:

Funkcia ako parameter

Predstavme si, že by Java povoľovala vložiť do premennej aj funkciu:

// tento kód sa nedá kompilovať, slúži len ako ilustrácia vysvetľovaného princípu 
public class Trieda {
    
    public int funkcia(String s, int a) {
       ... // kód sa nemení
    }
    
    public int novaFunkcia(Funkcia fun, String s) {
        int vysledok = fun(s, 1);
        return vysledok;
    }
    
    public void main(String[] args) {
        Trieda objekt = new Trieda();
        int vysledok = objekt.novaFunkcia(objekt.funkcia, "Android")
    }
}

Vyššie uvedený kód obsahuje nasledovné:

Funkcia ako typ, literál

Upravme ešte predošlý kód nasledovne:

// tento kód sa nedá kompilovať, slúži len ako ilustrácia vysvetľovaného princípu 
public class Trieda {
    
    public int novaFunkcia((String, int)->(int) fun, String s) {
        int vysledok = fun(s, 1);
        return vysledok;
    }
    
    public void main(String[] args) {
        Trieda objekt = new Trieda();
        int vysledok = objekt.novaFunkcia({s, a -> s.charAt(a);}, "Android")
    }
}

Takýto kód sa v Jave síce urobiť nedá, ale obsahuje nasledovné:

Konkrétne príklady z Kotlinu

Všimnime si zápis funkcie, ktorá príde ako parameter. Funkcie vyššieho rádu môžu prijať funkciu ako parameter alebo môžu vrátiť funkciu.

fun novaFunkcia(s: String, fun: (String, Int) -> Int): Int
fun filter(predicate: (T) -> Boolean): List<T>

Lambda výraz je literálom funkcie. Sú vložené ako výraz.

1.| objekt.novaFunkcia("Android"){s, a -> s.charAt(a)}
2.| objekt.novaFunkcia("Android", ::funkcia)
3.| list.filter{it.length > 5}
  1. Ak je funkcia posledným parametrom, môžeme lambda výraz písať v {} mimo zátvoriek (), teda akoby za volanie funkcie
  2. Ak chceme referencovať inú funkciu, použijeme ::. Nazýva sa to reflection.
  3. Ak má funkcia iba jeden parameter, nemusíme v lambda výraze zapisovať vstupné premenné, ale táto jedna vstupná premenná vystupuje ako it.

Presúvanie položiek je náročné, lebo na to aby sa s položkou dalo hýbať, je potrebné držať na nej prst relatívne dlho. Je to nepraktické. V nasledovnej zmene urobíme to, že keď sa klikne na obrázok (ikonu) na položke, tak sa začne presúvanie tejto položky. Využije sa funkcia startDrag, ktorú ponúka itemTouchHelper.

Prezrime si nasledovný kód, v ktorom nájdeme použitie funkcie vyššieho rádu a lambda výrazu. Zaregistrovať, či sa kliklo na ikonku vieme v triede NamesViewHolder, ale volania funkcií ItemTouchHelper je možné v hlavnej aktivite. V predošlej aplikácii sme tento mechanizmus implementovali pomocou pomocného rozhrania. Teraz využijeme funkcie vyššieho rádu a lambda výrazy.

V triede NamesAdapter a NamesViewHolder si všímame typ premennej onClick a volanie tejto funkcie.

V triede MainActivity si všímajme lambda výraz, ktorý hovorí, čo sa má po kliknutí vykonať.

NamesAdapter.kt

...
import android.view.MotionEvent

class NamesAdapter(val onClick: (RecyclerView.ViewHolder) -> Unit) :
    ListAdapter<Record, NamesAdapter.NamesViewHolder>(DiffCallback) {

	...
        
    class NamesViewHolder(val binding: ItemLayoutBinding) :
        RecyclerView.ViewHolder(binding.root) {

        fun bind(
            record: Record, visibleCounts: Boolean,
            onClick: (RecyclerView.ViewHolder) -> Unit
        ) {
            ...

            binding.reorderIcon.setOnTouchListener { _, motionEvent ->
                if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN)
                    onClick(this)
                return@setOnTouchListener true
            }
        }
    }

   	...
        
    override fun onBindViewHolder(holder: NamesViewHolder, position: Int) {
        holder.bind(getItem(position), visibleCounts, onClick)
    }

    ...

}

MainActivity.kt

...

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    ...
    
    override fun onCreate(savedInstanceState: Bundle?) {
     	...
        
        adapter = NamesAdapter {
            itemTouchHelper.startDrag(it)
        }
        ...
       
    }

    ...
}

Gitlab kód