WatermelonDB Error

I have been trying to connect to watermelonDB however i keep getting this error
TypeError : null isnot an object (evaluating 'DatabaseBridge[methodName]')
I looked all over the network but couldn't find a solution the problem that i am not generting or writing the DataBaseBridge it should be generated automatically with the packages ...

Can any one help with that urgently !!!

Android Native – How to load Album Art thumbnails

Introduction

When working with music files, you might have wondered how to load display art from Audio files. In this tutorial, we will learn how to load thumbnails for audio files.

Goals

At the end of the tutorial, you would have learned:

  1. How to load display art for music files.
Tools Required
  1. Android Studio. The version used in this tutorial is Bumblebee 2021.1.1 Patch 2.
Prerequisite Knowledge
  1. Basic Android.
  2. MediaStore. For simplicity, all queries in this tutorial are performed on the UI thread. In real code, prefer coroutines.
  3. ActivityResults API.
  4. Permissions.
Project Setup

To follow along with the tutorial, perform the steps below:

  1. Create a new Android project with the default Empty Activity.
    Replace activity_main.xml with the content below. This removes the default Hello World! TextView, adds a Button and an ImageView.

     <?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">
    
        <Button
            android:id="@+id/button_loadMusic"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/load_music"
            android:layout_marginVertical="16dp"
            app:layout_constraintBottom_toTopOf="@+id/imageView_displayArt"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
    
        <ImageView
            android:id="@+id/imageView_displayArt"
            android:layout_width="300dp"
            android:layout_height="300dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/button_loadMusic"
            tools:srcCompat="@tools:sample/backgrounds/scenic" />
     </androidx.constraintlayout.widget.ConstraintLayout>
  2. Add the <string> resource below into strings.xml.

     <string name="load_music">Load Music</string>
  3. Download the sample music song I Move On by Jan Morgenstern ( copyright Blender Foundation | durian.blender.org). Upload the file onto your test device.

  4. In AndroidManifest.xml, add the <uses-permission> element inside of <manifest>, but outside of <application>.

      <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
Project Overview

Currently, our project only has two elements: a Button and an ImageView.

1.png

At the end of the tutorial, our app should be able to perform the workflow below:

  1. The end user taps the Button to initiate the MediaStore to load the music file that we downloaded previously.
  2. After the thumbnail has been loaded, we apply it to the ImageView surface so that it will draw the thumbnail on the screen for us.
Open the music file

First, let us get the logic to load the music file out of the way. Follow the steps below to modify MainActivity#onCreate().

  1. Obtains a reference to the Button.

     val button = findViewById<Button>(R.id.button_loadMusic)
  2. Obtains a reference to the ImageView.

     val imageView = findViewById<ImageView>(R.id.imageView_displayArt)
  3. The permission is required, so create a launcher to ask for permission below.

     val permissionResultLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()){ isGranted ->
    
     }
  4. Bind the permission launcher code to the Buttons OnClickListener.

     button.setOnClickListener {
        permissionResultLauncher.launch(READ_EXTERNAL_STORAGE)
     }
Loading thumbnails on Android Q and above

The API to get the thumbnail is different for devices running Android Q and above. So we will have to check for the version and call appropriate methods for each. Inside of permissionResultLauncher, add the code below.

val thumbnail = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
   getAlbumArtAfterQ()
} else {
   getAlbumArtBeforeQ()
}

imageView.setImageBitmap(thumbnail)

Now we are going to create getAlbumArtAfterQ() first. For devices running Android Q or above, there is a dedicated convenient function to load thumbnails, ContentResolver#loadThumbnail(). The only things that are required to use this function is the Content Uri and a Size object. Because we already know the title of the song that we want to load the thumbnail for, it should be easy to query for its id. Adds the getAlbumArtAfterQ() function below into MainActivity.

@RequiresApi(Build.VERSION_CODES.Q)
private fun getAlbumArtAfterQ(): Bitmap? {
   val collection = MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)

   //The columns that you want. We need the ID to build the content uri
   val projection = arrayOf(
       MediaStore.Audio.Media._ID,
       MediaStore.Audio.Media.TITLE,
   )

   //filter by title here
   val selection = "${MediaStore.Audio.Media.TITLE} = ?"

   //We already know the song title in advance
   val selectionArgs = arrayOf(
       "I Move On (Sintel's Song)"
   )
   val sortOrder = null //sorting order is not needed

   var thumbnail: Bitmap? = null

   applicationContext.contentResolver.query(
       collection,
       projection,
       selection,
       selectionArgs,
       sortOrder
   )?.use { cursor ->
       val idColIndex = cursor.getColumnIndex(MediaStore.Audio.Media._ID)


       while (cursor.moveToNext()) {
           val id = cursor.getLong(idColIndex)

           //Builds the content uri here
           val uri = ContentUris.withAppendedId(
               MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
               id
           )
       }
   }
   return thumbnail
}

The code above simply queries the appropriate MediaStore collection to get the songs ID. With the ID received, we built a Content Uri with ContentUris#withAppendedId(). Because MediaStore knowledge is a requirement for this tutorial, I will not bore you with explaining the query further. You can read this tutorial to get an idea of how the query was built.

Now add the code below inside the while loop to load the thumbnail.

try {
   thumbnail = contentResolver.loadThumbnail(
       uri,
       Size(300, 300),
       null
   )
} catch (e: IOException) {
   TODO("Load alternative thumbnail here")
}

loadThumbnail() can fail if the file does not contain a display art, so you can optionally catch the exception and load an alternative album art instead if desired.

Loading thumbnails before Android Q

To load the thumbnail for devices before Android Q, we will have to query the MediaStore.Audio.Albums(ALBUM_ART) collection instead. But because this collection does not contain the song title, which is our approach here, we will have to find the ALBUM_ART via one of the available columns in the MediaStore.Audio.Albums.

In this tutorial, we first find the album id in MediaStore.Audio.Media, and then use that album id to search for the ALBUM_ART in MediaStore.Audio.Albums. This approach performs two queries. You can also use a different approach, such as querying for the Album Title directly and you should be able to get the thumbnail in one query.

Add the getAlbumId() function to find the Album ID.

private fun getAlbumId(): Long? {
   val collection = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI

   //The columns that you want. We need the ID to build the content uri
   val projection = arrayOf(
       MediaStore.Audio.Media.TITLE,
       MediaStore.Audio.Media.ALBUM_ID
   )

   //filter by title here
   val selection = "${MediaStore.Audio.Media.TITLE} = ?"

   //We already know the song title in advance
   val selectionArgs = arrayOf(
       "I Move On (Sintel's Song)"
   )

   val sortOrder = null //sorting order is not needed

   var id: Long? = null

   applicationContext.contentResolver.query(
       collection,
       projection,
       selection,
       selectionArgs,
       sortOrder
   )?.use { cursor ->
       val albumIdColIndex = cursor.getColumnIndex(MediaStore.Audio.Media.ALBUM_ID)

       while (cursor.moveToNext()) {
           id = cursor.getLong(albumIdColIndex)
       }
   }

   return id
}

Now, add the getAlbumArtBeforeQ() function to query for the Album Art (it is a path String).

private fun getAlbumArtBeforeQ(): Bitmap? {
   val collection = MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI

   //The columns that you want. We need the ID to build the content uri
   val projection = arrayOf(
       MediaStore.Audio.Albums._ID,
       MediaStore.Audio.Albums.ALBUM_ART
   )

   //filter by title here
   val selection = "${MediaStore.Audio.Albums._ID} = ?"

   val albumId = getAlbumId()

   //We already know the song title in advance
   val selectionArgs = arrayOf(
       "$albumId"
   )
   val sortOrder = null //sorting order is not needed

   var thumbnail: Bitmap? = null

   applicationContext.contentResolver.query(
       collection,
       projection,
       selection,
       selectionArgs,
       sortOrder
   )?.use { cursor ->
       val albumArtColIndex = cursor.getColumnIndex(MediaStore.Audio.Albums.ALBUM_ART)

       while (cursor.moveToNext()) {
           val albumArtPath = cursor.getString(albumArtColIndex)

           thumbnail = BitmapFactory.decodeFile(albumArtPath)
           if (thumbnail === null){
               TODO("Load alternative thumbnail here")
           }
       }
   }

   return thumbnail
}

The function decodeFile() used above does not throw an exception. It returns null if it fails to load the thumbnail, so instead of catching an IOException here, we need to check for null and load alternative thumbnails if desired.

Run the App

To test, we need to run the app on both Android Q (or higher) and an older Android API. In my case, I have used two emulators running Android API 32 and 22.

For Android 32, your app should behave similarly to the Gif below.

DaniwebThumbnail32.gif

For Android 22, your app should behave similarly to the Gif below.

DaniwebThumbnail22.gif

Solution Code

MainActivity.kt

package com.codelab.daniwebandroidnativeloadmediathumbnail

import android.Manifest.permission.READ_EXTERNAL_STORAGE
import android.content.ContentUris
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Build
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.provider.MediaStore
import android.util.Size
import android.widget.Button
import android.widget.ImageView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import java.io.IOException

class MainActivity : AppCompatActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)

       val button = findViewById<Button>(R.id.button_loadMusic)
       val imageView = findViewById<ImageView>(R.id.imageView_displayArt)

       val permissionResultLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
           val thumbnail = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
               getAlbumArtAfterQ()
           } else {
               getAlbumArtBeforeQ()
           }

           imageView.setImageBitmap(thumbnail)
       }

       button.setOnClickListener {
           permissionResultLauncher.launch(READ_EXTERNAL_STORAGE)
       }
   }

   private fun getAlbumArtBeforeQ(): Bitmap? {
       val collection = MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI

       //The columns that you want. We need the ID to build the content uri
       val projection = arrayOf(
           MediaStore.Audio.Albums._ID,
           MediaStore.Audio.Albums.ALBUM_ART
       )

       //filter by title here
       val selection = "${MediaStore.Audio.Albums._ID} = ?"

       val albumId = getAlbumId()

       //We already know the song title in advance
       val selectionArgs = arrayOf(
           "$albumId"
       )
       val sortOrder = null //sorting order is not needed

       var thumbnail: Bitmap? = null

       applicationContext.contentResolver.query(
           collection,
           projection,
           selection,
           selectionArgs,
           sortOrder
       )?.use { cursor ->
           val albumArtColIndex = cursor.getColumnIndex(MediaStore.Audio.Albums.ALBUM_ART)

           while (cursor.moveToNext()) {
               val albumArtPath = cursor.getString(albumArtColIndex)

               thumbnail = BitmapFactory.decodeFile(albumArtPath)
               if (thumbnail === null){
                   TODO("Load alternative thumbnail here")
               }
           }
       }

       return thumbnail
   }

   private fun getAlbumId(): Long? {
       val collection = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI

       //The columns that you want. We need the ID to build the content uri
       val projection = arrayOf(
           MediaStore.Audio.Media.TITLE,
           MediaStore.Audio.Media.ALBUM_ID
       )

       //filter by title here
       val selection = "${MediaStore.Audio.Media.TITLE} = ?"

       //We already know the song title in advance
       val selectionArgs = arrayOf(
           "I Move On (Sintel's Song)"
       )

       val sortOrder = null //sorting order is not needed

       var id: Long? = null

       applicationContext.contentResolver.query(
           collection,
           projection,
           selection,
           selectionArgs,
           sortOrder
       )?.use { cursor ->
           val albumIdColIndex = cursor.getColumnIndex(MediaStore.Audio.Media.ALBUM_ID)

           while (cursor.moveToNext()) {
               id = cursor.getLong(albumIdColIndex)
           }
       }

       return id
   }

   @RequiresApi(Build.VERSION_CODES.Q)
   private fun getAlbumArtAfterQ(): Bitmap? {
       val collection = MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)

       //The columns that you want. We need the ID to build the content uri
       val projection = arrayOf(
           MediaStore.Audio.Media._ID,
           MediaStore.Audio.Media.TITLE,
       )

       //filter by title here
       val selection = "${MediaStore.Audio.Media.TITLE} = ?"

       //We already know the song title in advance
       val selectionArgs = arrayOf(
           "I Move On (Sintel's Song)"
       )
       val sortOrder = null //sorting order is not needed

       var thumbnail: Bitmap? = null

       applicationContext.contentResolver.query(
           collection,
           projection,
           selection,
           selectionArgs,
           sortOrder
       )?.use { cursor ->
           val idColIndex = cursor.getColumnIndex(MediaStore.Audio.Media._ID)

           while (cursor.moveToNext()) {
               val id = cursor.getLong(idColIndex)

               //Builds the content uri here
               val uri = ContentUris.withAppendedId(
                   MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
                   id
               )

               try {
                   thumbnail = contentResolver.loadThumbnail(
                       uri,
                       Size(300, 300),
                       null
                   )
               } catch (e: IOException) {
                   TODO("Load alternative thumbnail here")
               }
           }
       }
       return thumbnail
   }
}

activity_main.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="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">

   <Button
       android:id="@+id/button_loadMusic"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:text="@string/load_music"
       android:layout_marginVertical="16dp"
       app:layout_constraintBottom_toTopOf="@+id/imageView_displayArt"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintHorizontal_bias="0.5"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent" />

   <ImageView
       android:id="@+id/imageView_displayArt"
       android:layout_width="300dp"
       android:layout_height="300dp"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintHorizontal_bias="0.5"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/button_loadMusic"
       tools:srcCompat="@tools:sample/backgrounds/scenic" />
</androidx.constraintlayout.widget.ConstraintLayout>

strings.xml

<resources>
   <string name="app_name">Daniweb Android Native Load Media Thumbnail</string>
   <string name="load_music">Load Music</string>
</resources>

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
   package="com.codelab.daniwebandroidnativeloadmediathumbnail">

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

   <application
       android:allowBackup="true"
       android:icon="@mipmap/ic_launcher"
       android:label="@string/app_name"
       android:roundIcon="@mipmap/ic_launcher_round"
       android:supportsRtl="true"
       android:theme="@style/Theme.DaniwebAndroidNativeLoadMediaThumbnail">
       <activity
           android:name=".MainActivity"
           android:exported="true">
           <intent-filter>
               <action android:name="android.intent.action.MAIN" />

               <category android:name="android.intent.category.LAUNCHER" />
           </intent-filter>
       </activity>
   </application>

</manifest>
Summary

We have learned how to load thumbnails on both pre-Q and post-Q Android devices in this tutorial. The full project code can be found at https://github.com/dmitrilc/DaniwebAndroidNativeLoadMediaThumbnail

Android Native – How to create a Service

Introduction

Android provides many ways to perform background tasks, such as Services, WorkManager, or even threads. In this tutorial, we will learn how to create a Service for our Android app.

Goals

At the end of the tutorial, you would have learned:

  1. How to create a Service.
Tools Required
  1. Android Studio. The version used in this tutorial is Bumblebee 2021.1.1 Patch 2.
Prerequisite Knowledge
  1. Basic Android.
Project Setup

To follow along with the tutorial, perform the steps below:

  1. Create a new Android project with the default Empty Activity.

  2. Replace activity_main.xml with the code below. This removes the default Hello World! Textview and adds two Buttons for starting and stopping the Service.

     <?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">
    
        <Button
            android:id="@+id/button_startService"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/start_service"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toStartOf="@+id/button_stopService"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
    
        <Button
            android:id="@+id/button_stopService"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/stop_service"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toEndOf="@+id/button_startService"
            app:layout_constraintTop_toTopOf="parent" />
     </androidx.constraintlayout.widget.ConstraintLayout>
  3. Add the <string> resources below into strings.xml.

     <string name="start_service">Start Service</string>
     <string name="stop_service">Stop Service</string>
  4. In MainActivity#onCreate(), obtain a reference to button_startService.

     val startServiceButton = findViewById<Button>(R.id.button_startService)
  5. Obtain a reference to button_stopService as well.

     val stopServiceButton = findViewById<Button>(R.id.button_stopService)
Creating the Service class

A service is a class that extends Android.app.Service. From the Service class, the only required function that we must override is the onBind() function. There are other functions such as onStartCommand() or onDestroy() for us to override as well.

There are three different types of Service:

  1. Foreground Service: A service that the user is aware of.
  2. Background Service: A service that the user is not aware of.
  3. Bound Service: A service where the calling client binds to it. It can communicate with the client via IPC (interprocess communication). If the service is not a bound service, then we can just return null from the onBind() function.

To create a Service class, follow the steps below.

  1. Create a new class called SampleService.

     class SampleService {
    
     }
  2. Adds Service as the parent class.

     class SampleService : Service() {
    
     }
  3. Implements onBind() by simply returning null.

     override fun onBind(p0: Intent?): IBinder? = null
  4. That is all that is needed for the Service to work, but let us add some basic functionality, such as displaying a Toast when it starts. To do that, we will override onStartCommand().

     override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
        Toast.makeText(
            this,
            "Service Started",
            Toast.LENGTH_SHORT
        ).show()
    
        return START_NOT_STICKY
     }
  5. Also, override onDestroy() to display another Toast when the Service is stopped.

     override fun onDestroy() {
        super.onDestroy()
    
        Toast.makeText(
            this,
            "Service Stopped",
            Toast.LENGTH_SHORT
        ).show()
     }
Declare the Service in the Manifest

Next, we will have to register the Service in the Manifest using <service>. In AndroidManifest.xml, inside of the <application> tag, add the <service> tag below.

<service android:name=".SampleService"
   android:exported="false"/>

android:exported=false is a flag that prevents other Apps from using our Service.

Start and Stop the Service

To start the service, we can call the startService() function from the Context object. It takes an Intent object. To enable starting and stopping the service, follow the steps below.

  1. In MainActivity#onCreate, create an Intent to start our SampleService. Here, we pass in the context object and the Class object of the Service.

     val serviceIntent = Intent(
        this,
        SampleService::class.java
     )
  2. Now, bind button_startService to the startService() call.

     startServiceButton.setOnClickListener{
        startService(serviceIntent)
     }
  3. Lastly, bind button_stopService to the stopService() function.

     stopServiceButton.setOnClickListener {
        stopService(serviceIntent)
     }
Run the App

We are now ready to run the app. Pressing the Start Service Button should start SampleService, and pressing Stop Service Button should stop SampleService. Refer to the Gif below to check your Apps behavior.

Services.gif

Solution Code

SampleService.kt

package com.codelab.daniwebandroidcreateaservice

import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.widget.Toast

class SampleService : Service() {
   override fun onBind(p0: Intent?): IBinder? = null

   override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
       Toast.makeText(
           this,
           "Service Started",
           Toast.LENGTH_SHORT
       ).show()

       return START_NOT_STICKY
   }

   override fun onDestroy() {
       super.onDestroy()

       Toast.makeText(
           this,
           "Service Stopped",
           Toast.LENGTH_SHORT
       ).show()
   }

}

strings.xml

<resources>
   <string name="app_name">Daniweb Android Create a Service</string>
   <string name="start_service">Start Service</string>
   <string name="stop_service">Stop Service</string>
</resources>

MainActivity.kt

package com.codelab.daniwebandroidcreateaservice

import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Button

class MainActivity : AppCompatActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)

       val startServiceButton = findViewById<Button>(R.id.button_startService)
       val stopServiceButton = findViewById<Button>(R.id.button_stopService)

       val serviceIntent = Intent(
           this,
           SampleService::class.java
       )

       startServiceButton.setOnClickListener{
           startService(serviceIntent)
       }

       stopServiceButton.setOnClickListener {
           stopService(serviceIntent)
       }

   }
}

activity_main.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="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">

   <Button
       android:id="@+id/button_startService"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:text="@string/start_service"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toStartOf="@+id/button_stopService"
       app:layout_constraintHorizontal_bias="0.5"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent" />

   <Button
       android:id="@+id/button_stopService"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:text="@string/stop_service"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintHorizontal_bias="0.5"
       app:layout_constraintStart_toEndOf="@+id/button_startService"
       app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
   package="com.codelab.daniwebandroidcreateaservice">

   <application
       android:allowBackup="true"
       android:icon="@mipmap/ic_launcher"
       android:label="@string/app_name"
       android:roundIcon="@mipmap/ic_launcher_round"
       android:supportsRtl="true"
       android:theme="@style/Theme.DaniwebAndroidCreateAService">
       <activity
           android:name=".MainActivity"
           android:exported="true">
           <intent-filter>
               <action android:name="android.intent.action.MAIN" />

               <category android:name="android.intent.category.LAUNCHER" />
           </intent-filter>
       </activity>
       <service android:name=".SampleService"
           android:exported="false"/>
   </application>

</manifest>
Summary

We have learned how to create a Service in this tutorial. The full project code can be found at https://github.com/dmitrilc/DaniwebAndroidCreateService

How can I improve my ROI in my Taxi business?

Hello, my name is Nicholas, and I'm the owner of a taxi service in Miami. I recently started a cab service with the support of a few friends. This firm has taken a lot of time and work to build. So far, we haven't been able to uncover anything that provides a good return on investment. Is there a proven strategy for boosting my return on investment?

Android Native – How to Inject Hilt ViewModels

Introduction

When working with Hilt, you might have wondered how to inject ViewModels into your application. In this tutorial, we will learn how to inject ViewModels into your app Fragments.

Goals

At the end of the tutorial, you would have learned:

  1. How to inject ViewModels into Fragments.
  2. Understand injected ViewModels lifecycle.
Tools Required
  1. Android Studio. The version used in this tutorial is Bumblebee 2021.1.1 Patch 1.
Prerequisite Knowledge
  1. Basic Android.
  2. Basic Hilt.
Project Setup

To follow along with the tutorial, perform the steps below:

  1. Create a new Android project with the default Empty Activity.

  2. Create 3 new Fragments called BlankFragment1, BlankFragment2, and BlankFragment3 by right-clicking the main package > New > Fragment > Fragment (Blank).

  3. Replace the code inside activity_main.xml with the code below. This removes the default Hello World! TextView, adds three Buttons aligned on a vertical chain, and a FragmentContainerView.

     <?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">
    
        <Button
            android:id="@+id/button_toFragment1"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/to_fragment_1"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
    
        <Button
            android:id="@+id/button_toFragment2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/to_fragment_2"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/button_toFragment1" />
    
        <Button
            android:id="@+id/button_toFragment3"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/to_fragment_3"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/button_toFragment2" />
    
        <androidx.fragment.app.FragmentContainerView
            android:id="@+id/fragmentContainerView"
            android:name="com.codelab.daniwebhiltviewmodels.BlankFragment1"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/button_toFragment3" />
     </androidx.constraintlayout.widget.ConstraintLayout>
  4. Add the code below into MainActivity#onCreate(). It binds the three buttons to an action that will always create the respective Fragment, without any backstack.

     val toFragment1Button = findViewById<Button>(R.id.button_toFragment1)
     val toFragment2Button = findViewById<Button>(R.id.button_toFragment2)
     val toFragment3Button = findViewById<Button>(R.id.button_toFragment3)
    
     toFragment1Button.setOnClickListener{
        supportFragmentManager.commit {
            replace<BlankFragment1>(R.id.fragmentContainerView)
        }
     }
    
     toFragment2Button.setOnClickListener {
        supportFragmentManager.commit {
            replace<BlankFragment2>(R.id.fragmentContainerView)
        }
     }
    
     toFragment3Button.setOnClickListener {
        supportFragmentManager.commit {
            replace<BlankFragment3>(R.id.fragmentContainerView)
        }
     }
  5. Annotate MainActivity and all three Fragment classes with @AndroidEntryPoint.

  6. Add the <string> resources below into your strings.xml.

     <string name="to_fragment_1">To Fragment 1</string>
     <string name="to_fragment_2">To Fragment 2</string>
     <string name="to_fragment_3">To Fragment 3</string>
     <string name="hello_blank_fragment">Hello blank fragment</string>
     <string name="hello_blank_fragment2">Hello blank fragment2</string>
     <string name="hello_blank_fragment3">Hello blank fragment3</string>
  7. Go to each Fragments associated layout XML and modify the default TextViews android:text to the respective <string> resource above.

  8. Properly tag each Fragment with BLANK_FRAGMENT_X, respectively.

  9. Add the dependency below into your Project build.gradle.

     buildscript {
        dependencies {
            classpath 'com.google.dagger:hilt-android-gradle-plugin:2.40.5'
        }
     }
  10. Add the plugins for kapt and hilt to your Module build.gradle.

     implementation "com.google.dagger:hilt-android:2.40.5"
     implementation "androidx.fragment:fragment-ktx:1.4.1"
     kapt "com.google.dagger:hilt-compiler:2.40.5"
  11. Also in the Module build.gradle, add the dependencies below.

     implementation "com.google.dagger:hilt-android:2.40.5"
     kapt "com.google.dagger:hilt-compiler:2.40.5"
  12. Create the required Application class for Hilt called MyApplication.kt.

     import android.app.Application
     import dagger.hilt.android.HiltAndroidApp
    
     @HiltAndroidApp
     class ExampleApplication : Application()
  13. Add the attribute below to the manifest <application> element.

     android:name=".ExampleApplication"
Project Overview

Our project so far includes three buttons, which will navigate to three different fragments. At the end of the tutorial, we should have created one ViewModel, which will be injected into the existing fragments. The lifecycle of the ViewModel will behave differently depending how it was injected, so we will observe the output from Logcat to understand what happens to the injected ViewModel instances.

Creating the ViewModel

First, let us create the ViewModel class FragmentViewModel. Copy and paste the code below into a new file named FragmentViewModel.kt.

private const val TAG = "FRAGMENT_VIEW_MODEL"

@HiltViewModel
class FragmentViewModel @Inject constructor(private val application: Application) : ViewModel() {

   override fun onCleared() {
       super.onCleared()

       Log.d(TAG, "ViewModel ${hashCode()} is queued to be destroyed.")
   }

}

In the FragmentViewModel class above, the annotation @HiltViewModel tells Hilt that this ViewModel can be injected into other classes marked with @AndroidEntryPoint as well as allowing Hilt to inject other dependencies into this ViewModel. In this case, we have injected an Application class, which is a special pre-defined binding, only to show that Hilt will assist us in providing instances of this class by injecting this application dependency.

We have also overridden the onCleared() function. This function is called when the Android system destroys the ViewModel.

Hilt ViewModels scoped to Fragment lifecycle

New Hilt ViewModels can be created and injected into each Fragment if requested. This means that each Fragment instance will receive different instances of ViewModel.

In BlankFragment1 and BlankFragment2, perform the steps below:

  1. Add the fragmentViewModel member below. The viewModels() property delegate will provide a ViewModel scoped to the Fragment lifecycle. viewModels() is not only used with Hilt, but it can be used to provide simple ViewModel instances as well when not using Hilt.

     private val fragmentViewModel: FragmentViewModel by viewModels()
  2. Append to onCreate() the code below. This will log when the Fragment is created and which ViewModel instance it receives.

     Log.d(TAG, "Fragment ${hashCode()} is created with ViewModel ${fragmentViewModel.hashCode()} injected.")
  3. Override onDestroy() and append the code below. Leave the super() call as-is. This will log when the Fragment is destroyed. Shortly after the Fragment is destroyed, we should be able to see that the ViewModel calls its onClear() function as well.

     Log.d(TAG, "Fragment ${hashCode()} is being destroyed.")

While running the App with the Logcat filter BLANK_FRAGMENT|FRAGMENT_VIEW_MODEL in the Debug channel, we can see that the Fragment/ViewModel pair are being created and destroyed together (when you create another Fragment).

1.jpg

Hilt ViewModels scoped to Activity lifecycle

Hilt can also inject the same ViewModel instance to different instances of the same Fragment. We will apply this behavior to BlankFragment3.

  1. Repeat the steps that you did in the previous section to BlankFragment3.
  2. Replace the viewModel() delegate with activityViewModel(). This will ensure that the ViewModel received is scoped to the Activitys lifecycle; this can mean the entire application lifecycle in many cases.

When running the app and recreating BlankFragment3 multiple times, we can see that the same ViewModel instance is injected into multiple different instances of BlankFragment3.

2.jpg

Solution Code

MainActivity.kt

package com.codelab.daniwebhiltviewmodels

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Button
import androidx.fragment.app.commit
import androidx.fragment.app.replace
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)

       val toFragment1Button = findViewById<Button>(R.id.button_toFragment1)
       val toFragment2Button = findViewById<Button>(R.id.button_toFragment2)
       val toFragment3Button = findViewById<Button>(R.id.button_toFragment3)

       toFragment1Button.setOnClickListener{
           supportFragmentManager.commit {
               replace<BlankFragment1>(R.id.fragmentContainerView)
           }
       }

       toFragment2Button.setOnClickListener {
           supportFragmentManager.commit {
               replace<BlankFragment2>(R.id.fragmentContainerView)
           }
       }

       toFragment3Button.setOnClickListener {
           supportFragmentManager.commit {
               replace<BlankFragment3>(R.id.fragmentContainerView)
           }
       }

   }
}

FragmentViewModel.kt

package com.codelab.daniwebhiltviewmodels

import android.app.Application
import android.util.Log
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject

private const val TAG = "FRAGMENT_VIEW_MODEL"

@HiltViewModel
class FragmentViewModel @Inject constructor(private val application: Application) : ViewModel() {

   override fun onCleared() {
       super.onCleared()

       Log.d(TAG, "ViewModel ${hashCode()} is queued to be destroyed.")
   }

}

BlankFragment1.kt

package com.codelab.daniwebhiltviewmodels

import android.os.Bundle
import android.util.Log
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint

// TODO: Rename parameter arguments, choose names that match
// the fragment initialization parameters, e.g. ARG_ITEM_NUMBER
private const val ARG_PARAM1 = "param1"
private const val ARG_PARAM2 = "param2"

private const val TAG = "BLANK_FRAGMENT_1"

/**
* A simple [Fragment] subclass.
* Use the [BlankFragment1.newInstance] factory method to
* create an instance of this fragment.
*/
@AndroidEntryPoint
class BlankFragment1 : Fragment() {
   // TODO: Rename and change types of parameters
   private var param1: String? = null
   private var param2: String? = null

   private val fragmentViewModel: FragmentViewModel by viewModels()

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       arguments?.let {
           param1 = it.getString(ARG_PARAM1)
           param2 = it.getString(ARG_PARAM2)
       }

       Log.d(TAG, "Fragment ${hashCode()} is created with ViewModel ${fragmentViewModel.hashCode()} injected.")
   }

   override fun onCreateView(
       inflater: LayoutInflater, container: ViewGroup?,
       savedInstanceState: Bundle?
   ): View? {
       //Log.d(TAG, fragmentViewModel.toString())
       // Inflate the layout for this fragment
       return inflater.inflate(R.layout.fragment_blank1, container, false)
   }

   override fun onDestroy() {
       super.onDestroy()
       Log.d(TAG, "Fragment ${hashCode()} is being destroyed.")
   }

   companion object {
       /**
        * Use this factory method to create a new instance of
        * this fragment using the provided parameters.
        *
        * @param param1 Parameter 1.
        * @param param2 Parameter 2.
        * @return A new instance of fragment BlankFragment3.
        */
       // TODO: Rename and change types and number of parameters
       @JvmStatic
       fun newInstance(param1: String, param2: String) =
           BlankFragment1().apply {
               arguments = Bundle().apply {
                   putString(ARG_PARAM1, param1)
                   putString(ARG_PARAM2, param2)
               }
           }
   }
}

BlankFragment2.kt

package com.codelab.daniwebhiltviewmodels

import android.os.Bundle
import android.util.Log
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint

// TODO: Rename parameter arguments, choose names that match
// the fragment initialization parameters, e.g. ARG_ITEM_NUMBER
private const val ARG_PARAM1 = "param1"
private const val ARG_PARAM2 = "param2"

private const val TAG = "BLANK_FRAGMENT_2"

/**
* A simple [Fragment] subclass.
* Use the [BlankFragment2.newInstance] factory method to
* create an instance of this fragment.
*/
@AndroidEntryPoint
class BlankFragment2 : Fragment() {
   // TODO: Rename and change types of parameters
   private var param1: String? = null
   private var param2: String? = null

   private val fragmentViewModel: FragmentViewModel by viewModels()

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       arguments?.let {
           param1 = it.getString(ARG_PARAM1)
           param2 = it.getString(ARG_PARAM2)
       }

       Log.d(TAG, "Fragment ${hashCode()} is created with ViewModel ${fragmentViewModel.hashCode()} injected.")
   }

   override fun onCreateView(
       inflater: LayoutInflater, container: ViewGroup?,
       savedInstanceState: Bundle?
   ): View? {
       // Inflate the layout for this fragment
       return inflater.inflate(R.layout.fragment_blank2, container, false)
   }

   override fun onDestroy() {
       super.onDestroy()
       Log.d(TAG, "Fragment ${hashCode()} is being destroyed.")
   }

   companion object {
       /**
        * Use this factory method to create a new instance of
        * this fragment using the provided parameters.
        *
        * @param param1 Parameter 1.
        * @param param2 Parameter 2.
        * @return A new instance of fragment BlankFragment2.
        */
       // TODO: Rename and change types and number of parameters
       @JvmStatic
       fun newInstance(param1: String, param2: String) =
           BlankFragment2().apply {
               arguments = Bundle().apply {
                   putString(ARG_PARAM1, param1)
                   putString(ARG_PARAM2, param2)
               }
           }
   }
}

BlankFragment3.kt

package com.codelab.daniwebhiltviewmodels

import android.os.Bundle
import android.util.Log
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint

// TODO: Rename parameter arguments, choose names that match
// the fragment initialization parameters, e.g. ARG_ITEM_NUMBER
private const val ARG_PARAM1 = "param1"
private const val ARG_PARAM2 = "param2"

private const val TAG = "BLANK_FRAGMENT_3"

/**
* A simple [Fragment] subclass.
* Use the [BlankFragment3.newInstance] factory method to
* create an instance of this fragment.
*/
@AndroidEntryPoint
class BlankFragment3 : Fragment() {
   // TODO: Rename and change types of parameters
   private var param1: String? = null
   private var param2: String? = null

   private val fragmentViewModel: FragmentViewModel by activityViewModels()

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       arguments?.let {
           param1 = it.getString(ARG_PARAM1)
           param2 = it.getString(ARG_PARAM2)
       }

       Log.d(TAG, "Fragment ${hashCode()} is created with ViewModel ${fragmentViewModel.hashCode()} injected.")
   }

   override fun onCreateView(
       inflater: LayoutInflater, container: ViewGroup?,
       savedInstanceState: Bundle?
   ): View? {
       // Inflate the layout for this fragment
       return inflater.inflate(R.layout.fragment_blank3, container, false)
   }

   override fun onDestroy() {
       super.onDestroy()
       Log.d(TAG, "Fragment ${hashCode()} is being destroyed.")
   }

   companion object {
       /**
        * Use this factory method to create a new instance of
        * this fragment using the provided parameters.
        *
        * @param param1 Parameter 1.
        * @param param2 Parameter 2.
        * @return A new instance of fragment BlankFragment3.
        */
       // TODO: Rename and change types and number of parameters
       @JvmStatic
       fun newInstance(param1: String, param2: String) =
           BlankFragment3().apply {
               arguments = Bundle().apply {
                   putString(ARG_PARAM1, param1)
                   putString(ARG_PARAM2, param2)
               }
           }
   }
}

MyApplication.kt

package com.codelab.daniwebhiltviewmodels

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

@HiltAndroidApp
class ExampleApplication : Application()

**activity_main.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="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">

   <Button
       android:id="@+id/button_toFragment1"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:text="@string/to_fragment_1"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintHorizontal_bias="0.5"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent" />

   <Button
       android:id="@+id/button_toFragment2"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:text="@string/to_fragment_2"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintHorizontal_bias="0.5"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/button_toFragment1" />

   <Button
       android:id="@+id/button_toFragment3"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:text="@string/to_fragment_3"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintHorizontal_bias="0.5"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/button_toFragment2" />

   <androidx.fragment.app.FragmentContainerView
       android:id="@+id/fragmentContainerView"
       android:name="com.codelab.daniwebhiltviewmodels.BlankFragment1"
       android:layout_width="match_parent"
       android:layout_height="0dp"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@id/button_toFragment3" />
</androidx.constraintlayout.widget.ConstraintLayout>

fragment_blank_1.xml

<?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=".BlankFragment1">

   <!-- TODO: Update blank fragment layout -->
   <TextView
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       android:text="@string/hello_blank_fragment" />

</FrameLayout>

fragment_blank_2.xml

<?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=".BlankFragment2">

   <!-- TODO: Update blank fragment layout -->
   <TextView
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       android:text="@string/hello_blank_fragment2" />

</FrameLayout>

fragment_blank_3.xml

<?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=".BlankFragment3">

   <!-- TODO: Update blank fragment layout -->
   <TextView
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       android:text="@string/hello_blank_fragment3" />

</FrameLayout>

strings.xml

<resources>
   <string name="app_name">Daniweb Hilt ViewModels</string>
   <string name="to_fragment_1">To Fragment 1</string>
   <string name="to_fragment_2">To Fragment 2</string>
   <string name="to_fragment_3">To Fragment 3</string>
   <string name="hello_blank_fragment">Hello blank fragment</string>
   <string name="hello_blank_fragment2">Hello blank fragment2</string>
   <string name="hello_blank_fragment3">Hello blank fragment3</string>
</resources>

Project build.gradle

buildscript {
   dependencies {
       classpath 'com.google.dagger:hilt-android-gradle-plugin:2.40.5'
   }
}

// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
   id 'com.android.application' version '7.1.1' apply false
   id 'com.android.library' version '7.1.1' apply false
   id 'org.jetbrains.kotlin.android' version '1.6.10' apply false
}

task clean(type: Delete) {
   delete rootProject.buildDir
}

Module build.gradle

plugins {
   id 'com.android.application'
   id 'org.jetbrains.kotlin.android'
   id 'kotlin-kapt'
   id 'dagger.hilt.android.plugin'
}

android {
   compileSdk 32

   defaultConfig {
       applicationId "com.codelab.daniwebhiltviewmodels"
       minSdk 21
       targetSdk 32
       versionCode 1
       versionName "1.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 {
   implementation "com.google.dagger:hilt-android:2.40.5"
   implementation "androidx.fragment:fragment-ktx:1.4.1"
   kapt "com.google.dagger:hilt-compiler:2.40.5"

   implementation 'androidx.legacy:legacy-support-v4:1.0.0'
   implementation 'androidx.core:core-ktx:1.7.0'
   implementation 'androidx.appcompat:appcompat:1.4.1'
   implementation 'com.google.android.material:material:1.5.0'
   implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
   testImplementation 'junit:junit:4.13.2'
   androidTestImplementation 'androidx.test.ext:junit:1.1.3'
   androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
   package="com.codelab.daniwebhiltviewmodels">

   <application
       android:name=".ExampleApplication"
       android:allowBackup="true"
       android:icon="@mipmap/ic_launcher"
       android:label="@string/app_name"
       android:roundIcon="@mipmap/ic_launcher_round"
       android:supportsRtl="true"
       android:theme="@style/Theme.DaniwebHiltViewModels">
       <activity
           android:name=".MainActivity"
           android:exported="true">
           <intent-filter>
               <action android:name="android.intent.action.MAIN" />

               <category android:name="android.intent.category.LAUNCHER" />
           </intent-filter>
       </activity>
   </application>

</manifest>
Summary

We have learned how to inject ViewModels with Hilt in this tutorial. The full project code can be found at https://github.com/dmitrilc/DaniwebHiltViewModels

Android Native – Reusable Layouts with View Binding

Introduction

In Android development, reusable layouts can be used to reduce duplicate code across XML files. In this tutorial, we will learn how to reuse layout files, especially with View Binding enabled.

Goals

At the end of the tutorial, you would have learned:

  1. How to reuse layouts.
  2. How to use View Binding with reusable layouts.
Tools Required
  1. Android Studio. The version used in this tutorial is Bumblebee 2021.1.1 Patch 1.
Prerequisite Knowledge
  1. Basic Android.
  2. Basic ViewBinding.
Project Setup

To follow along with the tutorial, perform the steps below:

  1. Create a new Android project with the default Empty Activity.

  2. Give the default Hello World! TextView an android:id of textView_default.

  3. Create another layout resource file called reusable_layout.xml. Copy and paste the code below to replace all of the content of this file. This code removes the default ConstraintLayout because it is not necessary for our use case. It also adds a TextView with an id.

     <?xml version="1.0" encoding="utf-8"?>
     <TextView xmlns:android="http://schemas.android.com/apk/res/android"
        android:id="@+id/textView_reusable"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="TextView" />
  4. Enable View Binding in the Module gradle.build file, inside of the android {} tag. This will instruct the compiler to generate Binding classes at development time.

     buildFeatures {
         viewBinding true
     }
  5. Add the binding to activity_main.xml in MainActivity with the code below.

     private lateinit var binding: ActivityMainBinding
  6. Replace the current onCreate() call with the code below. We now inflate the layout using View Binding instead of R.id.

     override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
    
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
     }
Project Overview

Currently, our project is a simple app with a Hello World! TextView. The presentation of the app is not the focus of the tutorial though. At the end of the tutorial, we would be able to reuse the reusable_layout.xml in activitiy_main.xml and referencing it with View Binding as well.

Reusing Layouts

To reuse a layout, we only need to do two things:

  1. Create the reusable layout as an independent layout. The reusable layout root is not required to be a ViewGroup. We have already created this layout with reusable_layout.xml.
  2. In the parent layout that we watch to attach the reusable layout to, we need to add the <include> tag where the reusable layout would normally be placed.

Let us do this now for our project.

  1. Open activity_main.xml.

  2. Add the <include> tag below <TextView>.

     <include/>
  3. Give the <include> tag a layout attribute, with its value being the path to the reusable layout.

     <include
        layout="@layout/reusable_layout"/>
  4. The reusable layout would now show up in the Design surface as well.
    1.png

  5. But there are two new problems: the included View is not constrained at all and the compiler does not provide any error. Normally, a child view of ConstraintLayout is required to have constraints. It seems like the compiler does not throw an error for missing constraints in child views added via <include>, so there is nothing that we can do about it. Fortunately, if we select the <include> tag in the Component Tree and open its Attributes panel, we can at least see that the ConstraintLayout expects the child to have constraints, so at least we should add the constraints by ourselves.
    2.png

  6. Replace the current <include> with the code below. This will add constraints to the included layout and situate it under the default TextView. Everytime that you override any layout_x attribute of an <include> tag, you must also override both <layout_width> and <layout_height> attributes. Because we have overridden <layout_constraintX> attributes, we are required to override <layout_width> and <layout_height> as well, even if that means duplicating code already declared in the reusable layout.

     <include
        layout="@layout/reusable_layout"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/textView_default" />
  7. Select the <include> tag again with the Attributes panel opened and we should see the warnings cleared out.

3.png

Referencing the included View via View Binding

As of right now, our project is unable to reference the child textView_reusable from the parent View Binding.

5.png

For the compiler to generate Binding classes, we simply provide <include> with an id of our choice.

android:id="@+id/include"

We should be able to reference the child view immediately via include. If you do not see it showing up, you might need to clean and rebuild your project.

6.png

Our project depends on the compiler to generate Binding classes. If you do not see generated Binding classes after running Gradle tasks Clean and Rebuild, you should check whether you are running the latest version of Gradle and if it works with the AGP version. The AGP version used in this project is 7.1.1 and Gradle version is 7.3.3.

When running the app, we can see that it is working correctly.

7.png

Solution Code

MainActivity.kt

package com.codelab.daniwebreusablelayoutsandviewbinding

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import com.codelab.daniwebreusablelayoutsandviewbinding.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {
   private lateinit var binding: ActivityMainBinding

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)

       binding = ActivityMainBinding.inflate(layoutInflater)
       setContentView(binding.root)

       binding.include.textViewReusable.text = "Replaced Text"
   }
}

activitiy_main.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="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">

   <TextView
       android:id="@+id/textView_default"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:text="Hello World!"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintLeft_toLeftOf="parent"
       app:layout_constraintRight_toRightOf="parent"
       app:layout_constraintTop_toTopOf="parent" />

   <include
       android:id="@+id/include"
       layout="@layout/reusable_layout"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@id/textView_default" />

</androidx.constraintlayout.widget.ConstraintLayout>

reusable_layout.xml

<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
   android:id="@+id/textView_reusable"
   android:layout_width="wrap_content"
   android:layout_height="wrap_content"
   android:text="TextView" />

Module build.gradle

plugins {
   id 'com.android.application'
   id 'org.jetbrains.kotlin.android'
}

android {
   compileSdk 32

   buildFeatures {
       viewBinding true
   }

   defaultConfig {
       applicationId "com.codelab.daniwebreusablelayoutsandviewbinding"
       minSdk 21
       targetSdk 32
       versionCode 1
       versionName "1.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 {

   implementation 'androidx.core:core-ktx:1.7.0'
   implementation 'androidx.appcompat:appcompat:1.4.1'
   implementation 'com.google.android.material:material:1.5.0'
   implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
   testImplementation 'junit:junit:4.13.2'
   androidTestImplementation 'androidx.test.ext:junit:1.1.3'
   androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}
Summary

We have learned how to reuse layouts and referencing them via View Binding as well. The full project code can be found at https://github.com/dmitrilc/DaniwebReusableLayoutsAndViewBinding

Android Native – sync MediaPlayer progress to Seekbar

Introduction

MediaPlayer (android.media.MediaPlayer) is a popular way to play media files, and combining it with a SeekBar can greatly improve the user experience. In this tutorial, we will learn how to synchronize a MediaPlayer progress to a SeekBar position.

Goals

At the end of the tutorial, you would have learned:

  1. How to sync an active MediaPlayer to a SeekBar.
Tools Required
  1. Android Studio. The version used in this tutorial is Bumblebee 2021.1.1 Patch 1.
Prerequisite Knowledge
  1. Intermediate Android.
  2. ActivityResult APIs.
  3. Storage Access Framework (SAF).
  4. Coroutines.
Project Setup

To follow along with the tutorial, perform the steps below:

  1. Create a new Android project with the default Empty Activity.

  2. Give the default Hello World! TextView android:id of textView_time.

  3. Completely remove the android:text attribute from textView_time.

  4. Add the tools:text attribute to textView_time with the value of 0:00.

  5. Add the android:textSize attribute with the value of 32sp.

  6. Constraint textView_time to the top, start, and end of ConstraintLayout, but leave the bottom side unconstrained.

  7. Your textView_time should look like the code below.

     <TextView
        android:id="@+id/textView_time"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="32sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:text="0.00" />
  8. Download the .mp3 file called Unexpected Gifts of Spring by Alextree from the FreeMusicArchive. This song is licensed under CC BY 4.0. You are recommended to download the file from the AVDs built-in web browser. If you have downloaded the file to your development machine, you can also drag and drop the file into the AVDs via the Device File Explorer, which will trigger a download action on the AVD (this behavior has only been tested on a Windows machine, I am not sure if this works on a Mac/Linux). Getting the files using these methods will automatically add an entry into the MediaStore.

  9. Add the LifecycleScope KTX extension to your module build.gradle file.

     implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.0'
Add the SeekBar

SeekBar is a built-in Android View, which extends ProgressBar. It contains a draggable circle that end users can drag to specific points on a timeline.

progress.jpg

Perform the steps below to add a SeekBar into the project.

  1. Open activity_main.xml in the Code view.

  2. Copy and paste the code below into activity_main.xml.

     <SeekBar
        android:id="@+id/seekBar"
        android:layout_width="match_parent"
        android:layout_height="64dp"
        android:layout_marginHorizontal="16dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/textView_time" />
  3. Alternatively, if you prefer to configure your own SeekBar from scratch, it can also be found at Palette > Widgets, in the Design view.

  4. Now, add these attributes below into textView_time to finish creating a vertical chain for the two Views.

     app:layout_constraintBottom_toTopOf="@id/seekBar"
     app:layout_constraintHorizontal_bias="0.5"
  5. We will need to reference these Views later, so append these lines of code to MainActivity#onCreate().

     //Gets the textView_time reference
     val timeView = findViewById<TextView>(R.id.textView_time)
    
     //Gets the seekBar reference
     val seekBar = findViewById<SeekBar>(R.id.seekBar)
Open an mp3 File

The next thing that we need in our project is a way to open the mp3 file that we downloaded earlier. We will use SAF/ActivityResult APIs for this.

  1. Append the code below to MainActivity#onCreate().

     //Launcher to open file with a huge callback. Organize in real code.
     val openMusicLauncher = registerForActivityResult(OpenDocument()){ uri ->
    
     }
    
     val mimeTypes = arrayOf("audio/mpeg")
     openMusicLauncher.launch(mimeTypes)
  2. The code snippet above will start a Content Picker UI for the end user to pick a file matching the specified mime type. The chosen mime type of audio/mpeg will match .mp3 files, according to this Common MIME Types list.

Add the MediaPlayer

Now, we need to add a MediaPlayer object into our App for playing music files. To add MediaPlayer into our code, follow the steps below:

  1. It is recommended to call MediaPlayer#release() to release resources when you are done with it, so we will add a reference to the MediaPlayer as a class property, for easy access in multiple callbacks such as onPause(), onStop(), onDestroy().

     //Keeps a reference here to make it easy to release later
     private var mediaPlayer: MediaPlayer? = null
  2. Override MainActivity#onStop() to release and null out mediaPlayer.

     override fun onStop() {
        super.onStop()
        mediaPlayer?.release()
        mediaPlayer = null
     }
  3. Inside the openMusicLauncher callback, instantiate a MediaPlayer using the factory function MediaPlayer#create() and assign it to mediaPlayer.

     mediaPlayer = MediaPlayer.create(applicationContext, uri)
  4. Since mediaPlayer is nullable, we will chain the newly created MediaPlayer with an also {} scope function block to skip multiple null checks in the next few steps.

     mediaPlayer = MediaPlayer.create(applicationContext, uri)
        .also { //also {} scope function skips multiple null checks
    
        }
Synchronize SeekBar (and TextView) to MediaPlayer progress

The SeekBar that the end user sees on the screen must scale relatively with the duration of the media file.

  1. So we will have to set the SeekBar max value corresponding to the file duration. Inside the also {} block, add the code below.

     seekBar.max = it.duration
  2. Now, start() the MediaPlayer.

     it.start()
  3. Launch a coroutine running the Main thread with the code below.

     //Should be safe to use this coroutine to access MediaPlayer (not thread-safe)
     //because it uses MainCoroutineDispatcher by default
     lifecycleScope.launch {
        }
        //Can also release mediaPlayer here, if not looping.
     }
  4. Inside of launch {}, add a while() loop conditioned to the Boolean MediaPlayer.isPlaying.

     //Should be safe to use this coroutine to access MediaPlayer (not thread-safe)
     //because it uses MainCoroutineDispatcher by default
     lifecycleScope.launch {
        while (it.isPlaying){
        }
        //Can also release mediaPlayer here, if not looping.
     }
  5. Inside of this while() loop, we can synchronize SeekBar#progress with MediaPlayer.currentPosition.

     lifecycleScope.launch {
        while (it.isPlaying){
            seekBar.progress = it.currentPosition
        }
        //Can also release mediaPlayer here, if not looping.
     }
  6. We can also synchronize TextView#text with MediaPlayer.currentPosition. The milliseconds extension property of Int is used here for convenience because its default toString() form is quite readable (you will see later). Time-formatting is not the focus of this tutorial.

     //Should be safe to use this coroutine to access MediaPlayer (not thread-safe)
     //because it uses MainCoroutineDispatcher by default
     lifecycleScope.launch {
        while (it.isPlaying){
            seekBar.progress = it.currentPosition
            timeView.text = "${it.currentPosition.milliseconds}"
        }
        //Can also release mediaPlayer here, if not looping.
     }
  7. Finally, add a delay() to the coroutine. This ensures that the seekBar will only update every one second.

     lifecycleScope.launch {
        while (it.isPlaying){
            seekBar.progress = it.currentPosition
            timeView.text = "${it.currentPosition.milliseconds}"
            delay(1000)
        }
        //Can also release mediaPlayer here, if not looping.
     }
Synchronize MediaPlayer progress (and TextView) to SeekBar position

The code that we have so far will only update the SeekBar position and TextView content to the MediaPlayer progress. We will have to add a SeekBar.OnSeekBarChangeListener object to the SeekBar to monitor for changes. Follow the steps below to complete the tutorial.

  1. Append the object below to the openMusicLauncher callback. We have only implemented onProgressChanged because that is all we need for now. We also used the function MediaPlayer#seekTo() to seek a specified time position.

     //Move this object somewhere else in real code
     val seekBarListener = object : SeekBar.OnSeekBarChangeListener {
        override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
            if (fromUser){
                //sets the playing file progress to the same seekbar progressive, in relative scale
                mediaPlayer?.seekTo(progress)
    
                //Also updates the textView because the coroutine only runs every 1 second
                timeView.text = "${progress.milliseconds}"
            }
        }
        override fun onStartTrackingTouch(seekBar: SeekBar?) {}
        override fun onStopTrackingTouch(seekBar: SeekBar?) {}
     }
  2. Now, assign seekBarLisenter to seekBar.

     seekBar.setOnSeekBarChangeListener(seekBarListener)
Run the App

We are now ready to launch the App. Your App should behave similarly to the Gif below. You can ignore the other files in my AVDs Downloads directory that are not part of the project setup.

AudioLoop.gif

Solution Code

build.gradle

plugins {
   id 'com.android.application'
   id 'org.jetbrains.kotlin.android'
}

android {
   compileSdk 31

   defaultConfig {
       applicationId "com.codelab.daniwebandroidaudioseekbarsync"
       minSdk 21
       targetSdk 31
       versionCode 1
       versionName "1.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 {
   implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.0'
   implementation 'androidx.core:core-ktx:1.7.0'
   implementation 'androidx.appcompat:appcompat:1.4.1'
   implementation 'com.google.android.material:material:1.5.0'
   implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
   testImplementation 'junit:junit:4.13.2'
   androidTestImplementation 'androidx.test.ext:junit:1.1.3'
   androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}

activity_main.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="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">

   <TextView
       android:id="@+id/textView_time"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:textSize="32sp"
       app:layout_constraintBottom_toTopOf="@id/seekBar"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintHorizontal_bias="0.5"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent"
       tools:text="0.00" />

   <SeekBar
       android:id="@+id/seekBar"
       android:layout_width="match_parent"
       android:layout_height="64dp"
       android:layout_marginHorizontal="16dp"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintHorizontal_bias="0.5"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@id/textView_time" />
</androidx.constraintlayout.widget.ConstraintLayout>

MainActivity.kt

package com.codelab.daniwebandroidaudioseekbarsync

import android.media.MediaPlayer
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.SeekBar
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts.OpenDocument
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds

class MainActivity : AppCompatActivity() {

   //Keeps a reference here to make it easy to release later
   private var mediaPlayer: MediaPlayer? = null

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)

       //Gets the textView_time reference
       val timeView = findViewById<TextView>(R.id.textView_time)

       //Gets the seekBar reference
       val seekBar = findViewById<SeekBar>(R.id.seekBar)

       //Launcher to open file with a huge callback. Organize in real code.
       val openMusicLauncher = registerForActivityResult(OpenDocument()){ uri ->
           //Instantiates a MediaPlayer here now that we have the Uri.
           mediaPlayer = MediaPlayer.create(applicationContext, uri)
               .also { //also {} scope function skips multiple null checks
                   seekBar.max = it.duration
                   it.start()

                   //Should be safe to use this coroutine to access MediaPlayer (not thread-safe)
                   //because it uses MainCoroutineDispatcher by default
                   lifecycleScope.launch {
                       while (it.isPlaying){
                           seekBar.progress = it.currentPosition
                           timeView.text = "${it.currentPosition.milliseconds}"
                           delay(1000)
                       }
                       //Can also release mediaPlayer here, if not looping.
                   }
               }

           //Move this object somewhere else in real code
           val seekBarListener = object : SeekBar.OnSeekBarChangeListener {
               override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
                   if (fromUser){
                       //sets the playing file progress to the same seekbar progressive, in relative scale
                       mediaPlayer?.seekTo(progress)

                       //Also updates the textView because the coroutine only runs every 1 second
                       timeView.text = "${progress.milliseconds}"
                   }
               }
               override fun onStartTrackingTouch(seekBar: SeekBar?) {}
               override fun onStopTrackingTouch(seekBar: SeekBar?) {}
           }

           seekBar.setOnSeekBarChangeListener(seekBarListener)
       }

       val mimeTypes = arrayOf("audio/mpeg")
       openMusicLauncher.launch(mimeTypes)
   }

   override fun onStop() {
       super.onStop()
       mediaPlayer?.release()
       mediaPlayer = null
   }
}
Summary

Congrations, you have learned how to sync a MediaPlayer and a SeekBar. The full project code can be found at https://github.com/dmitrilc/DaniwebAndroidAudioSeekbarSync.

Plan to launch an Android mobile app for my business

In my hometown, I'm a budding entrepreneur with a cab business that I'd want to develop to meet current market demands. It would be more expensive to create a mobile app for both iOS and Android. As a result, I resorted to Android to build my own taxi booking application. Is there anyone who can help me come up with a nice idea for it? Should I rely on ready-made mobile apps on the market?

I'm simply looking for the most basic essential features right now, with the goal of eventually updating my app solution to include additional critical functions and a more appealing user experience.

Android Native – How to use TypeConverter for Room

Introduction

When working with a Room database, we are mostly restricted to save data using primitives (and boxed primitives). Reference types are not supported right out of the box, but can be enabled by creating additional TypeConverter. If you are familiar with ORM-light frameworks such as Spring JDBC, then you can think of TypeConverters as being conceptually similar to RowMapper.

In this tutorial, we will learn how to use TypeConverters in a Room database. Existing basic knowledge of Room is required for this tutorial.

Goals

At the end of the tutorial, you would have learned:

  1. How to create TypeConverters to save reference types in a Room database.
Tools Required
  1. Android Studio. The version used in this tutorial is Bumblebee 2021.1.1 Patch 1.
Prerequisite Knowledge
  1. Basic Android.
  2. Basic Room.
  3. Basic Serialization.
Project Setup

To follow along with the tutorial, perform the steps below:

  1. Create a new Android project with the default Empty Activity.

  2. Add the dependencies below into your Module build.gradle file inside the dependencies {} block.

     def roomVersion = "2.4.1"
    
     implementation "androidx.room:room-runtime:$roomVersion"
     annotationProcessor "androidx.room:room-compiler:$roomVersion"
    
     //To use Kotlin annotation processing tool (kapt)
     kapt "androidx.room:room-compiler:$roomVersion"
    
     //Kotlin Extensions and Coroutines support for Room
     implementation "androidx.room:room-ktx:$roomVersion"
    
     //lifecycle scope
     implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'
  3. In the same file, add the kapt annotation processor inside the plugins {} block.

     id 'org.jetbrains.kotlin.kapt'
  4. Create a new file called Teacher.kt and add the code below.

     data class Teacher(
        val name: String,
        val age: Int
     ) {
        override fun toString(): String {
            return "$name:$age"
        }
     }
  5. Create a new file called Classroom.kt and add the code below.

     import androidx.room.ColumnInfo
     import androidx.room.Entity
     import androidx.room.PrimaryKey
    
     @Entity
     data class Classroom(
        @PrimaryKey(autoGenerate = true) val uid: Int = 0,
        val grade: Grade,
        @ColumnInfo(name = "homeroom_teacher") val homeroomTeacher: Teacher
     )
  6. Create a new file called ClassroomDao.kt and add the code below.

     import androidx.room.*
    
     @Dao
     interface ClassroomDao {
        @Query("SELECT * FROM classroom")
        fun getAll(): List<Classroom>
    
        @Insert(onConflict = OnConflictStrategy.REPLACE)
        fun insertAll(vararg classrooms: Classroom)
     }
  7. Create a new file called Grade.kt and add the code below.

     enum class Grade {
        JUNIOR, SOPHOMORE, SENIOR
     }
  8. Create a new file called SchoolDatabase.kt and add the code below.

     import androidx.room.BuiltInTypeConverters
     import androidx.room.Database
     import androidx.room.RoomDatabase
     import androidx.room.TypeConverters
    
     @Database(entities = [Classroom::class], version = 1)
     //@TypeConverters(
     //    Converters::class,
     //    builtInTypeConverters = BuiltInTypeConverters(
     //        enums = BuiltInTypeConverters.State.DISABLED
     //    )
     //)
     abstract class SchoolDatabase : RoomDatabase() {
        abstract fun classroomDao(): ClassroomDao
     }
  9. Create a new file called Converters.kt and add the code below.

     package com.codelab.daniwebandroidroomdatabaseconverter
    
     import androidx.room.TypeConverter
    
     class Converters {
    
        @TypeConverter
        fun teacherToString(teacher: Teacher) = "$teacher" //Other options are json string, serialized blob
    
        @TypeConverter
        fun stringToTeacher(value: String): Teacher {
            val name = value.substringBefore(':')
            val age = value.substringAfter(':').toInt()
    
            return Teacher(name, age)
        }
    
     }
Project Overview

Our skeleton project for this tutorial is quite simple. We completely ignore the UI, ViewModel or Hilt DI to focus on the database here. The Room database SchoolDatabase includes a single table that stores classroom information. Each classroom contains an id, the Grade, and a Teacher.

Because Teacher is a reference type, so Room will refuse to compile for now. Upon compiling, we will receive the error messages below.

error: Cannot figure out how to save this field into database. You can consider adding a type converter for it.
error: Cannot figure out how to save this field into database. You can consider adding a type converter for it.
    private final com.codelab.daniwebandroidroomdatabaseconverter.Teacher homeroomTeacher = null;

error: Cannot figure out how to read this field from a cursor.
    private final com.codelab.daniwebandroidroomdatabaseconverter.Teacher homeroomTeacher = null;

These errors occur because we have not registered any TypeConverter to our SchoolDatabase.

Creating Converters

To help Room understand how to convert these reference types, we will have to create TypeConverters. TypeConverters can only be used to convert a column. In the the picture below, a TypeConverter pair can be used to convert a column into a primitive that Room allows, and vice versa.

1.jpg

TypeConverters are just functions annotated with @TypeConverter. We have already created some TypeConverters in the file Converters.kt, so let us inspect some of them.

The first function that we are going to look at is teacherToString().

@TypeConverter
fun teacherToString(teacher: Teacher) = "$teacher" //Other options are json string, serialized blob

In teacherToString(), the only three things that really matter for Room are the parameter type, the return type, and the @TypeConverter annotation. The compiler uses information from them to determine whether Room can properly use them to convert from one type to another. The parameter type and the return type are reversed when it comes to stringToTeacher().

@TypeConverter
fun stringToTeacher(value: String): Teacher {
   val name = value.substringBefore(':')
   val age = value.substringAfter(':').toInt()

   return Teacher(name, age)
}

2.jpg

I have decided to store a string representation of a Teacher object in this tutorial because it is quick and simple. I have also overridden Teachers toString() to make the deserialization easier.

override fun toString(): String {
   return "$name:$age"
}

In real code, you can store your object in other ways, such as a serialized BLOB or a JSON string, with proper sanitization.

Pre-made TypeConverters

You might have noticed that I have not discussed the Grade enum at all. It is, after all, also a reference type. The simple reason why we do not have to provide TypeConverters for Grade is because Android already includes some premade TypeConverters from BuiltInTypeConverters.

Enums and UUID types are supported by default. The default support for enum uses the name() value. If that is not good enough for you, then you can also provide custom TypeConverters for Enum and UUID. Your custom TypeConverters take precedence over the builtin ones.

Register the Converters with Room

The next step that we would need to do is to register the Converters with the database. You can do that by applying an @Converters annotation to the RoomDatabase class. Note that @Converter and @Converters are different annotations. We already have the @TypeConverters annotation set up in SchoolDatabase.kt, but commented out. Uncomment it, and we will have the code below.

@TypeConverters(
   Converters::class,
   builtInTypeConverters = BuiltInTypeConverters(
       enums = BuiltInTypeConverters.State.DISABLED
   )
)

The builtInTypeConverters argument is entirely optional. If you do not provide it any value, then it will enable the default TypeConverters. If we run the App now, we will receive a compile error of:

error: Cannot figure out how to save this field into database. You can consider adding a type converter for it.
    private final com.codelab.daniwebandroidroomdatabaseconverter.Grade grade = null;

This is because we have told Room to disable the builtin TypeConverter for enums. We also did not provide any custom enum TypeConverter. Simply comment out the builtInTypeConverters argument for the code to compile.

@TypeConverters(
   Converters::class
/*    builtInTypeConverters = BuiltInTypeConverters(
       enums = BuiltInTypeConverters.State.DISABLED
   )*/
)

The Class object that we provided indicates that this class contains the @TypeConverter functions, so Room should look there for any type that it does not know how to convert.

Run the App

The app should compile correctly now, but it does not do anything useful yet.

  1. Add the top level variable below into MainActivity.kt.

     private const val TAG = "MAIN_ACTIVITY"
  2. Append the code below to MainActivity#onCreate().

     val db = Room.databaseBuilder(
        applicationContext,
        SchoolDatabase::class.java, "school-db"
     ).build()
    
     lifecycleScope.launch(Dispatchers.IO) {
        val classroomDao = db.classroomDao()
    
        val teacher1 = Teacher(
            name = "Mary",
            age = 35
        )
    
        val teacher2 = Teacher(
            name = "John",
            age = 28
        )
    
        val teacher3 = Teacher(
            name = "Diana",
            age = 46
        )
    
        val classroom1 = Classroom(
            grade = Grade.JUNIOR,
            homeroomTeacher = teacher1
        )
    
        val classroom2 = Classroom(
            grade = Grade.SOPHOMORE,
            homeroomTeacher = teacher2
        )
    
        val classroom3 = Classroom(
            grade = Grade.SENIOR,
            homeroomTeacher = teacher3
        )
    
        classroomDao.insertAll(
            classroom1,
            classroom2,
            classroom3
        )
    
        val classrooms = classroomDao.getAll()
    
        classrooms.forEach {
            Log.d(TAG, "$it")
        }
    
        db.clearAllTables()
     }

Run the app now. We should be able to see the output below in Logcat.

2022-02-14 13:59:18.700 12504-12551/com.codelab.daniwebandroidroomdatabaseconverter D/MAIN_ACTIVITY: Classroom(uid=1, grade=JUNIOR, homeroomTeacher=Mary:35)
2022-02-14 13:59:18.700 12504-12551/com.codelab.daniwebandroidroomdatabaseconverter D/MAIN_ACTIVITY: Classroom(uid=2, grade=SOPHOMORE, homeroomTeacher=John:28)
2022-02-14 13:59:18.701 12504-12551/com.codelab.daniwebandroidroomdatabaseconverter D/MAIN_ACTIVITY: Classroom(uid=3, grade=SENIOR, homeroomTeacher=Diana:46)
Solution Code

MainActivity.kt

package com.codelab.daniwebandroidroomdatabaseconverter

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import androidx.lifecycle.lifecycleScope
import androidx.room.Room
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

private const val TAG = "MAIN_ACTIVITY"

class MainActivity : AppCompatActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)

       val db = Room.databaseBuilder(
           applicationContext,
           SchoolDatabase::class.java, "school-db"
       ).build()

       lifecycleScope.launch(Dispatchers.IO) {
           val classroomDao = db.classroomDao()

           val teacher1 = Teacher(
               name = "Mary",
               age = 35
           )

           val teacher2 = Teacher(
               name = "John",
               age = 28
           )

           val teacher3 = Teacher(
               name = "Diana",
               age = 46
           )

           val classroom1 = Classroom(
               grade = Grade.JUNIOR,
               homeroomTeacher = teacher1
           )

           val classroom2 = Classroom(
               grade = Grade.SOPHOMORE,
               homeroomTeacher = teacher2
           )

           val classroom3 = Classroom(
               grade = Grade.SENIOR,
               homeroomTeacher = teacher3
           )

           classroomDao.insertAll(
               classroom1,
               classroom2,
               classroom3
           )

           val classrooms = classroomDao.getAll()

           classrooms.forEach {
               Log.d(TAG, "$it")
           }

           db.clearAllTables()
       }

   }
}

Classroom.kt

package com.codelab.daniwebandroidroomdatabaseconverter

import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity
data class Classroom(
   @PrimaryKey(autoGenerate = true) val uid: Int = 0,
   val grade: Grade,
   @ColumnInfo(name = "homeroom_teacher") val homeroomTeacher: Teacher
)

ClassroomDao

package com.codelab.daniwebandroidroomdatabaseconverter

import androidx.room.*

@Dao
interface ClassroomDao {
   @Query("SELECT * FROM classroom")
   fun getAll(): List<Classroom>

   @Insert(onConflict = OnConflictStrategy.REPLACE)
   fun insertAll(vararg classrooms: Classroom)
}

Converters.kt

package com.codelab.daniwebandroidroomdatabaseconverter

import androidx.room.TypeConverter

class Converters {

   @TypeConverter
   fun teacherToString(teacher: Teacher) = "$teacher" //Other options are json string, serialized blob

   @TypeConverter
   fun stringToTeacher(value: String): Teacher {
       val name = value.substringBefore(':')
       val age = value.substringAfter(':').toInt()

       return Teacher(name, age)
   }

}

Grade.kt

package com.codelab.daniwebandroidroomdatabaseconverter

enum class Grade {
   JUNIOR, SOPHOMORE, SENIOR
}

**SchoolDatabase.kt**

package com.codelab.daniwebandroidroomdatabaseconverter

import androidx.room.BuiltInTypeConverters
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters

@Database(entities = [Classroom::class], version = 1)
@TypeConverters(
   Converters::class
/*    builtInTypeConverters = BuiltInTypeConverters(
       enums = BuiltInTypeConverters.State.DISABLED
   )*/
)
abstract class SchoolDatabase : RoomDatabase() {
   abstract fun classroomDao(): ClassroomDao
}

Teacher.kt

package com.codelab.daniwebandroidroomdatabaseconverter

data class Teacher(
   val name: String,
   val age: Int
) {
   override fun toString(): String {
       return "$name:$age"
   }
}

Module **build.gradle**

plugins {
   id 'com.android.application'
   id 'org.jetbrains.kotlin.android'
   id 'org.jetbrains.kotlin.kapt'
}

android {
   compileSdk 32

   defaultConfig {
       applicationId "com.codelab.daniwebandroidroomdatabaseconverter"
       minSdk 21
       targetSdk 32
       versionCode 1
       versionName "1.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 {
   def roomVersion = "2.4.1"

   implementation "androidx.room:room-runtime:$roomVersion"
   annotationProcessor "androidx.room:room-compiler:$roomVersion"

   //To use Kotlin annotation processing tool (kapt)
   kapt "androidx.room:room-compiler:$roomVersion"

   //Kotlin Extensions and Coroutines support for Room
   implementation "androidx.room:room-ktx:$roomVersion"

   //lifecycle scope
   implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'

   implementation 'androidx.core:core-ktx:1.7.0'
   implementation 'androidx.appcompat:appcompat:1.4.1'
   implementation 'com.google.android.material:material:1.5.0'
   implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
   testImplementation 'junit:junit:4.13.2'
   androidTestImplementation 'androidx.test.ext:junit:1.1.3'
   androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}
Summary

We have learned how to use TypeConverters in a Room database. The full project code can be found at https://github.com/dmitrilc/DaniwebAndroidRoomDatabaseConverter.

Android Native – RecyclerView swipe-to-remove and drag-to-reorder

Introduction

In this tutorial, we will learn how to add swipe-to-remove and drag-to-reorder functionalities into RecyclerView.

Goals

At the end of the tutorial, you would have learned:

  1. How to add swipe-to-remove and drag-to-reorder functionality to a RecyclerView.
Tools Required
  1. Android Studio. The version used in this tutorial is Bumblebee 2021.1.1 Patch 1.
Prerequisite Knowledge
  1. Basic Android.
  2. Basic RecyclerView.
Project Setup

To follow along with the tutorial, perform the steps below:

  1. Create a new Android project with the default Empty Activity.

  2. Replace the content of activity_main.xml with the code below.

     <?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.recyclerview.widget.RecyclerView
            android:id="@+id/recycler_view"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
            app:layout_constraintTop_toTopOf="parent" />
     </androidx.constraintlayout.widget.ConstraintLayout>
  3. Create a new layout resource called item_view.xml. This is the ViewHolders layout. Copy and paste the code below.

     <?xml version="1.0" encoding="utf-8"?>
     <TextView xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        android:id="@+id/itemView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_margin="16dp"
        android:gravity="center"
        android:textSize="32sp"
        tools:text="TextView" />
  4. Create a new Kotlin file called Item.kt. Copy and paste the code below.

     data class Item(
        val content: String
     )
  5. Create a new Kotlin file called ItemAdapter.kt. Copy and paste the code below.

     import android.view.LayoutInflater
     import android.view.View
     import android.view.ViewGroup
     import android.widget.TextView
     import androidx.recyclerview.widget.RecyclerView
    
     class ItemAdapter(private val dataset: List<Item>): RecyclerView.Adapter<ItemAdapter.ItemViewHolder>() {
    
        class ItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView){
            val textView: TextView = itemView.findViewById(R.id.itemView)
        }
    
        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder {
            val view = LayoutInflater.from(parent.context)
                .inflate(R.layout.item_view, parent, false)
    
            return ItemViewHolder(view)
        }
    
        override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
            holder.textView.text = dataset[position].content
        }
    
        override fun getItemCount() = dataset.size
     }
  6. In MainActivity#onCreate(), append the code below.

     //Creates a list of 10 elements
     val dataset = MutableList(10){
        Item("I am Item #$it")
     }
    
     //finds the RecyclerView
     val recyclerView = findViewById<RecyclerView>(R.id.recycler_view)
    
     //Assigns an adapter to RecyclerView
     recyclerView.adapter = ItemAdapter(dataset)
Project Overview

Currently, our project will compile and run just fine. All we have at the moment is a vanilla recycler with 10 items. As you can see from the Gif below with the pointer tracking option on, our RecyclerView currently does not register swiping and hold-to-drag actions.

VanillaRecycler.gif

At the end of the tutorial, we should be able to swipe to remove and hold-to-drag-to-reorder the RecyclerView.

ItemTouchHelper

ItemTouchHelper is a class provided by Android specifically to implement the functionalities that we want. Its most basic properties are:

  1. ItemTouchHelper.Callback: this is an abstract inner class. It is meant to be used only if you require fine control over the swipe and drop behaviors.
  2. ItemTouchHelper.SimpleCallback: this is also an abstract inner class. It already implements ItemTouchHelper.Callback, so we only have to implement two simple methods, onMove() and onSwipe(), to decide what to do when swiping and dragging actions are recorded.
  3. UP, DOWN, LEFT, RIGHT, START, END constants: These constants represent the actions that the user took, which the ItemTouchHelper.Callback class can use.

The picture below provides a rough picture of the relationship among the classes. LEFT and RIGHT are not listed because START and END can perform the same thing for both LTR and RTL layouts.

ItemTouchHelper.jpg

Implementing ItemTouchHelper.SimpleCallback

There are about 3 things that we need to perform to implement swipe-to-remove and drag-to-move.

  1. Create an ItemTouchHelper.SimpleCallback.
  2. Use that callback to create an ItemTouchHelper.
  3. Attaches the ItemTouchHelper to the RecyclerView.

Follow the steps below to implement ItemTouchHelper.SimpleCallback.

  1. Append the code below into MainActivity#onCreate(). Notice that we had to provide the bitwise or result of the direction constants.

     //Implementing the callback.
     val callback = object : ItemTouchHelper.SimpleCallback(
        ItemTouchHelper.UP or ItemTouchHelper.DOWN, //bitwise OR
        ItemTouchHelper.START or ItemTouchHelper.END //bitwise OR
     ) {
    
     }
  2. We have not implemented the required members yet, so let us start with the first required, onMove(). This function determines what to do during drag-and-drop behaviors. All we had to do was to get the adapter positions of the ViewHolders and then call the notifyItemMoved() publisher. When the RecyclerView receives that signal, it will automatically reorder the items for us. It is optional to update the original dataset for our tutorial App.

                override fun onMove(
                    recyclerView: RecyclerView,
                    viewHolder: RecyclerView.ViewHolder,
                    target: RecyclerView.ViewHolder
                ): Boolean {
                    val fromPosition = viewHolder.adapterPosition
                    val toPosition = target.adapterPosition
    
                    //modifying the dataset as well is optional for this tutorial
     //                val movedItem = dataset.removeAt(fromPosition)
     //                dataset.add(toPosition, movedItem)
    
                    //push specific event
                    recyclerView.adapter?.notifyItemMoved(fromPosition, toPosition)
    
                    return true
                }
  3. Next, we will need to implement the other required member, onSwiped(). All we had to do was to remove the item at the requested position, and then call the notifyItemRemoved() publisher.

     override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
        val position = viewHolder.adapterPosition
    
        //Actually removes the item from the dataset
        dataset.removeAt(position)
    
        //push specific event
        recyclerView.adapter?.notifyItemRemoved(position)
     }
Run the App

We only need two more lines of code before we can run the app.

  1. Create the ItemTouchHelper object.

     //Creates touch helper with callback
     val touchHelper = ItemTouchHelper(callback)
  2. Attach it to the RecyclerView.

     //attaches the helper to the recyclerView
     touchHelper.attachToRecyclerView(recyclerView)

Now, run the app. The swiping and dragging functions should work similarly to the Gif below.

SwipeDragRecycler.gif

Solution Code

MainActivity.kt

package com.codelab.daniwebrecyclerviewswipetoremove

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView

class MainActivity : AppCompatActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)

       //Creates a list of 10 elements
       val dataset = MutableList(10){
           Item("I am Item #$it")
       }

       //finds the RecyclerView
       val recyclerView = findViewById<RecyclerView>(R.id.recycler_view)

       //Assigns an adapter to RecyclerView
       recyclerView.adapter = ItemAdapter(dataset)

       //Implementing the callback.
       val callback = object : ItemTouchHelper.SimpleCallback(
           ItemTouchHelper.UP or ItemTouchHelper.DOWN, //bitwise OR
           ItemTouchHelper.START or ItemTouchHelper.END //bitwise OR
       ) {
           override fun onMove(
               recyclerView: RecyclerView,
               viewHolder: RecyclerView.ViewHolder,
               target: RecyclerView.ViewHolder
           ): Boolean {
               val fromPosition = viewHolder.adapterPosition
               val toPosition = target.adapterPosition

               //modifying the dataset as well is optional for this tutorial
//                val movedItem = dataset.removeAt(fromPosition)
//                dataset.add(toPosition, movedItem)

               //push specific event
               recyclerView.adapter?.notifyItemMoved(fromPosition, toPosition)

               return true
           }

           override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
               val position = viewHolder.adapterPosition

               //Actually removes the item from the dataset
               dataset.removeAt(position)

               //push specific event
               recyclerView.adapter?.notifyItemRemoved(position)
           }

       }

       //Creates touch helper with callback
       val touchHelper = ItemTouchHelper(callback)

       //attaches the helper to the recyclerView
       touchHelper.attachToRecyclerView(recyclerView)
   }
}

activity_main.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="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">

   <androidx.recyclerview.widget.RecyclerView
       android:id="@+id/recycler_view"
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
       app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

item_view.xml

<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:tools="http://schemas.android.com/tools"
   android:id="@+id/itemView"
   android:layout_width="match_parent"
   android:layout_height="wrap_content"
   android:layout_margin="16dp"
   android:gravity="center"
   android:textSize="32sp"
   tools:text="TextView" />

Item.kt

package com.codelab.daniwebrecyclerviewswipetoremove

data class Item(
   val content: String
)

ItemAdapter.kt

package com.codelab.daniwebrecyclerviewswipetoremove

import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView

class ItemAdapter(private val dataset: List<Item>): RecyclerView.Adapter<ItemAdapter.ItemViewHolder>() {

   class ItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView){
       val textView: TextView = itemView.findViewById(R.id.itemView)
   }

   override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder {
       val view = LayoutInflater.from(parent.context)
           .inflate(R.layout.item_view, parent, false)

       return ItemViewHolder(view)
   }

   override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
       holder.textView.text = dataset[position].content
   }

   override fun getItemCount() = dataset.size
}
Summary

We have learned how to add swipe-to-remove and drag-to-reorder in this tutorial. The full project code can be found at https://github.com/dmitrilc/DaniwebRecyclerViewSwipeToRemove.

Android Native – Typesafe Navigation with Args

Introduction

When using Navigation Components, you might have wondered how to pass arguments between destinations. We will learn how to do just that in this tutorial. This tutorial also builds upon the Basic Navigation tutorial, so if you are not familiar with Navigation Components, I would recommend you to check it out.

There are two parts to this tutorial. First, we will learn how to perform navigation with type safety. Secondly, we will learn how to pass arguments with Safe Args.

Goals

At the end of the tutorial, you would have learned:

  1. How to use Navigation with Type Safety.
  2. How to pass arguments between destinations with Safe Args.
Tools Required
  1. Android Studio. The version used in this tutorial is Bumblebee 2021.1.1 Patch 1.
Prerequisite Knowledge
  1. Basic Android.
  2. Basic Navigation.
Project Setup

To follow along with the tutorial, perform the steps below:

  1. Create a new Android project with the default Empty Activity.

  2. Remove the default Hello World! TextView.

  3. In the Project-level build.gradle file, add the buildscript{} block below. Make sure that you place it above the existing plugins{} block.

     buildscript {
        repositories {
            google()
        }
        dependencies {
            def nav_version = "2.4.0"
            classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
        }
     }
  4. In the Module-level build.gradle file, add the dependencies below to the dependencies{} block.

     def nav_version = "2.4.0"
    
     implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
     implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
  5. Also in the Module-level build.gradle file, add the plugin below into the plugins{} block.

     id 'androidx.navigation.safeargs.kotlin'
  6. Because of this bug, you will have to downgrade your Android Gradle Plugin (AGP) to version 7.0.4. Gradle version 7.4 seems to work fine as of this tutorial. You can change your Project settings by going to File > Project Structure > Project.
    gradle_version.jpg

  7. Performs a Gradle sync.

  8. In the same package as MainActivity.kt, create two new fragments called BlankFragment1.kt, BlankFragment2.kt along with their respective layout files fragment_blank1.xml and fragment_blank2.xml.

  9. Create a new navigation graph called nav_graph.xml.

  10. Replace the content of activity_main.xml with the code below.

     <?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"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">
    
        <androidx.fragment.app.FragmentContainerView
            android:id="@+id/fragmentContainerView"
            android:name="androidx.navigation.fragment.NavHostFragment"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:defaultNavHost="true"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:navGraph="@navigation/nav_graph" />
     </androidx.constraintlayout.widget.ConstraintLayout>
  11. Replace the content of nav_graph.xml with the code below.

     <?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"
        android:id="@+id/nav_graph"
        app:startDestination="@id/blankFragment1">
    
        <fragment
            android:id="@+id/blankFragment1"
            android:name="com.codelab.daniwebandroidtypesafenav.BlankFragment1"
            android:label="fragment_blank1"
            tools:layout="@layout/fragment_blank1" >
            <action
                android:id="@+id/action_blankFragment1_to_blankFragment2"
                app:destination="@id/blankFragment2" />
        </fragment>
        <fragment
            android:id="@+id/blankFragment2"
            android:name="com.codelab.daniwebandroidtypesafenav.BlankFragment2"
            android:label="fragment_blank2"
            tools:layout="@layout/fragment_blank2" />
     </navigation>
  12. Add the string resource below into strings.xml.

     <string name="second_screen">2nd Screen</string>
  13. Replace the content of fragment_blank1.xml with the code below.

     <?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/frameLayout"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".BlankFragment1" >
    
        <Button
            android:id="@+id/button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/second_screen"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
     </androidx.constraintlayout.widget.ConstraintLayout>
Typesafe Navigation

Before diving into navigation type safety, we will first look at what navigation without type safety is like. Copy and paste the overridden version of onViewCreated() below into BlankFragment1.

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
   super.onViewCreated(view, savedInstanceState)

   val navController = findNavController()

   val button = view.findViewById<Button>(R.id.button)

   button.setOnClickListener {
       //Will compile, but will crash at runtime. No Compiler checks (except for @IdRes).
       navController.navigate(R.id.button)

       //Will compile. Won't crash at runtime. No Compiler checks (except for @IdRes).
       //navController.navigate(R.id.action_blankFragment1_to_blankFragment2)

       //Will compile. Won't crash at runtime. Has Compiler checks
       //val direction = BlankFragment1Directions.actionBlankFragment1ToBlankFragment2()
       //navController.navigate(direction)
   }
}

The code snippet above presents three different ways to navigate to a destination using a NavController. Without type safety, we normally can navigate using the android:id of the navigation <action>. When dissecting the method signature of the NavController#navigate(),

public open fun navigate(@IdRes resId: Int)

then we can see that it will take just about any Int argument. We can pass a useful Int value from R.id, or any number, really. There is an annotation @IdRes applied to the parameter which helps a bit due to the IDEs annotation processor. If you were to provide a non-id number to navigate(), you will see some help from the IDE in the form of a compiler error, which can be suppressed.

navigate_9999.jpg

Right now, we are passing to navigate() a valid R.id.button,

navController.navigate(R.id.button)

but it is not a valid navigation <action>, so we will not see any compiler error. The code will compile, but it will crash at runtime when we press the Button.

java.lang.IllegalArgumentException: Navigation action/destination com.codelab.daniwebandroidtypesafenav:id/button cannot be found from the current destination 

NavCrash.gif

The second way to navigate using the <action> android:id is correct and will not crash, but it is still not typesafe.

navController.navigate(R.id.action_blankFragment1_to_blankFragment2)

Because we have added the safe-args dependencies into our project, we can now use navigation with type safety. Type safety is provided by the interface NavDirections. The safe-args dependencies will generate a class implementing NavDirections at development time for each originating Destination of an <action>. This class will have the same name as the originating fragment, but with the word Directions suffixed to it. This new class will also have methods with the same name as the <action> android:id, converted to camel case after stripping out underscores (_).

compiler.jpg

You can now comment out the first two ways to navigate and uncomment the last way to navigate with NavDirections.

Pass data with Safe Args

In this section, we will learn how to pass arguments with Safe Args.

Replace the content of fragment_blank2.xml with the code below.

<?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/frameLayout2"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".BlankFragment2">

   <TextView
       android:id="@+id/textView"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:textSize="32sp"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent"
       tools:text="Test Test Test" />

</androidx.constraintlayout.widget.ConstraintLayout>

To pass an argument to blankFragment2 when navigating to it from blankFragment1, we will have to do a couple of things.

  1. We have to add an <argument> element to the destination fragment (blankFragment2). This will trigger the compiler to add a parameter to the method actionBlankFragment1ToBlankFragment2(), so it will become actionBlankFragment1ToBlankFragment2(sampleArgs: String = default value) (if <argument> has the attribute android:defaultValue). In nav_graph.xml, inside of blankFragment2, add the <argument> below.

     <argument
        android:name="sampleArg"
        app:argType="string"
        android:defaultValue="blank" />
  2. You might have to rebuild the project for the compiler to generate the new class.

  3. Back in the Buttons onClickListener callback, we can replace the previous direction with the code below to pass an argument.

     val direction = BlankFragment1Directions.actionBlankFragment1ToBlankFragment2("Arg from fragment1 to fragment2")
  4. In order for BlankFragment2 to receive the arguments, we can use the navArgs() delegate from androidx.navigation.fragment to receive a type of BlankFragment2Args, which the compiler has generated for us as well. In BlankFragment2.kt, copy and paste the code below.

     val args: BlankFragment2Args by navArgs()
    
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
    
        val textView = view.findViewById<TextView>(R.id.textView)
        textView.text = args.sampleArg
     }

And that is the last step in this tutorial.

Run the App

We are not ready to run the App. Your app should behave similarly to the Gif below.

NavSafeArgs.gif

Solution Code

BlankFragment1.kt

package com.codelab.daniwebandroidtypesafenav

import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import androidx.navigation.fragment.findNavController

// TODO: Rename parameter arguments, choose names that match
// the fragment initialization parameters, e.g. ARG_ITEM_NUMBER
private const val ARG_PARAM1 = "param1"
private const val ARG_PARAM2 = "param2"

/**
* A simple [Fragment] subclass.
* Use the [BlankFragment1.newInstance] factory method to
* create an instance of this fragment.
*/
class BlankFragment1 : Fragment() {
   // TODO: Rename and change types of parameters
   private var param1: String? = null
   private var param2: String? = null

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       arguments?.let {
           param1 = it.getString(ARG_PARAM1)
           param2 = it.getString(ARG_PARAM2)
       }
   }

   override fun onCreateView(
       inflater: LayoutInflater, container: ViewGroup?,
       savedInstanceState: Bundle?
   ): View? {
       // Inflate the layout for this fragment
       return inflater.inflate(R.layout.fragment_blank1, container, false)
   }

   override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
       super.onViewCreated(view, savedInstanceState)

       val navController = findNavController()

       val button = view.findViewById<Button>(R.id.button)

       button.setOnClickListener {
           //Will compile, but will crash at runtime. No Compiler checks (except for @IdRes).
           //navController.navigate(R.id.button)

           //Will compile. Won't crash at runtime. No Compiler checks (except for @IdRes).
           //navController.navigate(R.id.action_blankFragment1_to_blankFragment2)

           //Will compile. Won't crash at runtime. Has Compiler checks
           //val direction = BlankFragment1Directions.actionBlankFragment1ToBlankFragment2()
           val direction = BlankFragment1Directions.actionBlankFragment1ToBlankFragment2("Arg from fragment1 to fragment2")
           navController.navigate(direction)
       }
   }

   companion object {
       /**
        * Use this factory method to create a new instance of
        * this fragment using the provided parameters.
        *
        * @param param1 Parameter 1.
        * @param param2 Parameter 2.
        * @return A new instance of fragment BlankFragment1.
        */
       // TODO: Rename and change types and number of parameters
       @JvmStatic
       fun newInstance(param1: String, param2: String) =
           BlankFragment1().apply {
               arguments = Bundle().apply {
                   putString(ARG_PARAM1, param1)
                   putString(ARG_PARAM2, param2)
               }
           }
   }
}

BlankFragment2.kt

package com.codelab.daniwebandroidtypesafenav

import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.navigation.fragment.navArgs

// TODO: Rename parameter arguments, choose names that match
// the fragment initialization parameters, e.g. ARG_ITEM_NUMBER
private const val ARG_PARAM1 = "param1"
private const val ARG_PARAM2 = "param2"

/**
* A simple [Fragment] subclass.
* Use the [BlankFragment2.newInstance] factory method to
* create an instance of this fragment.
*/
class BlankFragment2 : Fragment() {
   // TODO: Rename and change types of parameters
   private var param1: String? = null
   private var param2: String? = null

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       arguments?.let {
           param1 = it.getString(ARG_PARAM1)
           param2 = it.getString(ARG_PARAM2)
       }
   }

   override fun onCreateView(
       inflater: LayoutInflater, container: ViewGroup?,
       savedInstanceState: Bundle?
   ): View? {
       // Inflate the layout for this fragment
       return inflater.inflate(R.layout.fragment_blank2, container, false)
   }

   val args: BlankFragment2Args by navArgs()

   override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
       super.onViewCreated(view, savedInstanceState)

       val textView = view.findViewById<TextView>(R.id.textView)
       textView.text = args.sampleArg
   }

   companion object {
       /**
        * Use this factory method to create a new instance of
        * this fragment using the provided parameters.
        *
        * @param param1 Parameter 1.
        * @param param2 Parameter 2.
        * @return A new instance of fragment BlankFragment2.
        */
       // TODO: Rename and change types and number of parameters
       @JvmStatic
       fun newInstance(param1: String, param2: String) =
           BlankFragment2().apply {
               arguments = Bundle().apply {
                   putString(ARG_PARAM1, param1)
                   putString(ARG_PARAM2, param2)
               }
           }
   }
}

activity_main.xml

<?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"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">

   <androidx.fragment.app.FragmentContainerView
       android:id="@+id/fragmentContainerView"
       android:name="androidx.navigation.fragment.NavHostFragment"
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       app:defaultNavHost="true"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent"
       app:navGraph="@navigation/nav_graph" />
</androidx.constraintlayout.widget.ConstraintLayout>

fragment_blank1.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:id="@+id/frameLayout"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".BlankFragment1" >

   <Button
       android:id="@+id/button"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:text="@string/second_screen"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

fragment_blank2.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:id="@+id/frameLayout2"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".BlankFragment2">

   <TextView
       android:id="@+id/textView"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:textSize="32sp"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent"
       tools:text="Test Test Test" />

</androidx.constraintlayout.widget.ConstraintLayout>

nav_graph.xml

<?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"
   android:id="@+id/nav_graph"
   app:startDestination="@id/blankFragment1">

   <fragment
       android:id="@+id/blankFragment1"
       android:name="com.codelab.daniwebandroidtypesafenav.BlankFragment1"
       android:label="fragment_blank1"
       tools:layout="@layout/fragment_blank1" >
       <action
           android:id="@+id/action_blankFragment1_to_blankFragment2"
           app:destination="@id/blankFragment2" />
   </fragment>
   <fragment
       android:id="@+id/blankFragment2"
       android:name="com.codelab.daniwebandroidtypesafenav.BlankFragment2"
       android:label="fragment_blank2"
       tools:layout="@layout/fragment_blank2" >
       <argument
           android:name="sampleArg"
           app:argType="string"
           android:defaultValue="blank" />
   </fragment>
</navigation>

strings.xml

<resources>
   <string name="app_name">Daniweb Android Typesafe Nav</string>
   <!-- TODO: Remove or change this placeholder text -->
   <string name="hello_blank_fragment">Hello blank fragment</string>
   <string name="second_screen">2nd Screen</string>
</resources>

Project build.gradle

// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
   repositories {
       google()
   }
   dependencies {
       def nav_version = "2.4.0"
       classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
   }
}

plugins {
   id 'com.android.application' version '7.0.4' apply false
   id 'com.android.library' version '7.0.4' apply false
   id 'org.jetbrains.kotlin.android' version '1.6.10' apply false
}

task clean(type: Delete) {
   delete rootProject.buildDir
}

Module build.gradle

plugins {
   id 'com.android.application'
   id 'org.jetbrains.kotlin.android'
   id 'androidx.navigation.safeargs.kotlin'
}

android {
   compileSdk 31

   defaultConfig {
       applicationId "com.codelab.daniwebandroidtypesafenav"
       minSdk 21
       targetSdk 31
       versionCode 1
       versionName "1.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 {
   implementation 'androidx.legacy:legacy-support-v4:1.0.0'
   def nav_version = "2.4.0"

   implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
   implementation "androidx.navigation:navigation-ui-ktx:$nav_version"

   implementation 'androidx.core:core-ktx:1.7.0'
   implementation 'androidx.appcompat:appcompat:1.4.1'
   implementation 'com.google.android.material:material:1.5.0'
   implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
   testImplementation 'junit:junit:4.13.2'
   androidTestImplementation 'androidx.test.ext:junit:1.1.3'
   androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}
Summary

We have learned how to navigate with type safety and pass destination arguments in this tutorial. The full project code can be found at https://github.com/dmitrilc/DaniwebAndroidTypesafeNav.

Android Native – How to use Composables in the View system

Introduction

If you are working on a native Android app using the View system, then you might have come across a situation where you would need to add a Composable (androidx.compose.runtime.Composable) into your View hierarchy. In this tutorial, we will learn how to add a Composable into an existing View system.

The Composables that we will use in this tutorial will come from the Material 3 library.

Goals

At the end of the tutorial, you would have learned:

  1. How to add a Composable into a View system.
Tools Required
  1. Android Studio. The version used in this tutorial is Bumblebee 2021.1.1.
Prerequisite Knowledge
  1. Intermediate Android.
  2. Basic Jetpack Compose.
Project Setup

To follow along with the tutorial, perform the steps below:

  1. Create a new Android project with the default Empty Activity.

  2. Remove the default Hello World! TextView from activity_main.xml.

  3. Inside of your module build.gradle, upgrade your modules Material dependency to version 1.5.0.

     implementation 'com.google.android.material:material:1.5.0'
  4. Add the variable below to hold the compose version.

     def composeVersion = '1.0.5'
  5. Add these options to your android build options (the android{} block)

     buildFeatures {
         compose true
     }
    
     composeOptions {
         kotlinCompilerExtensionVersion = composeVersion
     }
  6. Add the Compose dependencies below into your dependencies{} block.

     //Compose
     implementation "androidx.compose.runtime:runtime:$composeVersion"
     implementation "androidx.compose.ui:ui:$composeVersion"
     implementation "androidx.compose.ui:ui-tooling:$composeVersion"
     implementation "androidx.compose.foundation:foundation:$composeVersion"
     implementation "androidx.compose.foundation:foundation-layout:$composeVersion"
  7. Add Compose support for Material 3.

     //Material 3 Compose Support
     implementation 'androidx.compose.material3:material3:1.0.0-alpha04'
  8. For simplicity, in the project build.gradle file, downgrade your Android Kotlin plugin version to 1.5.31.

     id 'org.jetbrains.kotlin.android' version '1.5.31' apply false
ComposeView

To bridge the gap between the Compose world and the View world, Android provides a couple of Interop APIs. ComposeView (androidx.compose.ui.platform.ComposeView) is one of those APIs that we can use in scenarios where we need to insert a Composable into an existing View hierarchy.

ComposeView is actually a View itself, so we will be able to add it via XML. To add it to activity_main.xml, copy and paste the code below inside of ConstraintLayout.

    <androidx.compose.ui.platform.ComposeView
       android:id="@+id/compose_view"
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent" />
Insert Composable into View hierarchy

For this tutorial, we can just add a simple Composable that will print the String "Hello" 100 times.

  1. In MainActivity.kt, add the top-level Composable below.

     @Composable
     private fun Hellos(hellos: Array<String> = Array(100) {"Hello $it"} ) {
        LazyColumn {
            items(items = hellos) { hello ->
                Text(text = hello)
            }
        }
     }
  2. Next, we will obtain the reference to the <ComposeView>. Inside of onCreate(), after the setContent() call, add the line of code below.

     val composeView = findViewById<ComposeView>(R.id.compose_view)
  3. To make use of composeView, we have two options: call setContent() directly from the composeView variable or use the Kotlin scope function apply(). We will be using apply() in this case because it has the advantage of calling multiple functions on composeView rather than just a singular setContent(). Append the code snippet below into onCreate().

     composeView.apply {
        setContent {
            Hellos()
        }
     }
ViewCompositionStrategy

We are almost done, but there is another important characteristic of ComposeView that needs discussion.

ComposeView also subclasses AbstractComposeView(androidx.compose.ui.platform.AbstractComposeView). AbstractComposeView includes an interesting function that is setViewCompositionStrategy(). Because we are mixing two different UI systems together, we will need to be aware of the Activity (or Fragment), View, and Composable lifecycles. With setViewCompositionStrategy(), we can pass in a ViewCompositionStrategy object to configure how the composition should be destroyed. There are three premade ViewCompositionStrategy available for us to use.

  1. ViewCompositionStrategy.DisposeOnDetachedFromWindow: disposes the composition whenever the view becomes detached from a window. This is the default behavior. This is a singleton object.
  2. ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed: disposes the composition when the ViewTreeLifecycleOwner of the next window the view is attached to is destroyed. This is also a singleton object.
  3. ViewCompositionStrategy.DisposeOnLifecycleDestroyed: disposes the composition when the lifecycle is destroyed. Similar to DisposeOnViewTreeLifecycleDestroyed, but this is a class with constructors that allows you to specify a specific lifecycle.

To set the ViewCompositionStrategy, you can add it to the apply() like below.

composeView.apply {
   //Default option
   //setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)

   //Explicit lifeCycle
   //setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnLifecycleDestroyed(lifecycle))

   setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
   setContent {
       Hellos()
   }
}
Run the App

It is now time to run our App to see if it works correctly.

Compose_in_View.gif

We can see that it displays the Composable LazyList inside of our ConstraintLayout View successfully.

Solution Code

MainActivity.kt

package com.codelab.daniwebcomposeinviews

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy

class MainActivity : AppCompatActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)

       val composeView = findViewById<ComposeView>(R.id.compose_view)

/*        composeView.setContent {
           Hellos()
       }*/

       composeView.apply {
           //Default option
           //setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)

           //Explicit lifeCycle
           //setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnLifecycleDestroyed(lifecycle))

           setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
           setContent {
               Hellos()
           }
       }
   }
}

@Composable
private fun Hellos(hellos: Array<String> = Array(100) {"Hello $it"} ) {
   LazyColumn {
       items(items = hellos) { hello ->
           Text(text = hello)
       }
   }
}

activity_main.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="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">

   <androidx.compose.ui.platform.ComposeView
       android:id="@+id/compose_view"
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

Project build.gradle

// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
   id 'com.android.application' version '7.1.0' apply false
   id 'com.android.library' version '7.1.0' apply false
   id 'org.jetbrains.kotlin.android' version '1.5.31' apply false
}

task clean(type: Delete) {
   delete rootProject.buildDir
}

Module build.gradle

plugins {
   id 'com.android.application'
   id 'org.jetbrains.kotlin.android'
}

def composeVersion = '1.0.5'

android {
   compileSdk 31

   defaultConfig {
       applicationId "com.codelab.daniwebcomposeinviews"
       minSdk 21
       targetSdk 31
       versionCode 1
       versionName "1.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'
   }

   buildFeatures {
       compose true
   }

   composeOptions {
       kotlinCompilerExtensionVersion = composeVersion
   }
}

dependencies {
   //Compose
   implementation "androidx.compose.runtime:runtime:$composeVersion"
   implementation "androidx.compose.ui:ui:$composeVersion"
   implementation "androidx.compose.ui:ui-tooling:$composeVersion"
   implementation "androidx.compose.foundation:foundation:$composeVersion"
   implementation "androidx.compose.foundation:foundation-layout:$composeVersion"

   //Material 3 Compose Support
   implementation 'androidx.compose.material3:material3:1.0.0-alpha04'

   implementation 'com.google.android.material:material:1.5.0'
   implementation 'androidx.core:core-ktx:1.7.0'
   implementation 'androidx.appcompat:appcompat:1.4.1'
   implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
   testImplementation 'junit:junit:4.13.2'
   androidTestImplementation 'androidx.test.ext:junit:1.1.3'
   androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}
Summary

We have learned how to add Composables into a View hierarchy using ComposeView. The full project code can be found at https://github.com/dmitrilc/DaniwebComposeInViews.

NgModel shows only one value

Screenshot_2022-01-26_at_11_35_23_AM.png

Hello i want to retrieve multiples values from single column on db. And i did. They are shown on Console (Painting, Illustration, Graphic Design) but when i put the values on ion-toggles with ngModel only one is set to true. As you may see on the screenshot the values are returning but only Painting is true (toggle on right side)

Html

 <ion-list >
<ion-list-header>
I am Interested in...
</ion-list-header><div><br /></div>
   <ion-item>
  <ion-label>Painting</ion-label>
        <ion-toggle color="gold" [ngModel]="interest=='Painting' ? true:false" (ionChange)="creativeInterest(creative, 'Painting')"></ion-toggle>
</ion-item>

<ion-item>
  <ion-label>Graphic Design</ion-label>
  <ion-toggle color="tertiary" [ngModel]="interest=='Graphic Design' ? true:false" (ionChange)="creativeInterest(creative, 'Graphic Design')"></ion-toggle>
</ion-item>

<ion-item>
  <ion-label>Illustration</ion-label>
  <ion-toggle color="danger" [ngModel]="interest=='Illustration' ? true:false" (ionChange)="creativeInterest(creative, 'Illustration')"></ion-toggle>
</ion-item>

<ion-item>
  <ion-label>Sculpture</ion-label>
  <ion-toggle color="success" [ngModel]="interest=='Sculpture' ? true:false" (ionChange)="creativeInterest(creative, 'Sculpture')"></ion-toggle>
</ion-item>

<ion-item>
  <ion-label>Literature</ion-label>
  <ion-toggle color="danger" [ngModel]="interest=='Literature' ? true:false" (ionChange)="creativeInterest(creative, 'Literature')"></ion-toggle>
</ion-item>

<ion-item>
  <ion-label>Theater</ion-label>
  <ion-toggle color="dark" [ngModel]="interest=='Theater' ? true:false" (ionChange)="creativeInterest(creative, 'Theater')"></ion-toggle>
</ion-item>

<ion-item>
  <ion-label>Film</ion-label>
  <ion-toggle color="warning" [ngModel]="interest=='Film' ? true:false" (ionChange)="creativeInterest(creative, 'Film')"></ion-toggle>
</ion-item>

.ts

export class CreativeSettingsPage implements OnInit {
  creativeInterests: any=[];
 creative: any= {};
 userDetails: any = {};
 interest:any=[];
 constructor(
public userData: UserData
 ) {
this.userDetails = this.userData.getUserData();
 }

ngOnInit() {
this.creativeInsterestSet();
  }

creativeInsterestSet() {
this.userData.creativeInterests(this.userDetails.uid).pipe(
    map((data: any) => {
        if (data.success) {
            this.creativeInterests = data.creativeInterests;
            this.creativeInterests.filter(item =>  {
              this.interest=item.interest;
             console.log( this.interest);
          });

        }
    })
).subscribe()
}

Android Native – How to use the Material 3 Bottom Navigation Bar

Introduction

If you are working with the latest Material 3 libraries on Android, you might have wondered how to use the Bottom Navigation Bar in your App. In this tutorial, we will learn how to use the Material 3 Bottom Navigation Bar (BNB).

Goals

At the end of the tutorial, you would have learned:

  1. How to use the Material 3 Bottom Navigation Bar.
Tools Required
  1. Android Studio. The version used in this tutorial is Arctic Fox 2020.3.1 Patch 4.
Prerequisite Knowledge
  1. Intermediate Android.
Project Setup

To follow along with the tutorial, perform the steps below:

  1. Create a new Android project with the default Empty Activity.

  2. Add the Material 3 dependency below into your projects build.gradle file.

     implementation 'com.google.android.material:material:1.5.0'
  3. Sync Gradle.

  4. Change the current theme(themes.xml) parent to Theme.Material3.DayNight.NoActionBar.

Add the Bottom Navigation Bar

First, let us add the BNB into our project. I normally use BNB with a LinearLayout-based parent, but that is not a hard requirement. We can just add a BNB to the ConstraintLayout that we already have in activity_main.xml just fine.

  1. Open activity_main.xml in Code view, add the code below under the default <TextView>. The code below already includes constraints for the parent ConstraintLayout, so you do not have to worry about them.

     <com.google.android.material.bottomnavigation.BottomNavigationView
        android:id="@+id/bottom_navigation"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:menu="@menu/bottom_navigation_menu" />
  2. Upon seeing the compile error Cannot resolve symbol '@menu/bottom_navigation_menu', go ahead and create the missing file with the same configurations as the screenshot below.

1.jpg

Bottom Navigation Bar items

Because the newly created menu resource is empty, the BNB is just an empty bar. Let us assume that we are building an App for a taxi company, so we will add five shortcuts for the most frequently used features into the BNB. The five shortcuts are:

  1. Call Cab: for calling a cab.
  2. Map: for tracking where your Cab is.
  3. SOS: a convenient button for emergencies.
  4. Schedule: schedule a taxi for a future trip.
  5. Payment: payment-related functionalities.

To add the five shortcuts to our BNB, follow the steps below:

  1. Add these 5 vector assets from the built-in Clip Art library into res/drawable.

     ic_baseline_local_taxi_24
     ic_baseline_map_24
     ic_baseline_emergency_24
     ic_baseline_edit_calendar_24
     ic_baseline_payment_24
  2. Add these 5 string resources into strings.xml.

     <string name="text_label_1">Call Cab</string>
     <string name="text_label_2">Map</string>
     <string name="text_label_3">SOS</string>
     <string name="text_label_4">Schedule</string>
     <string name="text_label_5">Payment</string>
  3. Open the file bottom_navigation_menu.xml. Add these <item> elements as children of the root <menu>.

     <?xml version="1.0" encoding="utf-8"?>
     <menu xmlns:android="http://schemas.android.com/apk/res/android">
        <item
            android:id="@+id/page_1"
            android:enabled="true"
            android:icon="@drawable/ic_baseline_local_taxi_24"
            android:title="@string/text_label_1"/>
        <item
            android:id="@+id/page_2"
            android:enabled="true"
            android:icon="@drawable/ic_baseline_map_24"
            android:title="@string/text_label_2"/>
        <item
            android:id="@+id/page_3"
            android:enabled="true"
            android:icon="@drawable/ic_baseline_emergency_24"
            android:title="@string/text_label_3"/>
        <item
            android:id="@+id/page_4"
            android:enabled="true"
            android:icon="@drawable/ic_baseline_edit_calendar_24"
            android:title="@string/text_label_4"/>
        <item
            android:id="@+id/page_5"
            android:enabled="true"
            android:icon="@drawable/ic_baseline_payment_24"
            android:title="@string/text_label_5"/>
     </menu>

If you run the app now, you can see something similar to the animation below.

BNB_Stock.gif

Menu Item actions

Currently, the BNB buttons are technically only dummy placeholders without any useful behavior. To bind user navigation actions to callbacks, you can use the function NavigationBarView#setOnItemSelectedLisenter(). Append the code below into MainActivity.kts onCreate().

val textView = findViewById<TextView>(R.id.textView)
val bottomNav = findViewById<BottomNavigationView>(R.id.bottom_navigation)

bottomNav.setOnItemSelectedListener { item ->
   when(item.itemId){
       R.id.page_1 -> {
           textView.text = getString(R.string.text_label_1)
           true
       }
       R.id.page_2 -> {
           textView.text = getString(R.string.text_label_2)
           true
       }
       R.id.page_3 -> {
           textView.text = getString(R.string.text_label_3)
           true
       }
       R.id.page_4 -> {
           textView.text = getString(R.string.text_label_4)
           true
       }
       R.id.page_5 -> {
           textView.text = getString(R.string.text_label_5)
           true
       }
       else -> false
   }
}

Now the default TextView will change every time a navigation action occurs. Run the app again and it will behave similarly to the animation below.

BNB_OnClick.gif

Solution Code

activity_main.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="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">

   <TextView
       android:id="@+id/textView"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:text="Hello World!"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintLeft_toLeftOf="parent"
       app:layout_constraintRight_toRightOf="parent"
       app:layout_constraintTop_toTopOf="parent" />

   <com.google.android.material.bottomnavigation.BottomNavigationView
       android:id="@+id/bottom_navigation"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:menu="@menu/bottom_navigation_menu" />

</androidx.constraintlayout.widget.ConstraintLayout>

MainActivity.kt

package com.example.daniwebmaterial3bottomnavigationbar

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.TextView
import com.google.android.material.bottomnavigation.BottomNavigationView

class MainActivity : AppCompatActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)

       val textView = findViewById<TextView>(R.id.textView)
       val bottomNav = findViewById<BottomNavigationView>(R.id.bottom_navigation)

       bottomNav.setOnItemSelectedListener { item ->
           when(item.itemId){
               R.id.page_1 -> {
                   textView.text = getString(R.string.text_label_1)
                   true
               }
               R.id.page_2 -> {
                   textView.text = getString(R.string.text_label_2)
                   true
               }
               R.id.page_3 -> {
                   textView.text = getString(R.string.text_label_3)
                   true
               }
               R.id.page_4 -> {
                   textView.text = getString(R.string.text_label_4)
                   true
               }
               R.id.page_5 -> {
                   textView.text = getString(R.string.text_label_5)
                   true
               }
               else -> false
           }
       }
   }
}

bottom_navigation_menu.xml

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
   <item
       android:id="@+id/page_1"
       android:enabled="true"
       android:icon="@drawable/ic_baseline_local_taxi_24"
       android:title="@string/text_label_1"/>
   <item
       android:id="@+id/page_2"
       android:enabled="true"
       android:icon="@drawable/ic_baseline_map_24"
       android:title="@string/text_label_2"/>
   <item
       android:id="@+id/page_3"
       android:enabled="true"
       android:icon="@drawable/ic_baseline_emergency_24"
       android:title="@string/text_label_3"/>
   <item
       android:id="@+id/page_4"
       android:enabled="true"
       android:icon="@drawable/ic_baseline_edit_calendar_24"
       android:title="@string/text_label_4"/>
   <item
       android:id="@+id/page_5"
       android:enabled="true"
       android:icon="@drawable/ic_baseline_payment_24"
       android:title="@string/text_label_5"/>
</menu>

strings.xml

<resources>
   <string name="app_name">Daniweb Material 3 Bottom Navigation Bar</string>
   <string name="text_label_1">Call Cab</string>
   <string name="text_label_2">Map</string>
   <string name="text_label_3">SOS</string>
   <string name="text_label_4">Schedule</string>
   <string name="text_label_5">Payment</string>
</resources>

themes.xml

<resources xmlns:tools="http://schemas.android.com/tools">
   <!-- Base application theme. -->
   <style name="Theme.DaniwebMaterial3BottomNavigationBar" parent="Theme.Material3.DayNight.NoActionBar">
       <!-- Primary brand color. -->
       <item name="colorPrimary">@color/purple_500</item>
       <item name="colorPrimaryVariant">@color/purple_700</item>
       <item name="colorOnPrimary">@color/white</item>
       <!-- Secondary brand color. -->
       <item name="colorSecondary">@color/teal_200</item>
       <item name="colorSecondaryVariant">@color/teal_700</item>
       <item name="colorOnSecondary">@color/black</item>
       <!-- Status bar color. -->
       <item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
       <!-- Customize your theme here. -->
   </style>
</resources>

build.gradle

plugins {
   id 'com.android.application'
   id 'kotlin-android'
}

android {
   compileSdk 31

   defaultConfig {
       applicationId "com.example.daniwebmaterial3bottomnavigationbar"
       minSdk 21
       targetSdk 31
       versionCode 1
       versionName "1.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 {
   implementation 'com.google.android.material:material:1.5.0'
   implementation 'androidx.core:core-ktx:1.7.0'
   implementation 'androidx.appcompat:appcompat:1.4.1'
   implementation 'com.google.android.material:material:1.5.0'
   implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
   testImplementation 'junit:junit:4.+'
   androidTestImplementation 'androidx.test.ext:junit:1.1.3'
   androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}
Summary

We have learned how to use the Bottom Navigation Bar. The full project code can be found at https://github.com/dmitrilc/DaniwebMaterial3BottomNavigationBar

Android Native – persist state with Proto DataStore

Introduction

Proto DataStore is a great way to store the permanent state of your application, especially if you prefer type safety. In this tutorial, we will learn how to store our App state using the Proto DataStore.

Goals

At the end of the tutorial, you would have learned:

  1. How to store App state using Proto DataStore.
Tools Required
  1. Android Studio. The version used in this tutorial is Arctic Fox 2020.3.1 Patch 4.
Prerequisite Knowledge
  1. Intermediate Android.
  2. protobuf3. If you are not familiar with protobuf, you can check out the syntax here.
  3. Lifecycle-aware coroutine scopes.
  4. Java Serialization.
Project Setup

To follow along with the tutorial, perform the steps below:

  1. Create a new Android project with the default Empty Activity.

  2. Remove the default Hello World! TextView.

  3. Add the Gradle dependency below into your project build.gradle.

     implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.4.0"
     implementation "androidx.datastore:datastore:1.0.0"
     implementation com.google.protobuf:protobuf-javalite:3.19.3
  4. Add the Google protobuf plugin to your project build.gradle.

     id "com.google.protobuf" version "0.8.18"
  5. Append the configuration below to the end of your project build.gradle.

     protobuf {
         protoc {
             artifact = 'com.google.protobuf:protoc:3.8.0'
         }
         generateProtoTasks {
             all().each { task ->
                 task.builtins {
                     java {
                         option "lite"
                     }
                 }
             }
         }
     }
  6. If you are not sure what these steps do, the instructions for setting up protobuf in your Android project can also be found in the official docs here.

  7. Add the code below inside of <ConstraintLayout>. This will add four checkboxes.

     <CheckBox
        android:id="@+id/checkBox1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/checkbox1"
        app:layout_constraintBottom_toTopOf="@+id/checkBox2"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
    
     <CheckBox
        android:id="@+id/checkBox2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/checkbox2"
        app:layout_constraintBottom_toTopOf="@+id/checkBox3"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/checkBox1" />
    
     <CheckBox
        android:id="@+id/checkBox3"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/checkbox3"
        app:layout_constraintBottom_toTopOf="@+id/checkBox4"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/checkBox2" />
    
     <CheckBox
        android:id="@+id/checkBox4"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/checkbox4"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/checkBox3" />
  8. Add the string resources below into strings.xml.

     <string name="checkbox1">CheckBox 1</string>
     <string name="checkbox2">CheckBox 2</string>
     <string name="checkbox3">CheckBox 3</string>
     <string name="checkbox4">CheckBox 4</string>
Project Overview

Our sample app for this tutorial is quite simple. It has one activity and four checkboxes.

1.png

Currently, the app is unable to remember which checkbox is checked after the app is killed. For the app to be able to save its state after it has been killed, we can employ the help of the Proto DataStore.

The Proto DataStore is not the only way to persist app states, but is merely one of the many methods to achieve this goal.

Protobuf schema

Similar to how you would define JPA Entities, Proto DataStore requires a schema file declared using the protobuf language. We are using latest version of protobuf (protobuf3) for this tutorial.

Follow the steps below to create a schema for our App.

  1. Under the Project panel of Android Studio, switches from Android to Project view.

  2. Navigate to app/src/main.

  3. Right-click on main > New > Directory, name the new directory proto.

  4. Right-click on the newly created directory proto > New > File, name the new file CheckBoxStates.proto. You might need to install the official Protocol Buffers (from Jetbrains) plugin for Android Studio here for the protobuf3 syntax highlighting to show.

  5. Paste the code below into CheckboxStates.proto.

     syntax = "proto3";
    
     option java_package = "com.example.daniwebprotobufdatastore";
     option java_multiple_files = true;
    
     message CheckboxStates {
      bool check_box_1 = 1;
      bool check_box_2 = 2;
      bool check_box_3 = 3;
      bool check_box_4 = 4;
     }
  6. Rebuild your project.

Creating a Serializer

Now that we have our schema, the next step is to create a class that can serialize the protobuf data into POJOs and vice-versa.

  1. Create a new class called CheckboxStatesSerializer.kt in the same package as MainActivity.kt.

  2. Copy and paste the code below into it. This is the class that serialize/de-serialize your CheckboxStates POJO as well as providing a default instance of CheckboxStates.

     package com.example.daniwebprotobufdatastore
    
     import android.content.Context
     import androidx.datastore.core.CorruptionException
     import androidx.datastore.core.DataStore
     import androidx.datastore.core.Serializer
     import androidx.datastore.dataStore
     import com.google.protobuf.InvalidProtocolBufferException
     import java.io.InputStream
     import java.io.OutputStream
    
     object CheckboxStatesSerializer: Serializer<CheckboxStates> {
        override val defaultValue: CheckboxStates
            get() = CheckboxStates.getDefaultInstance()
    
        override suspend fun readFrom(input: InputStream): CheckboxStates {
            try {
                return CheckboxStates.parseFrom(input)
            } catch (exception: InvalidProtocolBufferException) {
                throw CorruptionException("Cannot read proto.", exception)
            }
        }
    
        override suspend fun writeTo(t: CheckboxStates, output: OutputStream) {
            t.writeTo(output)
        }
     }
    
     val Context.checkboxStatesDataStore: DataStore<CheckboxStates> by dataStore(
        fileName = "checkbox_states.pb",
        serializer = CheckboxStatesSerializer
     )

The checkboxStatesDataStore extension property with the Context receiver exposes an instance of DataStore for us to use.

Saving State

Using the DataStore extension property of Context, we can read and write to the DataStore. Follow the steps below to save checkbox states.

  1. In MainActivity.kt, add the saveCheckboxStates() function below.

     private suspend fun saveCheckboxStates() {
        val checkbox1 = findViewById<CheckBox>(R.id.checkBox1)
        val checkbox2 = findViewById<CheckBox>(R.id.checkBox2)
        val checkbox3 = findViewById<CheckBox>(R.id.checkBox3)
        val checkbox4 = findViewById<CheckBox>(R.id.checkBox4)
    
        applicationContext.checkboxStatesDataStore.updateData { states ->
            states.toBuilder()
                .setCheckBox1(checkbox1.isChecked)
                .setCheckBox2(checkbox2.isChecked)
                .setCheckBox3(checkbox3.isChecked)
                .setCheckBox4(checkbox4.isChecked)
                .build()
        }
     }
  2. Since IO writing is expensive, we will save the state only at the onStop() lifecycle callback for this tutorial. Override onStop(). To keep things simple, I am doing everything from the Activity here, but this job is more fit a ViewModel.

     override fun onStop() {
        super.onStop()
        lifecycleScope.launch {
            saveCheckboxStates()
        }
     }
Restoring State

Next, we will add code to restore the state when the app is started again after being killed.

  1. Add the restoreCheckboxStates() function below to MainActivity.

     private suspend fun restoreCheckBoxStates() {
        val checkbox1 = findViewById<CheckBox>(R.id.checkBox1)
        val checkbox2 = findViewById<CheckBox>(R.id.checkBox2)
        val checkbox3 = findViewById<CheckBox>(R.id.checkBox3)
        val checkbox4 = findViewById<CheckBox>(R.id.checkBox4)
    
        applicationContext.checkboxStatesDataStore.data.collect { states ->
            checkbox1.isChecked = states.checkBox1
            checkbox2.isChecked = states.checkBox2
            checkbox3.isChecked = states.checkBox3
            checkbox4.isChecked = states.checkBox4
        }
     }
  2. Append the code below to onCreate() to restore the state. Again, you should properly do this in a ViewModel instead.

     lifecycleScope.launch {
        restoreCheckBoxStates()
     }
Run the App

We are now ready to run the App. Your app should properly save the state when performing a workflow similar to the animation below.

protobuf_final.gif

Solution Code

CheckboxStatesSerializer.kt

package com.example.daniwebprotobufdatastore

import android.content.Context
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.DataStore
import androidx.datastore.core.Serializer
import androidx.datastore.dataStore
import com.google.protobuf.InvalidProtocolBufferException
import java.io.InputStream
import java.io.OutputStream

object CheckboxStatesSerializer: Serializer<CheckboxStates> {
   override val defaultValue: CheckboxStates
       get() = CheckboxStates.getDefaultInstance()

   override suspend fun readFrom(input: InputStream): CheckboxStates {
       try {
           return CheckboxStates.parseFrom(input)
       } catch (exception: InvalidProtocolBufferException) {
           throw CorruptionException("Cannot read proto.", exception)
       }
   }

   override suspend fun writeTo(t: CheckboxStates, output: OutputStream) {
       t.writeTo(output)
   }
}

val Context.checkboxStatesDataStore: DataStore<CheckboxStates> by dataStore(
   fileName = "checkbox_states.pb",
   serializer = CheckboxStatesSerializer
)

MainActivity.kt

package com.example.daniwebprotobufdatastore

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.CheckBox
import androidx.lifecycle.coroutineScope
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

class MainActivity : AppCompatActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)

       lifecycleScope.launch {
           restoreCheckBoxStates()
       }
   }

   private suspend fun restoreCheckBoxStates() {
       val checkbox1 = findViewById<CheckBox>(R.id.checkBox1)
       val checkbox2 = findViewById<CheckBox>(R.id.checkBox2)
       val checkbox3 = findViewById<CheckBox>(R.id.checkBox3)
       val checkbox4 = findViewById<CheckBox>(R.id.checkBox4)

       applicationContext.checkboxStatesDataStore.data.collect { states ->
           checkbox1.isChecked = states.checkBox1
           checkbox2.isChecked = states.checkBox2
           checkbox3.isChecked = states.checkBox3
           checkbox4.isChecked = states.checkBox4
       }
   }

   private suspend fun saveCheckboxStates() {
       val checkbox1 = findViewById<CheckBox>(R.id.checkBox1)
       val checkbox2 = findViewById<CheckBox>(R.id.checkBox2)
       val checkbox3 = findViewById<CheckBox>(R.id.checkBox3)
       val checkbox4 = findViewById<CheckBox>(R.id.checkBox4)

       applicationContext.checkboxStatesDataStore.updateData { states ->
           states.toBuilder()
               .setCheckBox1(checkbox1.isChecked)
               .setCheckBox2(checkbox2.isChecked)
               .setCheckBox3(checkbox3.isChecked)
               .setCheckBox4(checkbox4.isChecked)
               .build()
       }
   }

   override fun onStop() {
       super.onStop()
       lifecycleScope.launch {
           saveCheckboxStates()
       }
   }

}

strings.xml

<resources>
   <string name="app_name">Daniweb protobuf DataStore</string>
   <string name="checkbox1">CheckBox 1</string>
   <string name="checkbox2">CheckBox 2</string>
   <string name="checkbox3">CheckBox 3</string>
   <string name="checkbox4">CheckBox 4</string>
</resources>

build.gradle

plugins {
   id 'com.android.application'
   id 'kotlin-android'
   id "com.google.protobuf" version "0.8.18"
}

android {
   compileSdk 31

   defaultConfig {
       applicationId "com.example.daniwebprotobufdatastore"
       minSdk 21
       targetSdk 31
       versionCode 1
       versionName "1.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 {
   implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.4.0"
   implementation 'com.google.protobuf:protobuf-javalite:3.19.3'
   implementation "androidx.datastore:datastore:1.0.0"
   implementation 'androidx.core:core-ktx:1.7.0'
   implementation 'androidx.appcompat:appcompat:1.4.1'
   implementation 'com.google.android.material:material:1.5.0'
   implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
   testImplementation 'junit:junit:4.+'
   androidTestImplementation 'androidx.test.ext:junit:1.1.3'
   androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}


protobuf {
   protoc {
       artifact = 'com.google.protobuf:protoc:3.8.0'
   }
   generateProtoTasks {
       all().each { task ->
           task.builtins {
               java {
                   option "lite"
               }
           }
       }
   }
}

activity_main.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="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">

   <CheckBox
       android:id="@+id/checkBox1"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:text="@string/checkbox1"
       app:layout_constraintBottom_toTopOf="@+id/checkBox2"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent" />

   <CheckBox
       android:id="@+id/checkBox2"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:text="@string/checkbox2"
       app:layout_constraintBottom_toTopOf="@+id/checkBox3"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/checkBox1" />

   <CheckBox
       android:id="@+id/checkBox3"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:text="@string/checkbox3"
       app:layout_constraintBottom_toTopOf="@+id/checkBox4"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/checkBox2" />

   <CheckBox
       android:id="@+id/checkBox4"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:text="@string/checkbox4"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/checkBox3" />
</androidx.constraintlayout.widget.ConstraintLayout>
Summary

We have learned how to persist state using Proto DataStore. The full project code can be found at https://github.com/dmitrilc/DaniwebProtobufDataStore