Attaching mapbox map to livedata - android

My goal is to use MapBox and connect the view to the data set containing GeoJSON polygons. I am already able to get changes from the dataset through a LiveData<List> where Case among other things contains an GeoJSON area.
Now I want to listen to changes to this data set from the ViewModel and bind the results to a specific layer in the map. I haven't been able to find any examples on how to do this as most samples does not use data binding or ViewModel with LiveData.
data class Case (
var id : String,
var feature : Feature,
var note : String?
)
Note: The Feature is a MapBox.Feature that implements GeoJSON and it is a polygon.
I am already able to listen for the changes on the list of cases but haven't figured out how to connect the remaining pieces. Can you help me on the steps here or maybe point to a good example for this? A possible answer could be some explanations along with some pheudo code.
<com.mapbox.mapboxsdk.maps.MapView
android:id="#+id/MyMapView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="#id/jobSelectionJobTitle"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:mapbox_cameraZoom="12" />
And a bonus question: I am not sure how much to put in the activity and what to put into the ModelView.

This example uses :
Data Binding
Binding Adapters
LiveData
Kotlin Coroutines
com.mapbox.mapboxsdk.style.sources.GeoJsonSource
com.mapbox.mapboxsdk.style.layers.Layer
The aim to bind GeoJsonSource and Layer objects to the MapView via databinding and LiveData. Also provide a mechanism to remove layers by their "id". I have deliberably left implementation of LiveData blank as I have no means of how this data is supplied, but provide the mechanism by which this can be achieved. This mechanism can be adapted to suit your needs.
Data Classes :
data class GeoJsonLayer(val source: GeoJsonSource, val layer: Layer)
Binding Adapters (place in separate kotlin file for global access)
#BindingAdapter(value = ["addGeoJsonLayers", "addCoroutineScope"], requireAll = true)
fun MapView.addGeoJsonLayers(layers: List<GeoJsonLayer>?, scope: CoroutineScope?) {
layers?.let { list ->
scope?.launch {
getStyle()?.run {
list.filter { getSource(it.source.id) == null }
.forEach { jsonLayer ->
addSource(jsonLayer.source)
addLayer(jsonLayer.layer)
}
}
}
}
}
#BindingAdapter(value = ["removeLayers", "removeCoroutineScope"], requireAll = true)
fun MapView.removeLayers(ids: List<String>?, scope: CoroutineScope?) {
ids?.let { list ->
scope?.launch {
getStyle()?.run {
list.forEach { id ->
removeLayer(id)
removeSource(id)
}
}
}
}
}
Coroutine Extension Functions (place in separate kotlin file for global access)
suspend fun MapView.getMap(): MapboxMap = suspendCoroutine { cont -> getMapAsync { cont.resume(it) } }
suspend fun MapView.getStyle(): Style? = getMap().style
Example ViewModel contract
abstract class MapViewModel : ViewModel() {
abstract val addGeoJsonLayers: LiveData<List<GeoJsonLayer>>
abstract val removeGeoJsonLayers: LiveData<List<String>>
}
XML Layout (layout/map_view.xml)
<data>
<import type="android.view.View" />
<variable
name="mapVm"
type="your.package.MapViewModel" />
<variable
name="scope"
type="kotlinx.coroutines.CoroutineScope" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="#+id/constraintLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.mapbox.mapboxsdk.maps.MapView
android:id="#+id/map_view"
android:layout_width="0dp"
android:layout_height="0dp"
app:addCoroutineScope="#{scope}"
app:addGeoJsonLayers="#{mapVm.addGeoJsonLayers}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:removeCoroutineScope="#{scope}"
app:removeLayers="#{mapVm.removeGeoJsonLayers}" />
</androidx.constraintlayout.widget.ConstraintLayout>
Example Activity
class MapActivity : AppCompatActivity() {
private lateinit var binding: MapViewBinding
private val viewModel: ConcreteMapViewModel by viewModels() // implementation of MapViewModel
override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) {
super.onCreate(savedInstanceState, persistentState)
binding = MapViewBinding.inflate(layoutInflater).apply {
setContentView(root)
lifecycleOwner = lifecycleOwner
scope = lifecycleOwner.lifecycleScope
mapView.onCreate(savedInstanceState)
lifecycleOwner.lifecycleScope.launch {
mapView.getMap().setStyle(Style.MAPBOX_STREETS)
}
mapVm = viewModel
}
}
override fun onStart() {
super.onStart()
binding.mapView.onStart()
}
override fun onResume() {
super.onResume()
binding.mapView.onResume()
}
override fun onPause() {
super.onPause()
binding.mapView.onPause()
}
override fun onStop() {
super.onStop()
binding.mapView.onStop()
}
override fun onDestroy() {
super.onDestroy()
binding.mapView.onDestroy()
}
override fun onLowMemory() {
super.onLowMemory()
binding.mapView.onLowMemory()
}
}
Gradle file setup (kts - kotlin DSL) :
plugins {
id("com.android.application")
id("kotlin-android")
id("kotlin-kapt")
}
android {
compileSdk = 30
buildToolsVersion = "30.0.3"
defaultConfig {
applicationId = "com.package.name"
minSdk = 24
targetSdk = 30
versionCode = 1
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
named("release") {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
dataBinding = true
}
}
dependencies {
// Kotlin
implementation("org.jetbrains.kotlin:kotlin-stdlib:1.5.10")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0")
// AndroidX
val lifecycleVersion = "2.3.1"
val navVersion = "2.3.5"
implementation("androidx.core:core-ktx:1.5.0")
implementation("androidx.appcompat:appcompat:1.3.0")
implementation("androidx.constraintlayout:constraintlayout:2.0.4")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion")
// MapBox
implementation("com.mapbox.mapboxsdk:mapbox-android-sdk:9.6.1")
implementation("com.google.code.gson:gson:2.8.7")
// Logging
implementation("com.jakewharton.timber:timber:4.7.1")
testImplementation("junit:junit:4.13.2")
}

Related

Recycler View doesn't update with livedata.observe()

I have a BluetoothService Class which offers BLE Services like scanning and holding a MutableList of known devices. When scanning it adds new devices to the list and posts them with the postValue() function to notify about the change.
My goal is to bring this data to the frontend and listing the found devices. I tried following the android widgets-sample and almost have the same code as there except for replacing the ViewModelFactory as its deprecated.
I checked my MutableLiveData List in the debugger and they are in fact up to date. I suspect the observer not being registered correctly, as it fires.
My Recycler View looks like this:
<androidx.recyclerview.widget.RecyclerView
android:id="#+id/listFoundDevices"
android:layout_width="347dp"
android:layout_height="352dp"
android:layout_marginTop="28dp"
android:background="#CD3131"
android:visibility="visible"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:listitem="#layout/recycler_view_item" />
recycler_view_item:
<TextView
android:id="#+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="66dp"
android:layout_marginTop="3dp"
android:textColor="#android:color/black"
android:textSize="20sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="#tools:sample/full_names" />
MainActivity shortened:
private val mainActivityViewModel by viewModels<MainActivityViewModel>()
private val bluetoothService = BluetoothService()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
val peripheralAdapter = PeripheralAdapter {peripheral -> adapterOnClick(peripheral)}
val recyclerView: RecyclerView = findViewById(R.id.listFoundDevices)
recyclerView.adapter = peripheralAdapter
recyclerView.layoutManager = LinearLayoutManager(this)
setSupportActionBar(binding.toolbar)
mainActivityViewModel.scanLiveData.observe(this) {
it?.let {
//Never fires the message despite updating the liveData with postValue()
println("Got an Update!")
peripheralAdapter.submitList(it as MutableList<MyPeripheral>)
}
}
}
private fun adapterOnClick(peripheral: MyPeripheral){
print("clicked on item")
}
ViewModel:
class MainActivityViewModel (private var bluetoothService: BluetoothService): ViewModel() {
private val repository by lazy { bluetoothService.scanLiveData }
val scanLiveData = MutableLiveData(repository.value)
}
PeripheralAdapter:
class PeripheralAdapter(private val onClick: (MyPeripheral) -> Unit) :
ListAdapter<MyPeripheral, PeripheralAdapter.PeripheralViewHolder>(PeripheralDiffCallback) {
class PeripheralViewHolder(itemView: View, val onClick: (MyPeripheral) -> Unit) :
RecyclerView.ViewHolder(itemView) {
private val peripheralTextView: TextView = itemView.findViewById(R.id.textView)
private var currentPeripheral: MyPeripheral? = null
init {
itemView.setOnClickListener {
currentPeripheral?.let {
onClick(it)
}
}
}
fun bind(peripheral: MyPeripheral) {
currentPeripheral = peripheral
peripheralTextView.text = peripheral.name
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PeripheralViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.recycler_view_item, parent, false)
return PeripheralViewHolder(view, onClick)
}
override fun onBindViewHolder(holder: PeripheralViewHolder, position: Int) {
val peripheral = getItem(position)
holder.bind(peripheral)
}
}
BluetoothService:
var knownPeripherals = ArrayList<MyPeripheral>()
var scanLiveData = MutableLiveData(knownPeripherals)
fun handleDevice(result: ScanResult, data: ByteArray?) {
val peripheral = knownPeripherals.find { it.serialNumberAdv == foundSN }
if (peripheral == null){
knownPeripherals.add(MyPeripheral(this, result))
updateMutableData()
}
}
private fun updateMutableData(){
scanLiveData.postValue(knownPeripherals)
}
A hint why it's not working out would be appreciated.
I finally figured it out and I had several flaws in my code I'd like to walk you through.
private val mainActivityViewModel by viewModels<MainActivityViewModel>() only works if the ViewModel has a zero arguments-constructor, which I didn't have. I'm not sure how to handle this as the ViewModelFactory is deprecated. In my case it didn't matter. The ViewModel looks now like this:
class MainActivityViewModel : ViewModel() {
val bluetoothService = BluetoothService()
}
With that, my ViewModel can be instantiated the way I did it in the code above and it registers the observers correctly, which it didn't before. Oddly enough it didn't throw me any error at first, but after trying to call mainActivityViewModel.bluetoothService.liveData.hasActiveObservers()
Secondly, the adapter doesn't work with a mutable list, as it seems like doesn't check for the contents of the list. Instead it just checks if its the same list. Thats the case if you always give your list with changed content into it.
My answer to it is the following in the onCreate of my MainActivity
mainActivityViewModel.scanLiveData.observe(this) {
it?.let {
//now it fires
println("Got an Update!")
peripheralAdapter.submitList(it.toMutableList())
}
}
It now updates correctly.

Multiple LiveData/StateFlow for one pair of Fragment-ViewModel

I have a Fragment, that represents a screen in a single activity app, and a ViewModel for this fragment.
ViewModel uses multiple repositories for loading a set of data from multiple API calls and emit this data to the fragment via multiple StateFlow.
Assume that the fragment has 2 views, each view is collecting data from a StateFlow related to it. Until all of the 2 views do not draw their data I want to show some progress bar, then when these views will receive data, animate the whole fragment visibility from invisible to visible.
My question is: how to correctly handle when all of 2 views received their data?
Repository:
class Repository(private val name: String) {
private val _data = MutableStateFlow<String?>(null)
val data = _data.asStateFlow()
suspend fun load() {
// load from the DB/API if no data
// load fresh from the API if have data
delay(1000)
_data.emit("$name data")
}
}
ViewModel:
class ScreenViewModel : ViewModel() {
// will be injected by Dagger2/Hilt
private val repository1 = Repository("Repository1")
private val repository2 = Repository("Repository2")
private val _flow1 = MutableStateFlow<String?>(null)
private val _flow2 = MutableStateFlow<String?>(null)
val flow1 = _flow1.asStateFlow()
val flow2 = _flow2.asStateFlow()
init {
viewModelScope.launch {
repository1.data.collect {
_flow1.emit(it)
}
}
viewModelScope.launch {
repository2.data.collect {
_flow2.emit(it)
}
}
viewModelScope.launch {
repository1.load()
}
viewModelScope.launch {
repository2.load()
}
}
}
Fragment:
class Screen : Fragment(/*layoutd*/) {
private val viewModel: ScreenViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
viewModel.flow1.filterNotNull().collect {
draw1(it)
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewModel.flow2.filterNotNull().collect {
draw2(it)
}
}
}
private fun draw1(data: String) {
// some stuff
}
private fun draw2(data: String) {
// some stuff
}
}
I would go for BindingAdapters for this case.
First in my ViewModel file (outside ViewModel Class) I would create an Enum Class
class ScreenViewModel : ViewModel() { .... }
enum class LoadingStatus {
LOADING,
ERROR,
FINISHED
}
Then I would create inside the ViewModel a MutableLiveData backed by LiveData to monitor the Loading status.
private val _status = MutableLiveData<LoadingStatus>()
val status: LiveData<LoadingStatus>
get() = _status
Inside the init block I would change the value of MutableLiveData
init {
viewModelScope.launch {
try {
//before fetching data show progressbar loading
_status.value = LoadingStatus.LOADING
repository1.data.collect {
_flow1.emit(it)
}
//after fetching the data change status to FINISED
_status.value = LoadingStatus.FINISHED
}
catch (e:Exception) {
_status.value = LoadingStatus.FINISHED
}
}
On a separate Kotlin File I write the code for the Binding Adapter This makes the ProgressBar disappear depending on the status.
#BindingAdapter("apiLoadingStatus")
fun ProgressBar.setApiLoadingStatus(status:LoadingStatus?){
when(status){
LoadingStatus.LOADING -> {
visibility = View.VISIBLE
}
LoadingStatus.FINISHED -> {
this.visibility = View.GONE
}
LoadingStatus.ERROR -> {
this.visibility = View.GONE
}
}
}
Then include this code for ProgressBar's Fragmement XML. Note I use DataBinding here
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:motion="http://schemas.android.com/tools">
<!-- TODO: Add data binding node -->
<data>
<variable
name="viewModel"
type="com.yourPackageName.ViewModel" />
</data>
<ImageView
android:id="#+id/statusImageView"
android:layout_width="192dp"
android:layout_height="192dp"
app:apiLoadingStatus="#{viewModel.status}"
Basically for this code to work you need to enable Databinding and also add dependency for ViewModel/LiveData in the gradle file.
You can also write another BindingAdapter to change the fragment's views visibility from invisible to visible.

Kotlin Flow returned from Room does not update when an insert is performed from another Fragment/ViewModel

I have a Room database that returns a Flow of objects. When I insert a new item into the database, the Flow's collect function only triggers if the insert was performed from the same Fragment/ViewModel.
I have recorded a quick video showcasing the issue: https://www.youtube.com/watch?v=7HJkJ7M1WLg
Here is my code setup for the relevant files:
AchievementDao.kt:
#Dao
interface AchievementDao {
#Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(achievement: Achievement)
#Query("SELECT * FROM achievement")
fun getAllAchievements(): Flow<List<Achievement>>
}
AppDB.kt:
#Database(entities = [Achievement::class], version = 1, exportSchema = false)
abstract class AppDB : RoomDatabase() {
abstract fun achievementDao(): AchievementDao
}
AchievementRepository.kt:
class AchievementRepository #Inject constructor(appDB: AppDB) {
private val achievementDao = appDB.achievementDao()
suspend fun insert(achievement: Achievement) {
withContext(Dispatchers.IO) {
achievementDao.insert(achievement)
}
}
fun getAllAchievements() = achievementDao.getAllAchievements()
}
HomeFragment.kt:
#AndroidEntryPoint
class HomeFragment : Fragment() {
private val viewModel: HomeViewModel by viewModels()
private lateinit var homeText: TextView
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_home, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
bindViews()
subscribeObservers()
}
private fun bindViews() {
homeText = requireView().findViewById(R.id.txt_home)
requireView().findViewById<ExtendedFloatingActionButton>(R.id.fab_add_achievement).setOnClickListener {
AddAchievementBottomSheet().show(parentFragmentManager, "AddAchievementDialog")
}
requireView().findViewById<ExtendedFloatingActionButton>(R.id.fab_add_achievement_same_fragment).setOnClickListener {
viewModel.add()
}
}
private fun subscribeObservers() {
viewModel.count.observe(viewLifecycleOwner, { count ->
if(count != null) {
homeText.text = count.toString()
} else {
homeText.text = resources.getString(R.string.app_name)
}
})
}
}
HomeViewModel.kt:
class HomeViewModel #ViewModelInject constructor(private val achievementRepository: AchievementRepository) :
ViewModel() {
private val _count = MutableLiveData<Int>(null)
val count = _count as LiveData<Int>
init {
viewModelScope.launch {
achievementRepository.getAllAchievements()
.collect { values ->
// FIXME this is only called when inserting from the same Fragment
_count.postValue(values.count())
}
}
}
fun add() {
viewModelScope.launch {
achievementRepository.insert(Achievement(0, 0, "Test"))
}
}
}
AddAchievementBottomSheet.kt:
#AndroidEntryPoint
class AddAchievementBottomSheet : BottomSheetDialogFragment() {
private val viewModel: AddAchievementViewModel by viewModels()
private lateinit var addButton: MaterialButton
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.dialog_add_achievement, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
addButton = requireView().findViewById(R.id.btn_add_achievement)
addButton.setOnClickListener {
viewModel.add(::close)
}
}
private fun close() {
dismiss()
}
}
AddAchievementBottomSheetViewModel.kt:
class AddAchievementViewModel #ViewModelInject constructor(private val achievementRepository: AchievementRepository) :
ViewModel() {
fun add(closeCallback: () -> Any) {
viewModelScope.launch {
achievementRepository.insert(Achievement(0, 0, "Test"))
closeCallback()
}
}
}
build.gradle (app):
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-kapt'
id 'dagger.hilt.android.plugin'
}
android {
compileSdkVersion 30
defaultConfig {
applicationId "com.marcdonald.achievementtracker"
minSdkVersion 23
targetSdkVersion 30
versionCode 1
versionName "1.0.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
}
dependencies {
// Kotlin
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.0'
implementation 'androidx.core:core-ktx:1.3.2'
// Android
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'com.google.android.material:material:1.2.1'
implementation "androidx.activity:activity-ktx:1.1.0"
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'
// Navigation
implementation 'androidx.navigation:navigation-fragment-ktx:2.3.1'
implementation 'androidx.navigation:navigation-ui-ktx:2.3.1'
// Testing
testImplementation 'junit:junit:4.13.1'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
// Dagger Hilt
implementation 'com.google.dagger:hilt-android:2.29.1-alpha'
implementation 'androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha02'
kapt 'androidx.hilt:hilt-compiler:1.0.0-alpha02'
kapt 'com.google.dagger:hilt-android-compiler:2.29.1-alpha'
// Timber for logging
implementation 'com.jakewharton.timber:timber:4.7.1'
// Room
implementation 'androidx.room:room-runtime:2.2.5'
implementation 'androidx.room:room-ktx:2.2.5'
kapt 'androidx.room:room-compiler:2.2.5'
androidTestImplementation 'androidx.room:room-testing:2.2.5'
}
build.gradle (project):
buildscript {
ext.kotlin_version = "1.4.10"
repositories {
google()
jcenter()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.2.0-alpha16'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'android.arch.navigation:navigation-safe-args-gradle-plugin:1.0.0'
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.29.1-alpha'
}
}
allprojects {
repositories {
google()
jcenter()
maven { url 'https://jitpack.io' }
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
I'm not sure if my understanding of Kotlin Flow is to blame or whether my setup is incorrect in some way, but I'd appreciate some help with the issue.
Make sure you use the same instance of your RoomDatabase. Add a #Singleton where you provide AppDB might do the trick.
Try calling subscribeObservers() in the onStart() lifecycle.
You need to add this dependency:
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.2.0"
Then don't collect the Flow in your ViewModel. Instead map it to your needs and expose it as LiveData like this:
class HomeViewModel #ViewModelInject constructor(private val achievementRepository: AchievementRepository) :
ViewModel() {
val count: LiveData<Int> = achievementRepository
.getAllAchievements()
.map {it.size}
.asLiveData()
fun add() {
viewModelScope.launch {
achievementRepository.insert(Achievement(0, 0, "Test"))
}
}
}
Flow is a cold stream which means you have to manually call Flow.collect{} to get the data.
To continuously observe changes in the database,
Option 1) convert Flow to Livedata,
val count: LiveData<Int> = achievementRepository.getAllAchivements().map {
it.count()
}.asLiveData()
Checkout the solution code in Google Codelab, "Android Room with a View - Kotlin"
Option 2) convert Flow to StateFlow which is a hot stream that you can observe on with StateFlow.collect {}

Observed LiveData value is always null inside a RecyclerView.ViewHolder (where data is updated inside a Worker)

I've been struggling to use the new WorkManager because I don't see a way to get fine grained status of my job. Basically, I want to use the WorkManager to upload files, and then I need my UI to reflect the progress of those uploads. The WorkInfo which you can retrieve regarding a WorkRequest only specifies terminal states (SUCCESS or FAILURE) not things like user defined progress of the job.
I thought perhaps I could use Room and LiveData to provide an elegant way to update status inside my UI. Basically, I create a database entity called VideoAsset, then provide methods which return LiveData<VideoAsset> inside my DAO. In my contrived example app below, when a user clicks on the FAB, a new video asset is added to the database, and then a new worker is scheduled, and the UUID of that video asset is passed to the worker. Inside doWork() on the worker, the worker retrieves the UUID, retrieves the video asset associated with that, and then updates the progress field inside the database (right now I'm just sleeping and updating to simulate a network upload to keep it simple). Then, inside my viewholder I am retrieving the LiveData<VideoAsset> and adding an observer to it.
I want to have my UI look like this, where the UUID is presented with an upload progress:
Here is the log output, showing it never has an observer object which is non null. It does properly update the progress inside the database via the worker.
D/WSVDB: Updating progress for 84b78a30: 98
D/WSVDB: Video is null, WHY?
I/chatty: uid=10091(com.webiphany.workerstatusviadb) identical 69 lines
D/WSVDB: Video is null, WHY?
D/WSVDB: Updating progress for 84b78a30: 99
D/WSVDB: Video is null, WHY?
I/chatty: uid=10091(com.webiphany.workerstatusviadb) identical 72 lines
D/WSVDB: Video is null, WHY?
D/WSVDB: Updating progress for 84b78a30: 105
I/WM-WorkerWrapper: Worker result SUCCESS for Work [ id=3612d7d3-23dd-4c29-9566-d1e15672ded7, tags={ com.webiphany.workerstatusviadb.UploadWorker } ]
D/WSVDB: Video is null, WHY?
I/chatty: uid=10091(com.webiphany.workerstatusviadb) identical 75 lines
D/WSVDB: Video is null, WHY?
First, the MainActivity. Pretty simple, it just sets up the RecyclerView and ViewModel, wires up the FAB button click handler. uploadNewVideo is where the video asset is created inside the database (using the view model which has a repository behind it...). Then, inside the VideoAssetsAdapter#onBindViewHolder I have code which retrieves the video and adds the observer. It never updates the progress, it always goes into the else branch and says Video is null, WHY.
package com.webiphany.workerstatusviadb
import android.os.Bundle
import android.util.Log
import android.view.*
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModelProviders
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.work.Data
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import com.google.android.material.floatingactionbutton.FloatingActionButton
import kotlinx.android.synthetic.main.activity_main.*
import java.util.*
class MainActivity : AppCompatActivity() {
private var videoAssetsViewModel: VideoAssetViewModel? = null
private var adapter: VideoAssetsAdapter? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val fab = findViewById<FloatingActionButton>(R.id.fab)
fab.setOnClickListener { _ ->
uploadNewVideo()
}
videoAssetsViewModel = ViewModelProviders.of(this).get(VideoAssetViewModel::class.java)
setupPreviewImages()
}
private fun uploadNewVideo() {
val videoAsset = VideoAsset()
videoAsset.uuid = Integer.toHexString(Random().nextInt() * 10000)
videoAssetsViewModel?.insert(videoAsset)
// Create a new worker, add to the items
val uploadRequestBuilder = OneTimeWorkRequest.Builder(UploadWorker::class.java)
val data = Data.Builder()
data.putString(UploadWorker.UUID, videoAsset.uuid)
uploadRequestBuilder.setInputData(data.build())
val uploadRequest = uploadRequestBuilder.build()
WorkManager.getInstance().enqueue(uploadRequest)
}
private fun setupPreviewImages() {
val mLayoutManager = GridLayoutManager(this, 4)
previewImagesRecyclerView.layoutManager = mLayoutManager
adapter = VideoAssetsAdapter(videoAssetsViewModel?.videos?.value)
previewImagesRecyclerView.adapter = adapter
videoAssetsViewModel?.videos?.observe(this, androidx.lifecycle.Observer { t ->
if( t != null ){
if (t.size > 0 ){
adapter?.setVideos(t)
previewImagesRecyclerView.adapter = adapter
}
}
})
}
inner class VideoAssetViewHolder(videoView: View) : RecyclerView.ViewHolder(videoView) {
var progressText: TextView
var uuidText: TextView
init {
uuidText = videoView.findViewById(R.id.uuid)
progressText = videoView.findViewById(R.id.progress)
}
}
inner class VideoAssetsAdapter(private var videos: List<VideoAsset>?) :
RecyclerView.Adapter<VideoAssetViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup,
viewType: Int): VideoAssetViewHolder {
return VideoAssetViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.preview_image, parent, false))
}
override fun onBindViewHolder(holder: VideoAssetViewHolder, position: Int) {
val video = videos?.get(position)
if (video != null && videoAssetsViewModel != null) {
val uuid = video.uuid
if( uuid != null ) {
holder.uuidText.text = uuid
// Get the livedata to observe and change
val living = videoAssetsViewModel?.getByUuid(uuid)
living?.observe(this#MainActivity, androidx.lifecycle.Observer { v ->
// Got a change, do something with it.
if (v != null) {
holder.progressText.text = "${v.progress}%"
}
else {
Log.d( TAG, "Video is null, WHY?")
}
})
}
}
}
fun setVideos(t: List<VideoAsset>?) {
videos = t
notifyDataSetChanged()
}
override fun getItemCount(): Int {
var size = 0
if (videos != null) {
size = videos?.size!!
}
return size
}
}
companion object {
var TAG: String = "WSVDB"
}
}
The video asset entity (and the DAO, Database, Repository) looks like this:
package com.webiphany.workerstatusviadb
import android.app.Application
import android.content.Context
import android.os.AsyncTask
import android.util.Log
import androidx.annotation.NonNull
import androidx.lifecycle.LiveData
import androidx.room.*
#Entity(tableName = "video_table")
class VideoAsset {
#PrimaryKey(autoGenerate = true)
#NonNull
#ColumnInfo(name = "id")
var id: Int = 0
#ColumnInfo(name = "progress")
var progress: Int = 0
#ColumnInfo(name = "uuid")
#NonNull
var uuid: String? = null
}
class VideoAssetRepository(application: Application) {
private var videoDao: VideoAssetDao? = null
init {
val db = VideoAssetDatabase.getDatabase(application)
if (db != null) {
videoDao = db.videoAssetDao()
}
}
fun findAllVideos(): LiveData<List<VideoAsset>>? {
if (videoDao != null) {
return videoDao?.findAll()
} else {
Log.v(MainActivity.TAG, "DAO is null, fatal error")
return null
}
}
fun insert(video: VideoAsset) {
insertAsyncTask(videoDao).execute(video)
}
fun get(id: String): LiveData<VideoAsset>? = videoDao?.findVideoAssetById(id)
private class insertAsyncTask internal
constructor(private val asyncTaskDao: VideoAssetDao?) :
AsyncTask<VideoAsset, Void, Void>() {
override fun doInBackground(vararg params: VideoAsset): Void? {
asyncTaskDao?.insert(params[0])
return null
}
}
companion object {
var instance: VideoAssetRepository? = null
fun getInstance(application: Application): VideoAssetRepository? {
synchronized(VideoAssetRepository::class) {
if (instance == null) {
instance = VideoAssetRepository(application)
}
}
return instance
}
}
}
#Database(entities = arrayOf(VideoAsset::class), version = 3)
abstract class VideoAssetDatabase : RoomDatabase() {
abstract fun videoAssetDao(): VideoAssetDao
companion object {
#Volatile
private var INSTANCE: VideoAssetDatabase? = null
fun getDatabase(context: Context): VideoAssetDatabase? {
if (INSTANCE == null) {
synchronized(VideoAssetDatabase::class.java) {
if (INSTANCE == null) {
INSTANCE = Room.databaseBuilder(context.applicationContext,
VideoAssetDatabase::class.java, "video_asset_database")
.build()
}
}
}
return INSTANCE
}
}
}
#Dao
interface VideoAssetDao {
#Insert
fun insert(asset: VideoAsset)
#Query("SELECT * from video_table")
fun findAll(): LiveData<List<VideoAsset>>
#Query("select * from video_table where id = :s limit 1")
fun findVideoAssetById(s: String): LiveData<VideoAsset>
#Query("select * from video_table where uuid = :uuid limit 1")
fun findVideoAssetByUuid(uuid: String): LiveData<VideoAsset>
#Query( "update video_table set progress = :p where uuid = :uuid")
fun updateProgressByUuid(uuid: String, p: Int )
}
Then, the worker is here. Again, it merely simulates upload progress by incrementing a variable by a random number between 1 and 10, and then sleeping for a second.
package com.webiphany.workerstatusviadb
import android.content.Context
import android.util.Log
import androidx.work.Worker
import androidx.work.WorkerParameters
import java.util.*
class UploadWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
override fun doWork(): Result {
// Get out the UUID
var uuid = inputData.getString(UUID)
if (uuid != null) {
doLongOperation(uuid)
return Result.success()
} else {
return Result.failure()
}
}
private fun doLongOperation(uuid: String) {
var progress = 0
var videoDao: VideoAssetDao? = null
val db = VideoAssetDatabase.getDatabase(applicationContext)
if (db != null) {
videoDao = db.videoAssetDao()
}
while (progress < 100) {
progress += (Random().nextFloat() * 10.0).toInt()
try {
Thread.sleep(1000)
} catch (ie: InterruptedException) {
}
Log.d( MainActivity.TAG, "Updating progress for ${uuid}: ${progress}")
videoDao?.updateProgressByUuid(uuid, progress)
}
}
companion object {
val UUID = "UUID"
}
}
Lastly, the view model:
package com.webiphany.workerstatusviadb
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import java.util.concurrent.Executors
class VideoAssetViewModel(application: Application) : AndroidViewModel(application) {
private val videoAssetRepository: VideoAssetRepository?
var videos: LiveData<List<VideoAsset>>? = null
private val executorService = Executors.newSingleThreadExecutor()
init {
videoAssetRepository = VideoAssetRepository.getInstance(application)
videos = videoAssetRepository?.findAllVideos()
}
fun getByUuid(id: String) = videoAssetRepository?.get(id)
fun insert(video: VideoAsset) {
executorService.execute {
videoAssetRepository?.insert(video)
}
}
}
activity_main.xml:
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout 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.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"
tools:showIn="#layout/activity_main">
<androidx.recyclerview.widget.RecyclerView
android:id="#+id/previewImagesRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:isScrollContainer="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:listitem="#layout/preview_image"
>
</androidx.recyclerview.widget.RecyclerView>
</androidx.constraintlayout.widget.ConstraintLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="#+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="#dimen/fab_margin"
app:srcCompat="#android:drawable/ic_dialog_email" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
preview_image.xml:
<?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="92dp"
android:layout_height="92dp"
android:padding="4dp"
android:background="#color/colorPrimary"
android:orientation="vertical">
<TextView
android:id="#+id/uuid"
android:layout_width="0dp"
android:layout_height="20dp"
android:background="#color/colorPrimaryDark"
android:gravity="end|center"
android:padding="2dp"
android:textColor="#android:color/white"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:text="abcd"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="#+id/progress"
android:layout_width="0dp"
android:layout_height="20dp"
android:background="#color/colorAccent"
android:gravity="end|center"
android:padding="2dp"
android:textColor="#android:color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:text="0%"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
And, the app/build.gradle
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
android {
compileSdkVersion 28
defaultConfig {
applicationId "com.webiphany.workerstatusviadb"
minSdkVersion 15
targetSdkVersion 28
versionCode 1
versionName "1.0"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.1.0-alpha01'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test:runner:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
implementation 'com.google.android.material:material:1.1.0-alpha02'
def work_version = "1.0.0-beta02"
implementation("android.arch.work:work-runtime-ktx:$work_version")
def room_version = "2.1.0-alpha03"
implementation "androidx.room:room-runtime:$room_version"
kapt "android.arch.persistence.room:compiler:$room_version"
testImplementation "androidx.room:room-testing:$room_version"
debugImplementation 'com.amitshekhar.android:debug-db:1.0.4'
}

Databinding not observe LiveData in ViewModel

I am trying to use Data binding with LiveData in ViewModel. But when using Transformations.map functions not trigged without adding observers explicitly. Data binding in XML not generate observers for LiveData in ViewModel.
LoginFragment.kt
class LoginFragment : Fragment() {
var homeViewModel: HomeViewModel? = null
companion object {
val TAG : String = "LoginFragment"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
homeViewModel = ViewModelProviders.of(this).get(HomeViewModel::class.java)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
val binding = DataBindingUtil.inflate<FragmentLoginBinding>(inflater, R.layout.fragment_login, container, false)
.apply {
setLifecycleOwner(this#LoginFragment)
loginButton.setOnClickListener {
signInWithEmail()
}
}
return binding.root
}
/*private fun observeHomeFragmentUIDataLiveData() {
homeViewModel?.homeFragmentUIDataLiveData?.observe(this, Observer {
val email = it.email
Toast.makeText(activity,email, Toast.LENGTH_SHORT).show()
})
}
private fun observeLoginErrorEventLiveData() {
homeViewModel?.loginErrorEventLiveData?.observe(this, Observer {
Toast.makeText(activity,it, Toast.LENGTH_SHORT).show()
})
}*/
/**
* Sign In via Email
*/
fun signInWithEmail(){
val email = email_text_input_layout.editText?.text.toString()
val password = password_text_input_layout.editText?.text.toString()
var cancel : Boolean? = false
var focusView : View? = null
if(password.isEmpty()){
password_text_input_layout.error = getString(R.string.this_field_is_required)
focusView = password_text_input_layout
cancel = true
}
if(email.isEmpty()){
email_text_input_layout.error = getString(R.string.this_field_is_required)
focusView = email_text_input_layout
cancel = true
}
if(cancel!!){
focusView?.requestFocus()
}
else{
homeViewModel?.signInWithEmail(email,password)
/*homeViewModel?.signInWithEmail(email,password)?.observe(this, Observer {
val email = it
Toast.makeText(activity,""+email, Toast.LENGTH_SHORT).show()
})*/
}
}
}
fragment_login.xml
<layout 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">
<data>
<variable name="homeViewModel" type="com.rramprasad.adminapp.HomeViewModel"/>
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="LoginFragment">
<!-- Skipped some code for clarity -->
<androidx.appcompat.widget.AppCompatTextView
android:id="#+id/error_textview"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="#{homeViewModel.loginErrorEventLiveData.value}"
app:isGone="#{homeViewModel.isLoginSuccess}"
android:textColor="#android:color/holo_orange_dark"
android:textSize="16sp"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="#id/password_text_input_layout"
app:layout_constraintBottom_toBottomOf="#id/login_button"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
HomeViewModel.kt
class HomeViewModel(application: Application) : AndroidViewModel(application) {
val homeRepository: HomeRepository?
init {
homeRepository = HomeRepository()
}
// UI Data for HomeFragment
val homeFragmentUIDataLiveData : MutableLiveData<HomeFragmentUIData> = MutableLiveData()
// UI Data for LoginFragment
val loginErrorEventLiveData : MutableLiveData<String> = MutableLiveData()
var isLoginSuccess: LiveData<Boolean>? = null
fun signInWithEmail(email: String, password: String) : LiveData<Boolean>? {
val signInResponseMutableLiveData : MutableLiveData<Any> = homeRepository?.signInWithEmail(email, password)!!
isLoginSuccess = Transformations.map(signInResponseMutableLiveData) { signInResponse ->
when (signInResponse) {
(signInResponse is FirebaseUser) -> {
val firebaseUserEmail = (signInResponse as FirebaseUser).email
homeFragmentUIDataLiveData.value = HomeFragmentUIData(firebaseUserEmail ?: "")
return#map true
}
else -> {
loginErrorEventLiveData.value = signInResponse.toString()
return#map false
}
}
}
return isLoginSuccess
}
}
BindingAdapters.kt
#BindingAdapter("isGone")
fun bindIsGone(view: View, isGone: Boolean) {
view.visibility = if (isGone) {
View.GONE
} else {
View.VISIBLE
}
}
Android studio version:
Android Studio 3.2 RC 3
Build #AI-181.5540.7.32.4987877, built on August 31, 2018
JRE: 1.8.0_152-release-1136-b06 x86_64
JVM: OpenJDK 64-Bit Server VM by JetBrains s.r.o
macOS 10.13.4
build.gradle:
android {
compileSdkVersion 28
defaultConfig {
minSdkVersion 21
targetSdkVersion 28
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
dataBinding {
enabled = true
}
}

Categories

Resources