MutableLiveData
a ViewModel
, ku ktorému majú fragmenty prístup cez svoju aktivitu.Navigation
definujúci akcie prechodov medzi fragmentami volaný pri jednotlivých kliknutiach na položku v RecyclerView
resp. na TextView
v detail fragmente.LiveData
ContentProvider
a ContentResolver
- získanie dát z androidu (z databázovej tabuľky vytvorenej mimo našej aplikácie)Permissions
Začíname s novým projektom, ktorý bude obsahovať jednu prázdnu aktivitu (empty views activity
).
Upozornenie: Odporúčam mať aktualizované Android Studio a AGP (Android Gradle plugin). Tento codelab bol pripravený s použitím Android Studio 2023.2.1 (Iguana) a Android Gradle plugin 8.3.1. Od verzie AGP 8.3 (ktorá sa nedá použiť v starších verziach Android Studio) sú tam zmeny v spôsobe ako sa pracuje so závislosťami (dependencies). V tomto codelabe bude spomenuté ako to zapísať aj v prípade, že použijete staršie verzie.
Cieľom aplikácie bude zobraziť zoznam hovorov (prijaté, neprijaté, odchádzajúce). Vytvorme triedu Call
, ktorá bude reprezentovaná troma údajmi (telefónne číslo, typ hovoru a dátum). Ak budeme vytvárať objekty manuálne, postačuje zadať v konštruktore telefónne číslo, zvyšné parametre majú priradené default hodnoty.
data class Call (
val number: String,
val type: Int = 0,
val date: String = ""
)
Doteraz sme videli prácu s lokálnymi údajmi iba v rámci jednej aplikácie (údaje boli uložené v SQLite databáze a pristupovalo sa k nic pomocou Room
knižnice), prípadne boli dáta dostupné cez REST API. Android poskytuje spôsob ako zdieľať dáta medzi aplikáciami (resp. medzi procesmi). Tento spôsob sa nazýva content provider.
Android poskytuje niektoré svoje údaje z telefónu pomocou príslušných poskytovateľov obsahu (viď zoznam). Napríklad existuje spôsob ako získať, prípadne modifikovať kontakty, informácie v kalendári, súbory médii a pod.
Z vyššie uvedeného zoznamu nás bude zaujímať trieda CallLog.Calls
(dokumentácia), ktorá obsahuje hodnoty NUMBER
, TYPE
a DATA
(odporúčam pozrieť v dokumentácii, čo reprezentujú). Tieto premenné iba označujú atribúty (názvy stĺpcov) z tabuľky hovorov, ktorá bude poskytnutá. Spôsob ako k týmto dátam pristupovať budeme vidieť v nasledovných krokoch.
Vytvoríme ViewModel
, ktorý poskytuje dáta pre aktivitu. Zatiaľ tam bude fixný zoznam telefonátov. Na konci tejto aplikácie budeme získavať tento zoznam z nášho telefónu.
import android.provider.CallLog
import androidx.lifecycle.ViewModel
class CallViewModel : ViewModel() {
val calls = listOf(
Call("0901234567"),
Call("0907888333"),
Call("0910405405"),
Call("112", type = CallLog.Calls.MISSED_TYPE)
)
}
Následne vytvoríme adaptér, ktorý bude využívať layout pre jednu položku už definovaný v androide (android.R.id.text1
). Rozšírením triedy ListAdapter
dostávame interný zoznam, ktorý je potom zobrazovaný v RecyclerView
.
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
class CallLogAdapter : ListAdapter<Call, CallLogAdapter.CallViewHolder>(DiffCallback) {
class CallViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val textView: TextView = itemView.findViewById(android.R.id.text1)
fun bind(item: Call) {
textView.text = item.number
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CallViewHolder {
val layout = LayoutInflater.from(parent.context)
.inflate(android.R.layout.simple_list_item_1, parent, false)
return CallViewHolder(layout)
}
override fun onBindViewHolder(holder: CallViewHolder, position: Int) {
holder.bind(getItem(position))
}
object DiffCallback : DiffUtil.ItemCallback<Call>() {
override fun areItemsTheSame(oldItem: Call, newItem: Call) =
oldItem.number == newItem.number
override fun areContentsTheSame(oldItem: Call, newItem: Call) =
oldItem == newItem
}
}
Ak chceme, môžeme pridať farebné zvýraznenie položiek podľa typu hovoru.
fun bind(item: Call) {
textView.text = item.number
if (item.type == CallLog.Calls.MISSED_TYPE) {
itemView.setBackgroundColor(Color.RED)
}
...
}
V tejto aplikácii budeme pridávať závislosti len v momente, keď budú potrebné. Na získanie referencie na ViewModel
v aktivite , resp. vo fragmente (fragmenty uvidíme a vysvetlíme v ďalšom kroku), využívame delegáta by viewModels()
. Pripomínam, že v Kotline môžeme delegovať gettre a settre nejakej premennej na niekoho iného. V tomto prípade je implementácia delegáta dostupná v Android KTX.
V našom prípade budeme vyžadovať dve závislosti. V staršej verzii Android Gradle plugin a Android Studio (viď popis pri prvom kroku v tomto codelabe) by sme to zapísali takto:
dependencies {
...
implementation("androidx.activity:activity-ktx:1.8.2")
implementation("androidx.fragment:fragment-ktx:1.6.2")
}
implementation
v rámci dependencies
Potrebné závislosti zapíšeme nasledovne:
dependencies {
implementation(libs.androidx.fragment.ktx)
implementation(libs.androidx.activity.ktx)
...
}
[versions]
...
fragment-ktx = "1.6.2"
activity-ktx = "1.8.2"
[libraries]
...
androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragment-ktx" }
androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity-ktx" }
...
Okrem toho je potrebné zapnúť viewBinding
na automatické prepojenie widgetov deklarovaných v xml
súboroch layoutu s kódom v aktivite (resp. fragmente).
android {
...
buildFeatures {
viewBinding true
}
}
Nastavovanie závislostí je možné vykonať aj cez Project Structure - nájdeme to v hlavnom menu (File->Project Structure).
Fragmenty, ktoré budeme používať, sa dajú aj vygenerovať. Tentokrát to nebudeme robiť - je tam dosť pre nás zbytočného kódu. Odporúčam si to ale pozrieť ako to vyzerá.
Odporúčam pozrieť do dokumentácie - Fragmenty v androide (okrem iného je tam obrázok ako využiť fragmenty pri rôznej veľkosti obrazovky).
A Fragment represents a reusable portion of your app's UI. A fragment defines and manages its own layout, has its own lifecycle, and can handle its own input events. Fragments can't live on their own. They must be hosted by an activity or another fragment.
V našej aplikácii vytvoríme dva fragmenty, ktoré budú hosťované aktivitou MainActivity
- kód pre túto aktivitu nebudeme nijak upravovať (iba sa upraví príslušný layout).
Vytvoríme si nový XML súbor pre layout fragmentu:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MasterFragment">
<androidx.recyclerview.widget.RecyclerView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/recyclerview1"/>
</FrameLayout>
FrameLayout
je jednoduchý kontainer vhodný na použitie predovšetkým ak sa v layoute nachádza jediný widget.
Fragmenty sa hosťujú v nejakej aktivite a bez nej nevedia fungovať. Pri fragmentoch je možné používať aj konštruktory. Avšak často sa dá fungovať s prekrytím dvoch funkcií onCreateView
a onViewCreated
. Jedna určuje ako sa má vykresliť vizuál a druhá, čo sa má stať po vykreslení. Neskôr si ukážeme jednoduchšie použitie. Avšak ak chceme využiť viewBinding
, potrebujeme prekryť obe tieto metódy.
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.LinearLayoutManager
import sk.upjs.calllog.databinding.FragmentMasterBinding
class MasterFragment : Fragment() {
private lateinit var binding: FragmentMasterBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentMasterBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val adapter = CallLogAdapter()
binding.recyclerview1.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerview1.adapter = adapter
val viewModel: CallViewModel by viewModels()
adapter.submitList(viewModel.calls)
}
}
Použitím requireContext
získame context (v našom prípade je contextom aktivita). Existuje aj metóda getContext
, resp. property context
, avšak pri použití requireContext
nemôžeme dostať null
.
V layoute aktivity pridáme fragment použitím widgetu FragmentContainerView
, kde špecifikujeme triedu, ktorá zodpovedá fragmentu.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/master_fragment"
android:name="sk.upjs.calllog.MasterFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
Aplikáciu si môžeme spustiť.
Druhý fragment bude zobrazovať detailnejšie informácie o jednotlivých hovoroch. Na začiatok si ale vyskúšame iba zobraziť druhý fragment bez obsahu (t.j. necháme tam ľubovoľný text).
Vytvorenie triedy fragmentu je jednoduché. V konštruktore označíme príslušný layout:
import androidx.fragment.app.Fragment
class DetailFragment : Fragment(R.layout.fragment_detail)
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
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="match_parent">
<TextView
android:id="@+id/detail_text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="HELLO"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
Pre nastavenie layoutu len pre určitú konfiguráciu:
activity_main
(resp. tak ako sa volá layout pre hlavnú aktivitu)Available qualifiers
vyberieme Orientation
a zvolíme landscape
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/master_fragment"
android:name="sk.upjs.calllog.MasterFragment"
android:layout_height="match_parent"
android:layout_width="0dp"
app:layout_constraintWidth_default="percent"
app:layout_constraintWidth_percent="0.4"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/detail_fragment"
app:layout_constraintTop_toTopOf="parent" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/detail_fragment"
android:name="sk.upjs.calllog.DetailFragment"
android:layout_height="match_parent"
android:layout_width="0dp"
app:layout_constraintWidth_default="percent"
app:layout_constraintWidth_percent="0.6"
app:layout_constraintStart_toEndOf="@id/master_fragment"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
Všimnite si v kóde ako je riešené rozvrhnutie šírky 40% vs 60%. Môžete sa pozrieť aj na ostatné nastavenia pre ConstraintLayout.
Aplikáciu môžeme spustiť. V landscape režime by malo byť vidieť recycler view v ľavej časti. Klikanie zatiať nefunguje.
To, že máme jeden layout pre rôzne konfigurácie implikuje nasledovné veci:
strings.xml
pre rôzne jazykové verzie.Bundle
).Vytvoríme ďalší ViewModel
, v ktorom si budú fragmenty zdieľať informáciu o aktuálne vybranom telefonáte. Obidva fragmenty sú prevádzkované rovnakou aktivitou a preto vedia mať prístup k spoločným dátam.
Použitie ViewModel
je bežné pre komunikáciu medzi fragmentmi. Nastáva situácia, že aplikácia má viac ako jeden ViewModel. V našom prípade jeden ViewModel slúži ako úložisko zoznamu hovorov, resp. miesto odkiaľ sa volajú korutíny na získanie týchto informácii. Druhý ViewModel slúži na komunikáciu medzi fragmentmi.
Schématický náčrt komunikácie:
Detailnejšie vysvetlenie lambda výrazov je v predošlom Codelabe (aplikácia opendatagame).
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
class SharedViewModel : ViewModel() {
val selectedCall = MutableLiveData<Call>()
fun select(call: Call) {
selectedCall.value = call
}
}
MutableLiveData
je také rozšírenie triedy LiveData
, že má prístupné funkcie setValue
a postValue
. Funkciu postValue
je vhodné použiť ak nastavujeme hodnotu z iného ako hlavného vlákna (na pozadí). V tomto prípade voláme funkciu setValue
- v Kotline využívame volanie settra priamo cez nastavenie hodnoty property value
.
import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
class DetailFragment : Fragment(R.layout.fragment_detail) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val textView: TextView = view.findViewById(R.id.detail_text_view)
val sharedViewModel : SharedViewModel by activityViewModels()
sharedViewModel.selectedCall.observe(viewLifecycleOwner) {
textView.text = it.toString()
}
}
}
Komentáre ku kódu pre DetailFragment
:
onCreateView
nie je potrebné prekrývať - vytvorenie layoutu je schované v konštruktore. Implementuje sa iba funkcia onViewCreated
, ktorá je volaná, keď sa fragment vykreslí.SharedViewModel
získavame cez delegáta by activityViewModels()
. Ak by bol použitý by viewModels()
, tak by kód nefungoval. Rozdiel je v scope - rozsahu platnosti príslušného View Modelu. Všimnime si, že dáta nie sú perzistentné (nie sú ukladané do DB alebo na iné miesto). V momente ak zanikne View Model, tak zanikne aj informácia o aktuálne vybranom objekte triedy Call
. Preto je životný cyklus tohto ViewModelu naviazaný na aktivitu, nie iba samotný fragment.observe
vkladáme vlastníka, ktorý kontroluje tento observer (vlastník je androidx.lifecycle.LifecycleOwner owner
). Doteraz sme používali this
, čo bola aktivita. Použitím viewLifecycleOwner
prenesieme zodpovednosť na Android, aby nám dodal vlastníka životného cyklu - on sa dopracuje k príslušnej aktivite. Samotný fragment nemôže byť vlastníkom.{}
za volanie metódy. Namiesto funkcia(premenná, lambda)
urobíme funkcia(premenná){lambda}
- viď metóda observe
vo fragmente....
class CallLogAdapter(private val onClick: (Call) -> Unit) :
ListAdapter<Call, CallLogAdapter.CallViewHolder>(DiffCallback) {
class CallViewHolder(
itemView: View,
private val onClick: (Call) -> Unit
) : RecyclerView.ViewHolder(itemView) {
...
fun bind(item: Call) {
...
textView.setOnClickListener {
onClick(item)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CallViewHolder {
val layout = LayoutInflater.from(parent.context)
.inflate(android.R.layout.simple_list_item_1, parent, false)
return CallViewHolder(layout, onClick)
}
...
}
Adaptér upravíme, aby reagoval na kliknutie na jeden telefonát spustením funkcie onClick
, ktorej lambda výraz (literál funkcie) implementujeme v hlavnom fragmente. Definuje sa iba typ premennej. V tomto prípade ide o funkciu, ktorá má nejaké vstupy a výstup (Call -> Unit
). Táto funkcia referencovaná premennou onClick
sa dá posunúť ako parameter inej funkcie alebo konštruktora (vidíme vo funkcii onCreateViewHolder
) alebo sa dá zavolať (vidíme vo funkcii bind
po zaznamenaní kliknutia na TextView
).
...
import androidx.fragment.app.activityViewModels
class MasterFragment : Fragment() {
...
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val sharedViewModel: SharedViewModel by activityViewModels()
val adapter = CallLogAdapter {
sharedViewModel.select(it)
}
...
}
}
MasterFragment
implementuje funkciu (pomocou lambda výrazu), ktorou nastavuje aktuálny Call
v zdieľanom View Modeli.
Aplikáciu si môžeme spustiť. DetailFragment
je zatiaľ zobrazený iba v landscape režime. Klikanie teda funguje iba keď je telefón otočený na šírku.
Navigation označuje interakcie, ktorými sa používateľ dostane na jednotlivé časti aplikácie. V našom prípade sa budeme pohybovať medzi jednotlivými fragmentmi. Tento koncept pozostáva z troch základnych zložiek:
[versions]
...
navigation = "2.7.7"
[libraries]
...
navigation-fragment-ktx = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigation" }
navigation-ui-ktx = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigation" }
...
dependencies {
implementation(libs.navigation.fragment.ktx)
implementation(libs.navigation.ui.ktx)
...
}
Okrem toho je možné použiť aj Safe Args - je to odporúčane, ale pri Jetpack Compose
sa to nepoužíva, takže to nemusíme skúšať.
Vytvoríme v resources New Resources File:
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/navigation"
app:startDestination="@id/master_fragment">
<fragment
android:id="@+id/master_fragment"
android:name="sk.upjs.calllog.MasterFragment"
android:label="fragment_master">
<action
android:id="@+id/action_from_master_to_detail"
app:destination="@id/detail_fragment" />
</fragment>
<fragment
android:id="@+id/detail_fragment"
android:name="sk.upjs.calllog.DetailFragment"
android:label="fragment_detail">
<action
android:id="@+id/action_from_detail_to_master"
app:destination="@id/master_fragment" />
</fragment>
</navigation>
Všimnite si pri navigation.xml
aj vizuálne zobrazenie grafu, nie iba xml
kód.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
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"
tools:context=".MainActivity">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/hosting_fragment_container"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:navGraph="@navigation/navigation" />
</androidx.constraintlayout.widget.ConstraintLayout>
Aktivita zobrazuje landscape layout na šírku, kde zobrazí oba fragmenty vedľa seba. V opačnom prípade (portrait režim) sa použije layout, kde je vložený jeden kontainer na fragment. V tomto prípade je to príslušný NavHostFragment
. Nahradíme názov nášho MasterFragment
označením androidx.navigation.fragment.NavHostFragment
.
...
import androidx.navigation.findNavController
class MasterFragment : Fragment() {
...
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
...
val adapter = CallLogAdapter {
sharedViewModel.select(it)
val container: View? = activity?.findViewById(R.id.hosting_fragment_container)
if (container != null) {
view.findNavController().navigate(R.id.action_from_master_to_detail)
}
}
...
}
}
MasterFragment
je upravený tak, aby po kliknutí na jednu položku v RecyclerView
boli aktualizované LiveData
v zdieľanom ViewModel
a zároveň, aby sa prepol fragment na obrazovke na ten, kde je zobrazenie detailov o jednom telefonáte.
Podmienka if (container != null)
slúži na rozlíšenie natočenia zariadenia. Pri landscape
režime widget s príslušným id
neexistuje. Pri portrait
režime existuje, a preto voláme kontroler, aby uskutočnil navigačnú cestu reprezentovanú akciou - hranou v grafe.
...
import androidx.navigation.findNavController
class DetailFragment : Fragment(R.layout.fragment_detail) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
...
val container: View? = activity?.findViewById(R.id.hosting_fragment_container)
if (container != null) {
textView.setOnClickListener {
view.findNavController().navigate(R.id.action_from_detail_to_master)
}
}
}
}
DetailFragment
sa prehodí na MasterFragment
po kliknutí na TextView
.
Content Provider je v androide komponent, ktorý poskytuje rozhranie na prístup k dátam aj z iných aplikácii. Content resolver je spôsob ako k týmto dátam pristupovať a vykonávať operácie (CRUD).
...
import android.app.Application
import androidx.lifecycle.AndroidViewModel
class CallViewModel(application: Application) : AndroidViewModel(application) {
...
private val contentResolver by lazy {
application.contentResolver
}
}
Triedu CallViewModel
zmeníme, aby rozširovala triedu AndroidViewModel
. Získame tak prístup k application
kontextu, pomocou ktorého máme dostupný contentResolver
.
Vytvoríme premennú contentResolver
na prístup k dátam. Použijeme delegát by lazy
, čo znamená, že sa hodnota priradí do premennej až v momente prvého zavolania getter metódy.
Následne načítame dáta z tabuľky. Jednotlivé položky (stĺpce z databázovej tabuľky) sú popísané v dokumentácii triedy CallLog.Calls. Content resolver query má viacero parametrov, ktoré v tomto prípade nepoužijeme.
...
import android.provider.CallLog
import java.text.SimpleDateFormat
import java.util.Locale
class CallViewModel(application: Application) : AndroidViewModel(application) {
...
fun loadCalls(): List<Call> {
val calls = mutableListOf<Call>()
val cursor = contentResolver.query(
CallLog.Calls.CONTENT_URI, null, null,
null, null, null
) ?: return emptyList()
if (cursor.count > 0) {
val numberColIdx = cursor.getColumnIndex(CallLog.Calls.NUMBER)
val typeColIdx = cursor.getColumnIndex(CallLog.Calls.TYPE)
val dateColIdx = cursor.getColumnIndex(CallLog.Calls.DATE)
val simpleDateFormat = SimpleDateFormat("dd/MM/yyyy HH:mm:ss", Locale.ENGLISH)
while (cursor.moveToNext()) {
val number = cursor.getString(numberColIdx)
val type = cursor.getInt(typeColIdx)
val dateLong = cursor.getLong(dateColIdx)
val date = simpleDateFormat.format(dateLong)
calls.add(Call(number, type, date))
}
}
cursor.close()
return calls
}
}
Komentár k čítaniu dát pomocou ContentResolver
:
contentResolver
poskytuje funkciu query
) na vykonanie dopytu do databázy. Nepíšeme priamo SQL dopyt, ale vieme ho takto vyskladať. Query
obsahuje parametre na projekciu (výber stĺpcov resp. atribútov), selekciu (definovanie WHERE) a poradie (ORDER BY). V tomto prípade použijeme všade null
a vytiahneme celú tabuľku.query
obsahuje Uri
(Uniform Resource Identifier), ktorý identifikuje obsah (tabuľku), ktorý je sprístupnený content providerom. Pre zoznam hovorov si vieme URI nájsť v premennej CallLog.Calls.CONTENT_URI
, čo má v skutočnosti hodnotu content://call_log/calls
.Cursor
slúži ako iterátor výslednej množiny. Použitie vidíme v predošlom kóde, ktorý prejde všetky záznamy vo výsledku a transformuje ich na jednotlivé objekty triedy Call
, ktoré sú vložené do výsledného zoznamu.SimpleDateFormat
je trieda z Javy (Kotlin vie volať aj Java triedy a metódy).V triede CallsViewModel
vytvoríme vlastné LiveData
. ContentResolver
poskytuje observer. Pri zmene dát v databázovej tabuľke sa aktualizujú LiveData
používané v našej aplikácii.
Pri použití databázy je toto robené automaticky cez knižnicu Room
(spomeňte si na volanie toLiveData()
).
...
import android.database.ContentObserver
import androidx.lifecycle.LiveData
class CallViewModel(application: Application) : AndroidViewModel(application) {
val calls = object : LiveData<List<Call>>() {
lateinit var observer: ContentObserver
override fun onActive() {
observer = object : ContentObserver(null) {
override fun onChange(selfChange: Boolean) {
value = loadCalls()
}
}
contentResolver.registerContentObserver(CallLog.Calls.CONTENT_URI, true, observer)
observer.onChange(true)
}
override fun onInactive() {
contentResolver.unregisterContentObserver(observer)
}
}
...
}
observer.onChange(true)
spôsobí, že sa pri zaregistrovaní na sledovanie zmien automaticky načítajú dáta.object
v Kotline označuje singleton objekt. V princípe ide o anonymnú triedu, ktorej inštancia sa priamo vytvorí.value=loadCalls()
- hodnota value
je property anonymnej triedy rozširujúcej LiveData
, pomocou ktorej sa nastaví aktuálna hodnota liveData
. Nastavovanie hodnoty vyzeralo podobne pri zdieľanom view modeli.onActive
sa zavolá, keď sa niekto (observer) zaregistruje na sledovanie dát. V momente, keď observer prestane sledovať (napr. ak zanikne), tak sa zavolá onInactive
....
class MasterFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
...
val viewModel: CallViewModel by viewModels()
viewModel.calls.observe(viewLifecycleOwner) {
adapter.submitList(it)
}
}
}
Aktuálna aplikácia spadne s FATAL EXCEPTION java.lang.SecurityException: Permission Denial: opening provider com.android.providers.contacts.CallLogProvider
.
V manifest.xml
pridáme permission na prístup k histórii hovorov READ_CALL_LOG
.
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.READ_CALL_LOG" />
...
</manifest>
V túto chvíľu vieme používať aplikáciu ak v nastaveniach telefónu priradíme príslušné povolenie (v sekcii aplikácie, povolenia a pod.).
V MasterFragment
presunieme všetok kód z metódy onViewCreated
do samostatnej metódy init
. Tento kód sa zavolá, len ak aplikácia bude mať od používateľa udelené povolenie.
...
class MasterFragment : Fragment() {
...
private fun init(){
val sharedViewModel: SharedViewModel by activityViewModels()
val adapter = CallLogAdapter {
sharedViewModel.select(it)
val container: View? = activity?.findViewById(R.id.hosting_fragment_container)
if (container != null) {
view?.findNavController()?.navigate(R.id.action_from_master_to_detail)
}
}
binding.recyclerview1.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerview1.adapter = adapter
val viewModel: CallViewModel by viewModels()
viewModel.calls.observe(viewLifecycleOwner) {
adapter.submitList(it)
}
}
}
Následný spôsob získavania permission je popísaný v dokumentácii vrátane diagramov. V princípe je potrebné pred prácou s kódom, ktorý závisí na nejakom povolení, najprv si to povolenie vyžiadať. Ak bolo permission zamietnuté zo strany používateľa, zvykne sa pri opätovnom vyžiadaní si povolenia poskytnúť viac informácii (viď showRequestPermissionRationale
).
...
import android.content.pm.PackageManager
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
class MasterFragment : Fragment() {
...
private val requestPermissionLauncher =
registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
init()
} else {
Toast.makeText(requireContext(), "PERMISSION MISSING", Toast.LENGTH_SHORT).show()
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
when {
ContextCompat.checkSelfPermission(
requireContext(), android.Manifest.permission.READ_CALL_LOG
) == PackageManager.PERMISSION_GRANTED -> {
// mam povolenie
init()
}
shouldShowRequestPermissionRationale(android.Manifest.permission.READ_CALL_LOG) -> {
// tu bude alert dialog
Toast.makeText(requireContext(), "NO PERMISSION", Toast.LENGTH_SHORT).show()
}
else -> {
requestPermissionLauncher.launch(android.Manifest.permission.READ_CALL_LOG)
}
}
}
private fun init() {
...
}
}
Aplikáciu si môžeme spustiť a sledovať správanie.
Funguje nasledovne:
onViewCreated
) može nastať jeden z 3 prípadov:no permission
)missing permission
. Tento krok je definovaný v premennej requestPermissionLauncher
.Pri vytváraní vlastných aplikácii odporúčam pozrieť v dokumentácii na prácu s fragmentmi, navigation a permissions.
Často je vhodná vec na použitie dialog fragment
.