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.LiveDataContentProvider a ContentResolver - získanie dát z androidu (z databázovej tabuľky vytvorenej mimo našej aplikácie)PermissionsNa začiatok si vytvoríme nový projekt s jednou prázdnou aktivitou (empty activity).
Základnou entitou bude informácia o jednom telefonáte. Vytvoríme triedu Call, ktorá bude uchovávať nejakú časť z informácii dostupných v androide (CallLog.Calls). Vytvoríme tiež ViewModel, ktorý bude (zatiaľ) poskytovať fixný zoznam telefonátov. Ten neskôr vytiahneme z androidu.
data class Call (
val number:String,
val type:Int = 0,
val date:String = ""
)
import androidx.lifecycle.ViewModel
class CallViewModel : ViewModel() {
val calls = listOf(
Call("0901234567"),
Call("112"),
Call("09078883332"),
Call("0910405405")
)
}
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.OUTGOING_TYPE) {
itemView.setBackgroundColor(Color.RED)
}
...
}
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á.
Fragmenty musia byť prevádzkované aktivitou. V našej aplikácii vytvoríme dva fragmenty, ktoré budú v rámci MainActivity - kód pre túto aktivitu nie je potrebné upravovať.
<?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>
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í.
import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager
import sk.upjs.vma.calllog.databinding.FragmentMasterBinding
class MasterFragment : Fragment() {
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.
ViewBinding je potrebné aktivovať v build.gradle. Aby bolo možné získať viewModel vo fragmente, je potrebné doplniť dependency (s príslušnou verziou knižníc).
android {
...
buildFeatures {
viewBinding true
}
}
dependencies {
...
implementation 'androidx.activity:activity-ktx:1.6.1'
implementation 'androidx.fragment:fragment-ktx:1.5.5'
}
import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
class DetailFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return inflater.inflate(R.layout.fragment_detail, container, false)
}
}
<?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=".DetailFragment">
<TextView
android:id = "@+id/detail_text_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="@string/hello_blank_fragment" />
</FrameLayout>
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"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="match_parent">
<fragment
class="sk.itsovy.android.calllog.MasterFragment"
android:id = "@+id/master_fragment"
android:layout_height = "match_parent"
android:layout_width = "0dp"
android:layout_weight = "1"
/>
<fragment
class="sk.itsovy.android.calllog.DetailFragment"
android:id = "@+id/detail_fragment"
android:layout_height = "match_parent"
android:layout_width = "0dp"
android:layout_weight = "2"
/>
</LinearLayout>
Widget fragment môže byť nahradený androidx.fragment.app.FragmentContainerView.
Aplikáciu môžeme spustiť. V landscape režime by malo byť vidieť recycler view v ľavej tretine. Klikanie zatiaťľ nefunguje.
Dve poznámky k layoutom:
LinearLayout a FrameLayout sú alternatívy k nášmu často používanému ConstraintLayout. V súčasnosti sa používajú len pri jednoduchších prípadoch FrameLayout ak je tam len jeden widget a LinearLayout, keď sú položky vedľa seba, resp. pod sebou.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.
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
class SharedViewModel : ViewModel() {
val selectedCall = MutableLiveData<Call>()
fun select(call: Call) {
selectedCall.value = call
}
}
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.
class CallLogAdapter(private val onClick: (Call) -> Unit)
: ListAdapter<Call, CallLogAdapter.CallViewHolder>(DiffCallback) {
class CallViewHolder(itemView: View, private val onClick: (Call) -> Unit)
: RecyclerView.ViewHolder(itemView) {
private val textView: TextView = itemView.findViewById(android.R.id.text1)
fun bind(item: Call) {
textView.text = item.number
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)
}
...
Oba fragmenty použijú vybraný telefonát - MasterFragment nastaví hodnotu a DetailFragment bude sledovať zmeny tejto hodnoty.
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()
}
}
val adapter = CallLogAdapter{
sharedViewModel.select(it)
}
V DetailFragment sa ViewModel získava pomocou activityViewModels namiesto viewModels. Dôvod uvidíme neskôr - budeme potrebovať ActivityViewModel kvôli dostupnosti referencie na objekt triedy Application.
Pri funkcii observe na live dátach potrebujeme dať ako parameter viewLifecycleOwner, čím prenesieme zodpovednosť na android, aby nám dodal vlastníka životného cyklu - on sa dopracuje k príslušnej aktivite.
Ak je v nejakej funkcii ako posledný parameter iná funkcia - teda na posledné miesto ide lambda výraz, tak tú funkciu môžeme zapísať aj tak, že lambdu píšeme do {} za volanie metódy. Teda namiesto funkcia(premenná, lambda) urobíme funkcia(premenná){lambda} - viď metóda observe vo fragmente.
plugins {
...
id 'androidx.navigation.safeargs'
}
dependencies {
def nav_version = "2.3.5"
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
...
}
buildscript {
repositories {
google()
}
dependencies {
classpath 'androidx.navigation.safeargs.kotlin:androidx.navigation.safeargs.kotlin.gradle.plugin:2.5.3'
}
}
...
V build.gradle musí ísť buildscript pred sekciou plugins.
Vytvoríme v resources New -> Android Resource Directory a vyberieme typ navigation. V príslušnom novom súbore bude popis prechodov medzi fragmentami.
<?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"
xmlns:tools="http://schemas.android.com/tools"
app:startDestination="@id/master_fragment"
android:id="@+id/navigation">
<fragment
android:id = "@+id/master_fragment"
android:name = "sk.upjs.vma.calllog.MasterFragment"
android:label = "fragment_master"
tools:layout = "@layout/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.vma.calllog.DetailFragment"
android:label = "fragment_detail"
tools:layout = "@layout/fragment_detail"
>
<action
android:id = "@+id/action_from_detail_to_master"
app:destination = "@id/master_fragment"
/>
</fragment>
</navigation>
<?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:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<androidx.fragment.app.FragmentContainerView
android:id = "@+id/nav_host_fragment"
android:name = "androidx.navigation.fragment.NavHostFragment"
android:layout_height="match_parent"
android:layout_width="match_parent"
app:defaultNavHost = "true"
app:navGraph="@navigation/navigation"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
import androidx.navigation.findNavController
...
val adapter = CallLogAdapter{
sharedViewModel.select(it)
// zistit ci som na vysku (portrait)
val container: View? = activity?.findViewById(R.id.nav_host_fragment)
if (container != null) {
view.findNavController().navigate(MasterFragmentDirections.actionFromMasterToDetail())
}
}
import androidx.navigation.fragment.findNavController
...
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
...
val container: View? = activity?.findViewById(R.id.nav_host_fragment)
if (container != null) {
textView.setOnClickListener {
findNavController().navigate(DetailFragmentDirections.actionFromDetailToMaster())
}
}
}
Rozoznanie, či ide o landscape alebo portrait režim je na základe toho, či v príslušnom layoute existuje element s daným ID.
Triedu CallsViewModel zmeníme, aby rozširovala triedu AndroidViewModel. Získame tak prístup k application context.
class CallsViewModel(application: Application) : AndroidViewModel(application){
Vytvoríme premennú contentResolver na prístup k dátam.
private val contentResolver by lazy {
application.contentResolver
}
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.
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")
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
}
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()).
val calls = object : LiveData<List<Call>>() {
lateinit var observer: ContentObserver
override fun onActive() {
observer = object : ContentObserver(null) {
override fun onChange(selfChange: Boolean) {
postValue(loadCalls())
}
}
contentResolver.registerContentObserver(CallLog.Calls.CONTENT_URI, true, observer)
observer.onChange(true)
}
override fun onInactive() {
contentResolver.unregisterContentObserver(observer)
}
}
Volanie observer.onChange(true) spôsobí, že sa pri zaregistrovaní na sledovanie zmien automaticky načítajú dáta.
V MasterFragment je potrebné aktuálne volanie adapter.submitList(model.calls) zameniť za
model.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.
<uses-permission android:name="android.permission.READ_CALL_LOG" />
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.
private fun init(){
...
}
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).
Pri vlastných jednoduchých aplikáciach sa vieme vyhnúť takémuto kódu tak, že povolenie môžeme udeliť priamo v nastaveniach telefónu (v sekcii aplikácie, povolenia a pod.).
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
}
else -> {
requestPermissionLauncher.launch(android.Manifest.permission.READ_CALL_LOG)
}
}
}
Pridáme potrebné importy a aplikáciu spustíme.