Android Native – Animate Sprites Using AnimationDrawable

Introduction

Android includes many options to add animations to your app. In this tutorial, we will learn how to add a type of animation called frame-by-frame animation into our Android app.

Goals

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

  1. How to add frame-by-frame animation to an Android app.
Tools Required
  1. Android Studio. The version used in this tutorial is Android Studio Dolphin | 2021.3.1.
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. Download the free Santa sprites from Game Art 2d.

  3. Unpack the archive and import all files in the png directory to the project (keep the same names that Android Studio generated for you).

  4. Copy and paste all the <string> resources below into your projects strings.xml file.

     <string name="idle">Idle</string>
     <string name="walk">Walk</string>
     <string name="run">Run</string>
     <string name="jump">Jump</string>
     <string name="die">Die</string>
     <string name="slide">Slide</string>
  5. Replace all 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">
    
        <ImageView
            android:id="@+id/image_santa"
            android:layout_width="0dp"
            android:layout_height="300dp"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            android:src="@drawable/idle__1_" />
    
        <Button
            android:id="@+id/idle"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:text="@string/idle"
            app:layout_constraintBottom_toTopOf="@id/walk"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/image_santa" />
    
        <Button
            android:id="@+id/walk"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/walk"
            app:layout_constraintBottom_toTopOf="@id/run"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/idle" />
    
        <Button
            android:id="@+id/run"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/run"
            app:layout_constraintBottom_toTopOf="@id/jump"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/walk" />
    
        <Button
            android:id="@+id/jump"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/jump"
            app:layout_constraintBottom_toTopOf="@id/slide"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/run" />
    
        <Button
            android:id="@+id/slide"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/slide"
            app:layout_constraintBottom_toTopOf="@id/die"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/jump" />
    
        <Button
            android:id="@+id/die"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/die"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.5"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/slide" />
     </androidx.constraintlayout.widget.ConstraintLayout>
Project Overview

Our starter application contains a single screen with 6 buttons, allowing the user to start animations of the following actions

  1. Idle.
  2. Walk.
  3. Run.
  4. Jump.
  5. Slide.
  6. Die.

Screenshot_1665435838.png

There are two steps that we need to do next to allow the animation to work:

  1. Set up the animation resources that describe the frame-by-frame image.
  2. Set up button listeners to activate the animation.
Set Up Animation Resource XML

The type of animation that we are aiming for can be achieved by loading images in a sequence. We can either define this animation in code using the AnimationDrawable class or using a pre-defined Frame Animation Resource. The frame animation resource starts with an <animation-list> tag, and then each image is defined in an <item> tag.

To create the frame animation resource for the idle action, create a new file called idle.xml and copy and paste the code below into it.

<?xml version="1.0" encoding="utf-8"?>
<animation-list xmlns:android="http://schemas.android.com/apk/res/android">
   <item android:drawable="@drawable/idle__1_" android:duration="75" />
   <item android:drawable="@drawable/idle__2_" android:duration="75" />
   <item android:drawable="@drawable/idle__3_" android:duration="75" />
   <item android:drawable="@drawable/idle__4_" android:duration="75" />
   <item android:drawable="@drawable/idle__5_" android:duration="75" />
   <item android:drawable="@drawable/idle__6_" android:duration="75" />
   <item android:drawable="@drawable/idle__7_" android:duration="75" />
   <item android:drawable="@drawable/idle__8_" android:duration="75" />
   <item android:drawable="@drawable/idle__9_" android:duration="75" />
   <item android:drawable="@drawable/idle__10_" android:duration="75" />
   <item android:drawable="@drawable/idle__11_" android:duration="75" />
   <item android:drawable="@drawable/idle__12_" android:duration="75" />
   <item android:drawable="@drawable/idle__13_" android:duration="75" />
   <item android:drawable="@drawable/idle__14_" android:duration="75" />
   <item android:drawable="@drawable/idle__15_" android:duration="75" />
   <item android:drawable="@drawable/idle__16_" android:duration="75" />
</animation-list>

Each <item> must contain the source to the actual drawable resource (the images that we imported earlier). The duration attribute dictates how long to show a frame, in milliseconds.

If you switch to Split/Design view, you can also preview the animation.

Screen_Shot_2022-10-10_at_5.50.14_PM.png

Now that we already know how to define the idle animation resource, create the rest of the animations with the names below.

  1. walk.xml.

     <?xml version="1.0" encoding="utf-8"?>
     <animation-list xmlns:android="http://schemas.android.com/apk/res/android">
        <item android:drawable="@drawable/walk__1_" android:duration="75" />
        <item android:drawable="@drawable/walk__2_" android:duration="75" />
        <item android:drawable="@drawable/walk__3_" android:duration="75" />
        <item android:drawable="@drawable/walk__4_" android:duration="75" />
        <item android:drawable="@drawable/walk__5_" android:duration="75" />
        <item android:drawable="@drawable/walk__6_" android:duration="75" />
        <item android:drawable="@drawable/walk__7_" android:duration="75" />
        <item android:drawable="@drawable/walk__8_" android:duration="75" />
        <item android:drawable="@drawable/walk__9_" android:duration="75" />
        <item android:drawable="@drawable/walk__10_" android:duration="75" />
        <item android:drawable="@drawable/walk__11_" android:duration="75" />
        <item android:drawable="@drawable/walk__12_" android:duration="75" />
        <item android:drawable="@drawable/walk__13_" android:duration="75" />
     </animation-list>
  2. run.xml.

     <?xml version="1.0" encoding="utf-8"?>
     <animation-list xmlns:android="http://schemas.android.com/apk/res/android">
        <item android:drawable="@drawable/run__1_" android:duration="75" />
        <item android:drawable="@drawable/run__2_" android:duration="75" />
        <item android:drawable="@drawable/run__3_" android:duration="75" />
        <item android:drawable="@drawable/run__4_" android:duration="75" />
        <item android:drawable="@drawable/run__5_" android:duration="75" />
        <item android:drawable="@drawable/run__6_" android:duration="75" />
        <item android:drawable="@drawable/run__7_" android:duration="75" />
        <item android:drawable="@drawable/run__8_" android:duration="75" />
        <item android:drawable="@drawable/run__9_" android:duration="75" />
        <item android:drawable="@drawable/run__10_" android:duration="75" />
        <item android:drawable="@drawable/run__11_" android:duration="75" />
     </animation-list>
  3. jump.xml.

     <?xml version="1.0" encoding="utf-8"?>
     <animation-list xmlns:android="http://schemas.android.com/apk/res/android">
        <item android:drawable="@drawable/jump__1_" android:duration="75" />
        <item android:drawable="@drawable/jump__2_" android:duration="75" />
        <item android:drawable="@drawable/jump__3_" android:duration="75" />
        <item android:drawable="@drawable/jump__4_" android:duration="75" />
        <item android:drawable="@drawable/jump__5_" android:duration="75" />
        <item android:drawable="@drawable/jump__6_" android:duration="75" />
        <item android:drawable="@drawable/jump__7_" android:duration="75" />
        <item android:drawable="@drawable/jump__8_" android:duration="75" />
        <item android:drawable="@drawable/jump__9_" android:duration="75" />
        <item android:drawable="@drawable/jump__10_" android:duration="75" />
        <item android:drawable="@drawable/jump__11_" android:duration="75" />
        <item android:drawable="@drawable/jump__12_" android:duration="75" />
        <item android:drawable="@drawable/jump__13_" android:duration="75" />
        <item android:drawable="@drawable/jump__14_" android:duration="75" />
        <item android:drawable="@drawable/jump__15_" android:duration="75" />
        <item android:drawable="@drawable/jump__16_" android:duration="75" />
     </animation-list>
  4. slide.xml

     <?xml version="1.0" encoding="utf-8"?>
     <animation-list xmlns:android="http://schemas.android.com/apk/res/android">
        <item android:drawable="@drawable/slide__1_" android:duration="75" />
        <item android:drawable="@drawable/slide__2_" android:duration="75" />
        <item android:drawable="@drawable/slide__3_" android:duration="75" />
        <item android:drawable="@drawable/slide__4_" android:duration="75" />
        <item android:drawable="@drawable/slide__5_" android:duration="75" />
        <item android:drawable="@drawable/slide__6_" android:duration="75" />
        <item android:drawable="@drawable/slide__7_" android:duration="75" />
        <item android:drawable="@drawable/slide__8_" android:duration="75" />
        <item android:drawable="@drawable/slide__9_" android:duration="75" />
        <item android:drawable="@drawable/slide__10_" android:duration="75" />
        <item android:drawable="@drawable/slide__11_" android:duration="75" />
     </animation-list>
  5. die.xml.

     <?xml version="1.0" encoding="utf-8"?>
     <animation-list xmlns:android="http://schemas.android.com/apk/res/android">
        <item android:drawable="@drawable/dead__1_" android:duration="75" />
        <item android:drawable="@drawable/dead__2_" android:duration="75" />
        <item android:drawable="@drawable/dead__3_" android:duration="75" />
        <item android:drawable="@drawable/dead__4_" android:duration="75" />
        <item android:drawable="@drawable/dead__5_" android:duration="75" />
        <item android:drawable="@drawable/dead__6_" android:duration="75" />
        <item android:drawable="@drawable/dead__7_" android:duration="75" />
        <item android:drawable="@drawable/dead__8_" android:duration="75" />
        <item android:drawable="@drawable/dead__9_" android:duration="75" />
        <item android:drawable="@drawable/dead__10_" android:duration="75" />
        <item android:drawable="@drawable/dead__11_" android:duration="75" />
        <item android:drawable="@drawable/dead__12_" android:duration="75" />
        <item android:drawable="@drawable/dead__12_" android:duration="75" />
        <item android:drawable="@drawable/dead__13_" android:duration="75" />
        <item android:drawable="@drawable/dead__14_" android:duration="75" />
        <item android:drawable="@drawable/dead__15_" android:duration="75" />
        <item android:drawable="@drawable/dead__16_" android:duration="75" />
        <item android:drawable="@drawable/dead__17_" android:duration="75" />
     </animation-list>
Start The Animation In Code

To use the animations that we have defined, first, in onCreate(), get the reference to the ImageView that displays Santa.

val santaImage = findViewById<ImageView>(R.id.image_santa)

We have six buttons in total, calling findViewById() 6 different times would be a chore, so let us use a Map to reduce repetitive code and improve readability.

// Add Button IDs and Animation IDs into map for easy looping.
val animationMap = mapOf(
   Pair(R.id.idle, R.drawable.idle),
   Pair(R.id.walk, R.drawable.walk),
   Pair(R.id.run, R.drawable.run),
   Pair(R.id.jump, R.drawable.jump),
   Pair(R.id.slide, R.drawable.slide),
   Pair(R.id.die, R.drawable.die)
)

for ((buttonId, animationId) in animationMap){
   findViewById<Button>(buttonId).setOnClickListener {
       santaImage.setImageResource(animationId)
       (santaImage.drawable as AnimationDrawable).start()
   }
}

The two most important lines of code above are

santaImage.setImageResource(animationId)
(santaImage.drawable as AnimationDrawable).start()
  1. Every time we need to play a different animation, we need to replace the image resource with the corresponding animation resource.
  2. We then cast the drawable to AnimationDrawable and then call start() to start the animation.
Run The App

We are now ready to run the app, it should behave similarly to the animation below.

Daniweb_animated_Sprites.gif

Summary

We have learned how to animate sprites in this tutorial. The full project code can be found at https://github.com/dmitrilc/DaniwebAnimateSpritesAnimationDrawable.

Android Native – How To Animate View LayoutParams

Introduction

Every Android View has a layoutParams property. This property tells the parent ViewGroup how a View wants to be laid out; it is also often used to change the size of a View.

In this tutorial, we will learn how to animate Views while modifying their layoutParams.

Goals

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

  1. How to animate Views when modifying layoutParams.
Tools Required
  1. Android Studio. The version used in this tutorial is Android Studio Dolphin | 2021.3.1.
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 ic_baseline_sports_basketball_24 vector asset to your project.

  3. 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">
    
        <ImageView
            android:id="@+id/imageView"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:src="@drawable/ic_baseline_sports_basketball_24"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
     </androidx.constraintlayout.widget.ConstraintLayout>
Animate Using ValueAnimator

The first method that we are going to learn in this tutorial would be to use the class ValueAnimator. ValueAnimator has many different static factory methods, but we will focus on the ofInt() method here.

ValueAnimators ofInt() or ofFloat() methods can be used to animate/iterate from a starting number to an ending number. When ValueAnimator is iterating over the numbers, we can set a listener when the number updates. You can access the current value using the property animatedValue.

To animate the basketball doubling in size after it is clicked, use the code below.

   findViewById<ImageView>(R.id.imageView).setOnClickListener { image ->
       Log.d(TAG, "Starting Animation, Width: ${image.width}, Height: ${image.height}")

       ValueAnimator.ofInt(image.height, image.height * 2).apply {
           // Adds listener when value updates
           addUpdateListener { animation ->
               Log.d(TAG, "Animated Value: ${animation.animatedValue as Int}")

               // Updates LayoutParams here
               image.updateLayoutParams<ViewGroup.LayoutParams> {
                   height = animation.animatedValue as Int
                   width = animation.animatedValue as Int
               }
           }

           duration = 2000

           //Must call start to start the animation
           start()
       }
   }

The following animation illustrates how the app behaves after clicks.

daniweb_animated_basketball_1.gif

I have also added the logs, so we can see what happens at each iteration.

---------------------------- PROCESS STARTED (12083) for package com.hoang.daniwebanimateviewlayoutparams ----------------------------
2022-09-29 15:52:18.303 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Starting Animation, Width: 66, Height: 66
2022-09-29 15:52:18.304 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 66
2022-09-29 15:52:18.306 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 66
2022-09-29 15:52:18.325 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 66
2022-09-29 15:52:18.341 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 67
2022-09-29 15:52:18.356 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 70
2022-09-29 15:52:18.375 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 73
2022-09-29 15:52:18.390 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 77
2022-09-29 15:52:18.409 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 82
2022-09-29 15:52:18.424 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 87
2022-09-29 15:52:18.440 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 93
2022-09-29 15:52:18.458 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 99
2022-09-29 15:52:18.474 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 104
2022-09-29 15:52:18.490 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 110
2022-09-29 15:52:18.507 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 115
2022-09-29 15:52:18.523 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 120
2022-09-29 15:52:18.542 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 124
2022-09-29 15:52:18.560 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 127
2022-09-29 15:52:18.573 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 129
2022-09-29 15:52:18.592 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 131
2022-09-29 15:52:18.609 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 132
2022-09-29 16:43:42.966 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Starting Animation, Width: 132, Height: 132
2022-09-29 16:43:42.966 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 132
2022-09-29 16:43:42.975 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 132
2022-09-29 16:43:42.990 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 133
2022-09-29 16:43:43.007 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 136
2022-09-29 16:43:43.026 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 140
2022-09-29 16:43:43.041 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 147
2022-09-29 16:43:43.057 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 155
2022-09-29 16:43:43.073 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 165
2022-09-29 16:43:43.091 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 175
2022-09-29 16:43:43.108 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 186
2022-09-29 16:43:43.124 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 198
2022-09-29 16:43:43.141 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 209
2022-09-29 16:43:43.157 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 221
2022-09-29 16:43:43.173 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 231
2022-09-29 16:43:43.191 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 240
2022-09-29 16:43:43.207 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 248
2022-09-29 16:43:43.224 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 255
2022-09-29 16:43:43.240 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 260
2022-09-29 16:43:43.257 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 263
2022-09-29 16:43:43.273 12083-12083 MAIN_ACTIVITY           com...aniwebanimateviewlayoutparams  D  Animated Value: 264
Animate LayoutParams Using ObjectAnimator

If you do not like the solution above for some reason, you can actually animate the LayoutParams object directly using ObjectAnimator (or ValueAnimator.ofObject()). ObjectAnimator is a subclass of ValueAnimator.

ObjectAnimator provides a large amount of static factory methods, but they all work similarly. The only downside to this method as opposed to a simple value animator is that you will have to write more code. The upside is that you do not have to create a listener. This method is probably more efficient because each frame is not calculated twice (my assumption is based on the Java docs of addUpdateListener).

For demonstration, here is an example implementation of the method public static ObjectAnimator ofObject (T target, Property<T, V> property, TypeEvaluator<V> evaluator, V... values)

findViewById<ImageView>(R.id.imageView).setOnClickListener { image ->
   image.updateLayoutParams {
       height = image.height
       width = image.width
   }

   Log.d(TAG, "Starting height expected: ${image.layoutParams.height * 2}")
   Log.d(TAG, "Starting width expected: ${image.layoutParams.width * 2}")

   ObjectAnimator.ofObject(
       image,
       Property.of(View::class.java, LayoutParams::class.java, "layoutParams"),
       { fraction, next, finalTarget ->
           // next will change as the animation progress
           // finalTarget will stay the same throughout the entire animation
           next.apply {
               height = ((fraction + 1) * finalTarget.height - height).toInt()
               width = ((fraction + 1) * finalTarget.width - width).toInt()
           }
       },
       // Copy constructor. This is final target
       LayoutParams(image.layoutParams).apply {
           height *= 2
           width *= 2
       }
   ).apply {
       doOnEnd {
           Log.d(TAG, "Final height: ${image.height}")
           Log.d(TAG, "Final width: ${image.width}")
           // If you want to, you can set the exact LayoutParams value
           // here to make up for loss precision when converting Float to Int
       }
       duration = 2000
       start()
   }
}

The complexity added here are:

  1. You will have to define a Property object.
  2. You will have to define a TypeEvaluator.
  3. Since the property layoutParams is mutable, you need to be careful where you are passing it.
Summary

We have learned how to animate LayoutParams in this tutorial. The full project code can be found at https://github.com/dmitrilc/DaniwebAnimateViewLayoutParams.

Android Native – Material 3 Card Expanding Animation

Introduction

Cards are a common widget for Material 3-themed applications. Expanding a card after the user performs a click action is a very common behavior. While Android can automatically render the new expanded card automatically, we will have to implement our own animation if we want a smooth, animated transition.

Goals

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

  1. How to animate card expansion animation.
Tools Required
  1. Android Studio. The version used in this tutorial is Android Studio Dolphin | 2021.3.1.
Prerequisite Knowledge
  1. Intermediate Android.
  2. Basic Material 3.
Project Setup

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

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

  2. Upgrade the Material library version to 1.8.0-alpha01.

     implementation 'com.google.android.material:material:1.8.0-alpha01'
  3. Add these two strings into strings.xml.

     <string name="expand">Expand</string>
     <string name="collapse">Collapse</string>
     <string name="super_large_text">SUPER LARGE TEXT</string>
  4. Open themes.xml and replace the current themes parent theme with Theme.Material3.DayNight.NoActionBar.

     <style name="Theme.DaniwebMaterial3CardExpandAnimation" parent="Theme.Material3.DayNight.NoActionBar">
  5. 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">
    
        <com.google.android.material.card.MaterialCardView
            android:id="@+id/cardView"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginHorizontal="16dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent">
    
            <androidx.constraintlayout.widget.ConstraintLayout
                android:layout_width="match_parent"
                android:layout_height="match_parent">
    
                <Button
                    android:id="@+id/button_sizeToggle"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:text="@string/expand"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toTopOf="parent" />
    
                <TextView
                    android:id="@+id/textView_large"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:text="@string/super_large_text"
                    android:textSize="64sp"
                    android:visibility="gone"
                    app:layout_constraintBottom_toBottomOf="parent"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toBottomOf="@id/button_sizeToggle"
                    tools:visibility="visible" />
    
            </androidx.constraintlayout.widget.ConstraintLayout>
        </com.google.android.material.card.MaterialCardView>
     </androidx.constraintlayout.widget.ConstraintLayout>
  6. Replace the content of MainActivity with the code below.

     class MainActivity : AppCompatActivity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_main)
    
            val card = findViewById<CardView>(R.id.cardView)
            val textView = findViewById<TextView>(R.id.textView_large)
            val button = findViewById<Button>(R.id.button_sizeToggle)
    
            button.setOnClickListener {
                if (textView.visibility == View.GONE){
                    button.text = getString(R.string.collapse)
                    textView.visibility = View.VISIBLE
                } else {
                    button.text = getString(R.string.expand)
                    textView.visibility = View.GONE
                }
            }
        }
     }
Easiest Method To Animate Layout Changes

The easiest method to animate layout changes is by adding android:animateLayoutChanges="true" to a ViewGroup element. In the file activity_main.xml, add this attribute to the ConstraintLayout directly below the MaterialCardView element.

<com.google.android.material.card.MaterialCardView


   <androidx.constraintlayout.widget.ConstraintLayout
       android:animateLayoutChanges="true"


       <Button

When we run the app, we can see that it has smooth animation.

daniweb_animated_card_1.gif

While adding this attribute is convenient, the animation duration is fixed at 300 ms. In real world scenarios, I have also found it not working on a RecyclerView. This attribute is a shortcut for the setLayoutTransition() method for ViewGroup. RecyclerViews version of setLayoutTransition() is deprecated, so it will throw an Exception if you attempt to set this attribute on a RecylerView.

java.lang.IllegalArgumentException: Providing a LayoutTransition into RecyclerView is not supported. Please use setItemAnimator() instead for animating changes to the items in this RecyclerView

Remove animateLayoutChanges attribute from the ConstraintLayout before proceeding to the next section.

Animate Using TransitionManager

A second method to animate a card expansion is by using TransitionManagers beginDelayedTransition() method.

To use this method, call it before you make layout changes. You will also have to provide the parent layout, which is the CardView in our case.

button.setOnClickListener {
   if (textView.visibility == View.GONE){
       TransitionManager.beginDelayedTransition(card)
       button.text = getString(R.string.collapse)
       textView.visibility = View.VISIBLE
   } else {
       TransitionManager.beginDelayedTransition(card)
       button.text = getString(R.string.expand)
       textView.visibility = View.GONE
   }
}

There is a subtle difference between the behavior of this technique and the previous technique. When the card is collapsing, it seems like the animation of the button happens before the resizing. When the card is expanding, the text fades in after the resizing.

daniweb_animated_card_2.gif

This behavior is because beginDelayedTransiton() uses an AutoTransition by default. AutoTransition performs the animation in the order below:

  1. Fade out disappearing Views, which is what we see with the texts in the Button and the TextView (on collapsing).
  2. Resizing.
  3. Fade in appearing Views. We saw this with the expanding animation on the TextView.

I would not go as far as to say that this sequential behavior is undesirable. It somewhat looks cool. It is also so subtle that it would not make a difference in most use cases. You can customize a Transition and use an overloaded version of beginDelayedTransition() or use a TransitionSet if you want to change this behavior.

Summary

Congratulations! We have learned how to animate the card expanding animation in this tutorial. The full project code can be found at https://github.com/dmitrilc/DaniwebMaterial3CardExpandAnimation.

Android Native – Drive Activity States in Espresso Tests

Introduction

When working on Espresso tests, you might have run into a situation where you need to verify what your app does when an activity is in a specific Lifecycle state. In this tutorial, we will learn how to achieve this by using the ActivityScenario class.

Goals

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

  1. How to drive an Activitys lifecycle.
Tools Required
  1. Android Studio. The version used in this tutorial is Android Studio Dolphin | 2021.3.1.
Prerequisite Knowledge
  1. Basic Android.
  2. Basic Espresso.
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 entire content of the MainActivity.kt class with the code below. This is just a simple Activity that logs some text at onCreate(), onStart(), onResume(), and onDestroy() methods.

     private const val TAG = "MAIN_ACTIVITY"
    
     class MainActivity : AppCompatActivity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            Log.d(TAG, "On Create")
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_main)
        }
    
        override fun onStart() {
            Log.d(TAG, "On Start")
            super.onStart()
        }
    
        override fun onResume() {
            Log.d(TAG, "On Resume")
            super.onResume()
        }
    
        override fun onDestroy() {
            Log.d(TAG, "On Destroy")
            super.onDestroy()
        }
     }
  3. In Android Studios Logcat, we can use the query below to see the log messages after we run our tests.

     level:debug tag:MAIN_ACTIVITY
  4. In the androidTest source set, remove all test cases from the file named ExampleInstrumentedTest.java.

Use ActivityScenario to Drive Activity States

The two primary methods in the ActivityScenario class that we need to be concerned of for this tutorial is the static factory method launch() and the instance method moveToState().

  1. The launch() methods are used to create the ActivityScenario<A> objects, while the A generic type represents the type of the Activity being driven.
  2. The moveToState() method is used to move the underlying Activity to a different state. The only states that it can move the Activity to are CREATED, STARTED, RESUMED, and DESTROYED.

As a side note, ActivityScenario also implements Closeable. You are recommended to close the Activity after the test completes, so you are recommended to use a Java try-with-resource block or a Kotlin use {} block to automatically close ActivityScenario.

Now its time to create our first test. Add the test below into the ExampleInstrumentedTest class.

@Test
fun lifecycleTest(){
   ActivityScenario.launch(MainActivity::class.java).use { scenario ->

   }
}

In the code snippet above, I used the simplest version of launch(), which only requires the Class object of the Activity being tested. The scenario parameter is of type ActivityScenario<MainActivity>. Using the scenario parameter, we can now call moveToState() methods to move the Activity to different states.

@Test
fun lifecycleTest(){
   ActivityScenario.launch(MainActivity::class.java).use { scenario ->
       scenario.moveToState(Lifecycle.State.CREATED)
       scenario.moveToState(Lifecycle.State.STARTED)
       scenario.moveToState(Lifecycle.State.RESUMED)
       scenario.moveToState(Lifecycle.State.RESUMED)
       scenario.moveToState(Lifecycle.State.RESUMED)
       scenario.moveToState(Lifecycle.State.DESTROYED)
   }
}

After running this test, we can see the code in our MainActivity logging when it is at each step.

---------------------------- PROCESS STARTED (10469) for package com.hoang.daniwebandroidactivityscenario ----------------------------
2022-09-28 17:52:59.082 10469-10469 MAIN_ACTIVITY           com...aniwebandroidactivityscenario  D  On Create
2022-09-28 17:52:59.117 10469-10469 MAIN_ACTIVITY           com...aniwebandroidactivityscenario  D  On Start
2022-09-28 17:52:59.118 10469-10469 MAIN_ACTIVITY           com...aniwebandroidactivityscenario  D  On Resume
2022-09-28 17:52:59.328 10469-10469 MAIN_ACTIVITY           com...aniwebandroidactivityscenario  D  On Start
2022-09-28 17:52:59.328 10469-10469 MAIN_ACTIVITY           com...aniwebandroidactivityscenario  D  On Resume
2022-09-28 17:52:59.393 10469-10469 MAIN_ACTIVITY           com...aniwebandroidactivityscenario  D  On Resume
2022-09-28 17:52:59.506 10469-10469 MAIN_ACTIVITY           com...aniwebandroidactivityscenario  D  On Destroy
---------------------------- PROCESS ENDED (10469) for package com.hoang.daniwebandroidactivityscenario ----------------------------
ActivityScenario vs. ActivityScenarioRule

Android also comes with the ActivityScenarioRule, which is the more preferable method over using ActivityScenario directly. ActivityScenarioRule can automatically handle starting and closing the ActivityScenario for us. It also reduces verbosity because we do not have to call the launch() method again every time we need it.

To use ActivityScenarioRule, declare it in the class ExampleInstrumentedTest (for Kotlin, Java users can just use the @Rule annotation).

@get:Rule
val rule = ActivityScenarioRule(MainActivity::class.java)

@Test
fun lifeCycleTestWithRule() {
   val scenario = rule.scenario
   scenario.moveToState(Lifecycle.State.CREATED)
   scenario.moveToState(Lifecycle.State.STARTED)
   scenario.moveToState(Lifecycle.State.RESUMED)
   scenario.moveToState(Lifecycle.State.RESUMED)
   scenario.moveToState(Lifecycle.State.RESUMED)
   scenario.moveToState(Lifecycle.State.DESTROYED)
}

To access the ActivityScenario object in the test, either call getScenario() (Java) or use property accessor syntax in Kotlin (used in example above).

Summary

In this tutorial, we have learned how to use ActivityScenario/ActivityScenarioRule to drive Activity state in our tests. The full project code can be found at https://github.com/dmitrilc/DaniwebAndroidActivityScenario.

Android Native – Gradle Managed Automated Test Devices

Introduction

In Android Studio, we can run multiple tests in parallel across multiple devices using the dropdown menu.

Screen_Shot_2022-09-26_at_3.02.02_PM.png

Running instrumented tests this way is very convenient during development, but there is a problem with this method when your tests are run on a remote build server:

  • There is no easy way to make sure that all of your emulators are in a consistent state between test runs.

To solve this problem, we can use a special type of emulator called Automated Test Device (ATD). ATDs are different from regular emulators in a couple of ways:

  1. Pre-installed apps that are not useful for your tests are removed. This reduces flakiness and potential device problems from update prompts.
  2. Background services that are not useful for your tests are disabled. This frees up resources for your ATD, improving test speeds.
  3. Hardware rendering is disabled. This allows your test to run on typical headless build servers that do not have a compatible GPU.

In this tutorial, we will learn how to set up our own ATDs using Gradle Managed Devices.

Goals

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

  1. How to set up Automated Test Devices using Gradle Manged Devices.
Tools Required
  1. Android Studio. The version used in this tutorial is Android Studio Dolphin | 2021.3.1.
Prerequisite Knowledge
  1. Intermediate Android.
  2. Basic Instrumented Tests.
Project Setup

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

  1. Create a new Android project with the default Empty Activity.
  2. We do not have to write any Android code for this project, but you should be aware that there is already an instrumented test called ExampleInstrumentedTest in the androidTest source set.
Create Gradle Managed Automated Test Devices

To create ATDs, we must create the Gradle Managed Devices first. After the Gradle Manged Devices are created, we only have to flip a simple switch to make that managed device an ATD.

Follow the steps below to create a managed device:

  1. Open the (Module) build.gradle file.

  2. Find the android block. Add a testOptions block under it. testOptions block can be used to instruct Gradle to how to run your tests. Its corresponding interface can be found here.

     android {
         
         testOptions {
    
         }
     }
  3. We want to add managed devices, so add a block called managedDevices inside testOptions.

     android {
         
         testOptions {
             managedDevices {
    
             }
         }
     }
  4. This block corresponds to the managedDevices() function in the TestOptions interface.

     managedDevices(action: @ExtensionFunctionType ManagedDevices.() -> Unit)
  5. To find out what options are available next in managedDevices block, we can find it in its interface here. The devices property is for setting up your devices. We can also optionally group the devices into groups using its groups property.

  6. Add the devices block inside managedDevices.

     android {
         
         testOptions {
             managedDevices {
                 devices {
    
                 }
             }
         }
     }
  7. Import the ManagedVirtualDevice at the top of this file.

     import com.android.build.api.dsl.ManagedVirtualDevice
  8. The devices block can receive a [ExtensiblePolymorphicDomainObjectContainer<Device>] object, which is also a (Java) Collection<Device>. Because devices is a Collection, you can create more than one device here. To create a new managed virtual device (myPixel5api30), follow the syntax below.

     testOptions {
        managedDevices {
            devices {
                // myPixel5api30 is a friendly name, so you can name it whatever you want.
                myPixel5api30 (ManagedVirtualDevice) {
                    // Device profiles found in Tools > Device Manager > Create Device
                    device = "Pixel 5"
                    // Only API levels 27 and higher are supported so far.
                    apiLevel = 30
                    // "aosp" image does not include google services.
                    // To include Google services, use "google".
                    systemImageSource = "aosp"
                }
            }
        }
     }
  9. ManagedVirtualDevice extends Device. device, apiLevel, and systemImageSource are public properties of ManagedVirtualDevice. You can check the comments in the code snippet above for the rest of the explanation.

  10. Let us add another managed virtual device called samplePixel4.

     testOptions {
        managedDevices {
            devices {
                // myPixel5api30 is a friendly name, so you can name it whatever you want.
                myPixel5api30 (ManagedVirtualDevice) {
                    // Device profiles found in Tools > Device Manager > Create Device
                    device = "Pixel 5"
                    // Only API levels 27 and higher are supported so far.
                    apiLevel = 30
                    // "aosp" image does not include google services.
                    // To include Google services, use "google".
                    systemImageSource = "aosp"
                }
                samplePixel4 (ManagedVirtualDevice){
                    device = "Pixel 4"
                    apiLevel = 30
                    systemImageSource = "google
                }
            }
        }
     }
  11. Now that we have the managed virtual devices created, we can convert them into ATDs by suffixing -atd to their systemImageSources raw String value.

     testOptions {
        managedDevices {
            devices {
                // myPixel5api30 is a friendly name, so you can name it whatever you want.
                myPixel5api30 (ManagedVirtualDevice) {
                    // Device profiles found in Tools > Device Manager > Create Device
                    device = "Pixel 5"
                    // Only API levels 27 and higher are supported so far.
                    apiLevel = 30
                    // "aosp" image does not include google services.
                    // To include Google services, use "google".
                    systemImageSource = "aosp-atd"
                }
                samplePixel4 (ManagedVirtualDevice){
                    device = "Pixel 4"
                    apiLevel = 30
                    systemImageSource = "google-atd"
                }
            }
        }
     }

I had to use apiLevel 30 for both of these sample devices because only API level 30 aosp-atd and google-atd images are available for my platform (Apple ARM). If you are on an x86 machine, then you will have more ATD images to choose from (27+).

Run Tests On Automated Test Devices

After syncing your Gradle tasks, you can run the tests on the ATD directly from the Gradle tasks or using the CLI.

To run the test on from the IDE,

  1. Open the Gradle window.

  2. Tasks > verification.

  3. Find your test tasks here.
    Screen_Shot_2022-09-28_at_12.26.16_PM.png

  4. Double-click on the test task that you want to run.

To run the tasks via the CLI, you can use the commands below.

  • Use your computer's GPU for hardware rendering

      ./gradlew myPixel5api30Check
      ./gradlew myPixel5api30DebugAndroidTest
      ./gradlew samplePixel4Check
      ./gradlew samplePixel4DebugAndroidTest
  • On remote build server without, use the flag -Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"

      ./gradlew -Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect" myPixel5api30Check
Summary

We have learned how to create Automated Test (Android) Devices in this tutorial. The full tutorial can be found at https://github.com/dmitrilc/DaniwebGradleManagedAutomatedTestDevices.

sending email from a webpage

I am trying to send an email from an HTML page using JS
In windows the outlook opens up and the fields are populated , however when I click on the same link to the HTML on my iphone ,

var subject = "NPS:" + document.getElementById("jid").value
var emailto = "sed.ahimi@ico.com"
var BodyMessage = "Node:"+subject+'%0D%0A'+"SCORE:" +document.getElementById("myRange").value+'%0D%0A'+"COMMENTS:"+document.getElementById("notes").value+""

var wmail="mailto:"+emailto +"?subject="+subject+"&body="+BodyMessage;
window = window.open(wmail, 'emailWindow')

I don't see anything being opened

Android development for beginner

Hi! I'm here to ask for an opinion as someone who want to develop an Android app for the first time.
Can someone suggest to me whether I should use Java or Kotlin to write my code? My app will constantly need to fetch and submit data to phpmyadmin which I put it in my online server. For now, I'm planning to use Retrovit for network library.

Thank you in advance.

Android Native – provide fake DataSources to Repositories in Unit Tests

Introduction

In Android projects, DataSource classes act as entry points to interacting with local or remote data sources. Their dependencies tend to be HTTP clients, the database, or DAOs, and their dependents are usually Repository classes.

In this tutorial, we will learn how to provide a fake DataSource to a Repository in unit tests.

Note that DataSource classes mentioned in this tutorial refers to classes following the naming convention of type of data + type of source + DataSource and not some specific framework class.

Goals

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

  1. How to create fake DataSource classes.
Tools Required
  1. Android Studio. The version used in this tutorial is Android Studio Chipmunk 2021.2.1 Patch 1.
Prerequisite Knowledge
  1. Basic Android.
  2. Basic Unit Testing.
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 a class called UserLocalDataSource using the code below. This is just a simple class that with a few functions to keep it simple. It also contains two dependencies: a database and a web service.

     class UserLocalDataSource(
        private val dataBase: Any,
        private val httpClient: Any
     ) {
        fun getUserName() = "User Name"
        fun getUserBirthday() = "User Birthday"
        fun getUserAddress() = "User Address"
     }
  3. Create a class called UserRepository using the code below. This class depends on all functions of UserLocalDatasource.

     class UserRepository(private val userLocalDataSource: UserLocalDataSource) {
        fun getUserData() = userLocalDataSource.getUserName()
        fun getUserBirthday() = userLocalDataSource.getUserBirthday()
        fun getUserAddress() = userLocalDataSource.getUserAddress()
     }
  4. Create the class UserRepositoryUnitTest in the test source set using the code below. Ignore the compile error for now.

     class UserRepositoryUnitTests {
    
        private val repo = UserRepository(UserLocalDataSource())
    
        @Test
        fun getUserData_isCorrect(){
            repo.getUserData()
        }
    
        @Test
        fun getUserBirthday_isCorrect(){
            repo.getUserBirthday()
        }
    
        @Test
        fun getUserAddress_isCorrect(){
            repo.getUserAddress()
        }
     }
The Problems with NOT using a Fake

At this point, there is a problem that exist in our application.

Because UserRepository depends directly on the UserLocalDatasource class, we are forced to instantiate a real instance of UserLocalDataSource in our unit tests as well. This makes it hard to set up the tests, especially when UserLocalDataSource is also dependent on other classes; this means that we will have to also set up dependencies for UserLocalDataSource in the tests (and probably dependencies of those dependencies).

Unit tests are supposed to focus only on the class being tested, and they should execute quickly.

Replacing concrete dependencies with Interfaces

A good method to fix the problem listed in the previous section would be to replace the concrete dependency of UserLocalDataSource in UserRepository with an interface instead. This makes it very easy to switch out the real implementation with a fake implementation in unit tests.

Follow the steps below to make UserRepository depend on an abstract interface:

  1. Open UserLocalDataSource.

  2. Right-click on the class name -> Refactor -> Rename.

  3. Change it to UserLocalDataSourceImpl.

     class UserLocalDataSourceImpl(
        private val dataBase: Any,
        private val httpClient: Any
     ) {
        fun getUserName() = "User Name"
        fun getUserBirthday() = "User Birthday"
        fun getUserAddress() = "User Address"
     }
  4. Now, create the interface UserLocalDataSource using the code below.

     interface UserLocalDataSource {
        fun getUserName(): String
        fun getUserBirthday(): String
        fun getUserAddress(): String
     }
  5. Back to the UserLocalDataSourceImpl class, make it implements UserLocalDataSource. You will have to prefix all the functions in UserLocalDataSourceImpl with the keyword override.

     class UserLocalDataSourceImpl(
        private val dataBase: Any,
        private val httpClient: Any
     ): UserLocalDataSource {
        override fun getUserName() = "User Name"
        override fun getUserBirthday() = "User Birthday"
        override fun getUserAddress() = "User Address"
     }
  6. Now that we have the interface UserLocalDataSource, we can use that as a dependency for UserRepository instead of the concrete type UserLocalDataSourceImpl. In the UserRepository, replace UserLocalDataSourceImpl with UserLocalDataSource**.

     class UserRepository(private val userLocalDataSource: UserLocalDataSource) {
        fun getUserData() = userLocalDataSource.getUserName()
        fun getUserBirthday() = userLocalDataSource.getUserBirthday()
        fun getUserAddress() = userLocalDataSource.getUserAddress()
     }
Creating a fake DataSource

Because UserRepository can now receive any instance of UserLocalDataSource, including fake ones, we no longer have to worry about providing a real implementation of UserLocalDataSourceImpl and its dependencies (database and web service).

Create a fake UserLocalDataSource called FakeUserLocalDataSource (in the test source set) using the code below.

class FakeUserLocalDataSource: UserLocalDataSource {
   override fun getUserName() = "User Name"
   override fun getUserBirthday() = "Birthday"
   override fun getUserAddress() = "Address"
}

The only important thing that you need to be aware of when overriding UserLocalDataSource in a fake is that you return the correct data that needs to be tested. If you are familiar with testing, the class above can also be called a Stub because it simply returns hard-coded data.

Finally, in the UserRepositoryUnitTests class, you can just provide UserRepository with an instance of FakeUserLocalDataSource without having to provide it any other dependencies.

private val repo = UserRepository(FakeUserLocalDataSource())
Solution Code

UserLocalDataSource.kt

interface UserLocalDataSource {
   fun getUserName(): String
   fun getUserBirthday(): String
   fun getUserAddress(): String
}

UserLocalDataSourceImpl.kt

class UserLocalDataSourceImpl(
   private val dataBase: Any,
   private val httpClient: Any
): UserLocalDataSource {
   override fun getUserName() = "User Name"
   override fun getUserBirthday() = "User Birthday"
   override fun getUserAddress() = "User Address"
}

UserRepository.kt

class UserRepository(private val userLocalDataSource: UserLocalDataSource) {
   fun getUserData() = userLocalDataSource.getUserName()
   fun getUserBirthday() = userLocalDataSource.getUserBirthday()
   fun getUserAddress() = userLocalDataSource.getUserAddress()
}

FakeUserLocalDataSource.kt

class FakeUserLocalDataSource: UserLocalDataSource {
   override fun getUserName() = "User Name"
   override fun getUserBirthday() = "Birthday"
   override fun getUserAddress() = "Address"
}

UserRepositoryUnitTests.kt

class UserRepositoryUnitTests {

   private val repo = UserRepository(FakeUserLocalDataSource())

   @Test
   fun getUserData_isCorrect(){
       repo.getUserData()
   }

   @Test
   fun getUserBirthday_isCorrect(){
       repo.getUserBirthday()
   }

   @Test
   fun getUserAddress_isCorrect(){
       repo.getUserAddress()
   }
}
Summary

We have learned how to create a fake DataSource in this tutorial. Creating fakes is not limited to only Android or DataSource classes. You can also create fakes for Repository classes, web services, etc. The full project can be found at https://github.com/dmitrilc/DaniwebAndroidFakeDataSourceUnitTest.

Android Native – How to test Navigation Components

Introduction

In this tutorial, we will learn how to create an instrumented test for Navigation Components.

Goals

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

  1. How to test Navigation Components.
Tools Required
  1. Android Studio. The version used in this tutorial is Android Studio Chipmunk 2021.2.1 Patch 1.
Prerequisite Knowledge
  1. Intermediate Android.
  2. Basic Navigation Components.
  3. Basic Espresso.
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.

     def nav_version = "2.4.2"
     // Kotlin
     implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
     implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
     // Testing Navigation
     androidTestImplementation "androidx.navigation:navigation-testing:$nav_version"
     def fragment_version = "1.4.1"
     debugImplementation "androidx.fragment:fragment-testing:$fragment_version"
  3. Replace the code in activity_main.xml with the code below. This adds a FragmentViewContainer.

     <?xml version="1.0" encoding="utf-8"?>
     <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">
    
        <androidx.fragment.app.FragmentContainerView
            android:id="@+id/nav_host_fragment"
            android:name="androidx.navigation.fragment.NavHostFragment"
            android:layout_width="0dp"
            android:layout_height="0dp"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
    
            app:defaultNavHost="true"
            app:navGraph="@navigation/nav_graph" />
    
     </androidx.constraintlayout.widget.ConstraintLayout>
  4. Add the navigation graph below into res/navigation. This navigation graph contains two destinations and one action.

     <?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/homeFragment">
    
        <fragment
            android:id="@+id/homeFragment"
            android:name="com.example.daniwebandroidnavigationtest.HomeFragment"
            android:label="fragment_home"
            tools:layout="@layout/fragment_home" >
            <action
                android:id="@+id/action_homeFragment_to_destination1Fragment"
                app:destination="@id/destination1Fragment" />
        </fragment>
        <fragment
            android:id="@+id/destination1Fragment"
            android:name="com.example.daniwebandroidnavigationtest.Destination1Fragment"
            android:label="fragment_destination1"
            tools:layout="@layout/fragment_destination1" />
     </navigation>
  5. Create a Fragment called HomeFragment using the code below.

     class HomeFragment : Fragment() {
    
        override fun onCreateView(
            inflater: LayoutInflater, container: ViewGroup?,
            savedInstanceState: Bundle?
        ): View? {
            val layout = inflater.inflate(R.layout.fragment_home, container, false)
    
            val button = layout.findViewById<Button>(R.id.button)
            button.setOnClickListener {
                findNavController().navigate(R.id.action_homeFragment_to_destination1Fragment)
            }
    
            // Inflate the layout for this fragment
            return layout
        }
     }
  6. Create another Fragment called Destination1Fragment using the code below.

     class Destination1Fragment : Fragment() {
    
        override fun onCreateView(
            inflater: LayoutInflater, container: ViewGroup?,
            savedInstanceState: Bundle?
        ): View? {
            // Inflate the layout for this fragment
            return inflater.inflate(R.layout.fragment_destination1, container, false)
        }
    
     }
  7. Add the layout resource called fragment_home.xml using 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=".HomeFragment">
    
        <Button
            android:id="@+id/button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/next"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
     </androidx.constraintlayout.widget.ConstraintLayout>
  8. Add the fragment_destination1.xml layout using the code below.

     <?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=".Destination1Fragment">
    
        <TextView
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:text="@string/hello_blank_fragment" />
    
     </FrameLayout>
  9. Add the <string> resources below into strings.xml.

     <string name="hello_blank_fragment">Hello blank fragment</string>
     <string name="next">Next</string>
Project Overview

Our app is super simple. It contains a navigation graph and two destinations, one of which is the home destination. After clicking on the Button Next, it will navigate to the next destination.

Android_Navigation_Component.gif

Our goal is to create an instrumented test for this interaction and verify whether the navigation is working correctly.

Creating the Instrument Test

Before creating any test, we will create the test class first. Create the class NavTest in the androidTest source set using the code below.

@RunWith(AndroidJUnit4::class)
class NavTest {

   @Test
   fun testNav() {

   }
}

Follow the steps below to add code for testing navigation.

  1. The first thing that we need to do in this test is to retrieve an instance of TestNavHostController.

     //Getting the NavController for test
     val navController = TestNavHostController(
        ApplicationProvider.getApplicationContext()
     )
  2. We will use FragmentScenario to start the home destination HomeFragment in isolation. Here we used the convenient method launchFragmentInContainer() from the androidx.fragment.app.testing package to create a FragmentScenario object. We immediately called onFragment() on it to perform further setup.

     //Launches the Fragment in isolation
     launchFragmentInContainer<HomeFragment>().onFragment { fragment ->
    
     }
  3. Inside the body of the lambda, set the navigation graph for the navController created in step 1.

     //Launches the Fragment in isolation
     launchFragmentInContainer<HomeFragment>().onFragment { fragment ->
        //Setting the navigation graph for the NavController
        navController.setGraph(R.navigation.nav_graph)
    
     }
  4. Because the Fragment launched started in isolation does not have any NavController associated with it, we need to associate the navController created in step 1 to the Fragment, so that its findNavController() call will work correctly.

     //Launches the Fragment in isolation
     launchFragmentInContainer<HomeFragment>().onFragment { fragment ->
        //Setting the navigation graph for the NavController
        navController.setGraph(R.navigation.nav_graph)
    
        //Sets the NavigationController for the specified View
        Navigation.setViewNavController(fragment.requireView(), navController)
     }
  5. The next step is using Espresso to find the Button and perform a click() on it, triggering the navigation to the next destination.

     // Verify that performing a click changes the NavControllers state
     onView(ViewMatchers.withId(R.id.button))
        .perform(ViewActions.click())
  6. Finally, we verify whether the navigation happened successfully by comparing the current destinations ID with the target destination ID.

     assertEquals(
        navController.currentDestination?.id,
        R.id.destination1Fragment
     )
Solution Code

NavTest.kt

@RunWith(AndroidJUnit4::class)
class NavTest {

   @Test
   fun testNav() {
       //Getting the NavController for test
       val navController = TestNavHostController(
           ApplicationProvider.getApplicationContext()
       )

       //Launches the Fragment in isolation
       launchFragmentInContainer<HomeFragment>().onFragment { fragment ->
           //Setting the navigation graph for the NavController
           navController.setGraph(R.navigation.nav_graph)

           //Sets the NavigationController for the specified View
           Navigation.setViewNavController(fragment.requireView(), navController)
       }

       // Verify that performing a click changes the NavControllers state
       onView(ViewMatchers.withId(R.id.button))
           .perform(ViewActions.click())

       assertEquals(
           navController.currentDestination?.id,
           R.id.destination1Fragment
       )
   }
}
Summary

We have learned how to test Navigation Components in this tutorial. The full tutorial code can be found at https://github.com/dmitrilc/DaniwebAndroidNavigationTest.

What kind of apps need ASO optimization ?

App Store Optimization (ASO) is the process of improving app visibility within the app stores and increasing app conversion rates. What kind of apps need ASO optimization? Here are a few suggestions for you!

New app
In major application markets, various types of apps have basically formed a monopoly. so how can these new apps have their own place in the fierce competition? The answer is that through ASO optimization, through ASO, some new apps have the opportunity to enter the public's vision and obtain their own user groups.

Fewer downloads and comments
Have ever found such a phenomenon, the number of downloads and comments of the app that ranks in the top of the list of keywords will not be less. According to our observation, the number of downloads and reviews has a great impact on the weight of the product, and the ranking of apps with high weight is easier to rise. Moreover, when users choose an app, they will go to see whether the number of downloads is large or not, and how the comments are scored. Therefore, apps with less downloads and comments also need to be optimized by ASO.

Less keywords
From the perspective of an app with a certain keyword ranking first, it covers a lot of keywords, which means that the greater the probability that users find the app through keyword search, which will greatly improve the number of users.
In contrast, some apps have very few keywords, and even some products have only one keyword of their own brand name, which means that users can find the app only by searching the product name. In contrast, the probability of users downloading the product will be greatly reduced.

Apps with more keywords but lower ranking
Although some products cover a lot of keywords, the ranking of each keyword is very low, which is not conducive to its subsequent development. This type is also the type that needs ASO optimization most. Once the ranking of effective keywords reaches top5, top3 or even Top1, the number of users will be immeasurable.

Android Native – How to serve asynchronous data to ListAdapter

Introduction ##

In this tutorial, we will learn how to load data asynchronously into a ListAdapter (a subclass of RecyclerView.Adapter).

Goals

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

  1. How to serve asynchronous data to a ListAdapter.
Tools Required
  1. Android Studio. The version used in this tutorial is Android Studio Chipmunk 2021.2.1 Patch 1.
Prerequisite Knowledge
  1. Intermediate Android.
  2. *RecyclerView.Adapter.
  3. Kotlin coroutines.
  4. Retrofit.
  5. Moshi
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.

     implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.5.0-rc01"
     implementation 'com.squareup.retrofit2:retrofit:2.9.0'
     implementation 'androidx.activity:activity-ktx:1.4.0'
     implementation 'io.coil-kt:coil:2.1.0'
     implementation 'com.squareup.retrofit2:converter-moshi:2.9.0'
  3. Because we are reaching out to the Dog API (https://dog.ceo/dog-api) in this tutorial, internet permission will be required. Add the permission below to your manifest.

     <uses-permission android:name="android.permission.INTERNET"/>
  4. Replace the code in activity_main.xml with the code below. We have replaced the default TextView with a RecyclerView.

     <?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/recyclerView_dog"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
            app:spanCount="2" />
    
     </androidx.constraintlayout.widget.ConstraintLayout>
  5. Create a new layout for a ViewHolder called item_view.xml. Replace the code inside item_view.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="wrap_content"
        android:layout_height="wrap_content">
    
        <ImageView
            android:id="@+id/imageView_breedImage"
            android:layout_width="50dp"
            android:layout_height="50dp"
            android:layout_margin="8dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            tools:srcCompat="@tools:sample/avatars" />
    
        <TextView
            android:id="@+id/textView_dogBreed"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginHorizontal="8dp"
            tools:text="Dog Breed"
            android:textSize="18sp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toEndOf="@+id/imageView_breedImage"
            app:layout_constraintTop_toTopOf="parent" />
    
     </androidx.constraintlayout.widget.ConstraintLayout>
  6. Create a new Kotlin file called DogService.kt. This file will house our Retrofit HTTP service. Copy and paste the code below into this file.

     interface DogService {
    
        @GET("breeds/list/all")
        suspend fun getAllBreeds(): BreedsCall?
    
        @GET("breed/{breed}/images/random")
        suspend fun getImageUrlByBreed(@Path("breed") breed: String): ImageUrlCall?
    
        companion object {
            val INSTANCE: DogService = Retrofit.Builder()
                .baseUrl("https://dog.ceo/api/")
                .addConverterFactory(MoshiConverterFactory.create())
                .build()
                .create(DogService::class.java)
        }
     }
    
     data class BreedsCall(
        val message: Map<String, List<String>>?
     )
    
     data class ImageUrlCall(
        val message: String?
     )
  7. Create a new data class called DogUiState using the code below. This is the data that we will service from the ViewModel.

     data class DogUiState(
        val breed: String,
        val image: Drawable? = null
     )
  8. Create a new class called MainViewModel using the code below. This is our programs only ViewModel.

     class MainViewModel : ViewModel() {
        private val httpClient = DogService.INSTANCE
    
        //Adapter will invoke this
        val imageUrlLoader: (String)->Unit = { breed ->
            if (!_imageUrlCache.value.containsKey(breed)){
                loadImageUrl(breed)
            }
        }
    
        //Self will invoke this
        var imageLoader: ((breed: String, url: String)->Unit)? = null
    
        private val _uiState = MutableStateFlow<List<DogUiState>>(listOf())
        val uiState = _uiState.asStateFlow()
    
        private val _imageUrlCache = MutableStateFlow<Map<String, String?>>(mapOf())
    
        //Fine-grained thread confinement. Performance penalty.
        private val mutex = Mutex()
    
        init {
            //Gets breeds
            viewModelScope.launch(Dispatchers.IO) {
                try {
                    httpClient.getAllBreeds()?.message?.let { breeds ->
                        if (breeds.isNotEmpty()){
                            val state = breeds.keys
                                .map {
                                    DogUiState(breed = it)
                                }
    
                            _uiState.value = state
                        }
                    }
                } catch (e: IOException){
                    e.printStackTrace()
                }
            }
        }
    
        private fun loadImageUrl(breed: String) {
            //Adding the breed key so observers know that there is already
            // pending async loading operation
            _imageUrlCache.value = _imageUrlCache.value.plus(breed to null)
    
            viewModelScope.launch(Dispatchers.IO){
                try {
                    //Loading image URL
                    httpClient.getImageUrlByBreed(breed)
                        ?.message
                        ?.let {
                            mutex.withLock {
                                //Adds url to the URL cache
                                _imageUrlCache.value = _imageUrlCache.value.plus(breed to it)
                            }
    
                            //Starts loading images
                            imageLoader?.invoke(breed, it)
                        }
                } catch (e: IOException){
                    e.printStackTrace()
                }
            }
        }
    
        fun updateImage(drawable: Drawable, breed: String){
            //Updates UiState with image
            viewModelScope.launch(Dispatchers.IO) {
                mutex.withLock {
                    _uiState.value = _uiState.value.map {
                        if (it.breed == breed){
                            it.copy(image = drawable)
                        } else {
                            it
                        }
                    }
                }
            }
        }
     }
  9. Create a new class called DogAdapter using the code below.

     class DogAdapter(private val imageUrlLoader: (String)->Unit)
        : ListAdapter<DogUiState, DogAdapter.DogViewHolder>(DIFF_UTIL_CALLBACK) {
    
        inner class DogViewHolder(view: View) : RecyclerView.ViewHolder(view){
            val breed: TextView = view.findViewById(R.id.textView_dogBreed)
            val image: ImageView = view.findViewById(R.id.imageView_breedImage)
        }
    
        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DogViewHolder {
            val itemView = LayoutInflater
                .from(parent.context)
                .inflate(R.layout.item_view, parent, false)
    
            return DogViewHolder(itemView)
        }
    
        override fun onBindViewHolder(holder: DogViewHolder, position: Int) {
            val currentData = currentList[position]
    
            holder.breed.text = currentData.breed
    
            //If there is no image data, requests ViewModel
            //to start loading the images
            if (currentData.image != null){
                holder.image.setImageDrawable(currentData.image)
            } else {
                imageUrlLoader(currentData.breed)
            }
        }
    
        override fun onViewRecycled(holder: DogViewHolder) {
            //If Drawables are not released, ViewHolders will display wrong image
            //when you are scrolling too fast
            holder.image.setImageDrawable(null)
            super.onViewRecycled(holder)
        }
    
        companion object {
            val DIFF_UTIL_CALLBACK = object : DiffUtil.ItemCallback<DogUiState>() {
                override fun areItemsTheSame(oldItem: DogUiState, newItem: DogUiState): Boolean {
                    //This is called first
                    return oldItem.breed == newItem.breed
                }
    
                override fun areContentsTheSame(oldItem: DogUiState, newItem: DogUiState): Boolean {
                    //This is called after
                    return oldItem == newItem
                }
            }
        }
     }
  10. Finally, replace the content of MainActivity.kt with the code below.

     class MainActivity : AppCompatActivity() {
        private val viewModel by viewModels<MainViewModel>()
    
        //Re-usable request builder
        private val imageRequestBuilder = ImageRequest.Builder(this)
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_main)
    
            /*
                Performing the image loading in Activity code because
                Coil requires a context. Can also use AndroidViewModel if
                you want the ViewModel to do the image loading as well.
            */
            val imageLoader: (breed: String, url: String)->Unit = { breed, url ->
                val request = imageRequestBuilder
                    .data(url)
                    .build()
    
                lifecycleScope.launch(Dispatchers.IO){
                    imageLoader.execute(request).drawable?.also {
                        //Sends image to ViewModel so it can update the UiState
                        viewModel.updateImage(it, breed)
                    }
                }
            }
    
            //Pass the callback to ViewModel
            viewModel.imageLoader = imageLoader
    
            val recyclerView = findViewById<RecyclerView>(R.id.recyclerView_dog)
            val dogAdapter = DogAdapter(viewModel.imageUrlLoader).also {
                recyclerView.adapter = it
            }
    
            lifecycleScope.launch {
                viewModel.uiState.collect {
                    //Submit list so ListAdapter can calculate the diff
                    dogAdapter.submitList(it)
                }
            }
        }
     }
Project Overview

We technically already have the fully completed project at this stage. The app will smoothly load both dog breed and a random breed image (in background threads) into the RecyclerView.

Dog_App.gif

For the rest of the tutorial, we will mostly learn how all of this works in the background.

Architecture

Because of how the Dog API works, we have to make at least three HTTP calls to be able to achieve the functionality that we want.

  1. First call: get the list of breeds. We only do this once in MainViewModel.

     httpClient.getAllBreeds()?.message?.let { breeds ->
        if (breeds.isNotEmpty()){
            val state = breeds.keys
                .map {
                    DogUiState(breed = it)
                }
    
            _uiState.value = state
        }
     }
  2. Second call: get a random image URL for a specific breed. We have to do this for every single breed, only as needed (when RecyclerView requests the data). The callback below is passed to the ListAdapter, which it will invoke during onBindViewHolder().

     //Adapter will invoke this
     val imageUrlLoader: (String)->Unit = { breed ->
        if (!_imageUrlCache.value.containsKey(breed)){
            loadImageUrl(breed)
        }
     }
  3. Third call: use Coil to load the image using the image URL.

     /*
        Performing the image loading in Activity code because
        Coil requires a context. Can also use AndroidViewModel if
        you want the ViewModel to do the image loading as well.
     */
     val imageLoader: (breed: String, url: String)->Unit = { breed, url ->
        val request = imageRequestBuilder
            .data(url)
            .build()
    
        lifecycleScope.launch(Dispatchers.IO){
            imageLoader.execute(request).drawable?.also {
                //Sends image to ViewModel so it can update the UiState
                viewModel.updateImage(it, breed)
            }
        }
     }
  4. Callbacks can be hard to read, so you can reference the diagram below for an overview of what is going on.

Untitled_Diagram_drawio.png

DiffUtil Usage in DogAdapter

The goal of DiffUtil in DogAdapter is only to assist the RecyclerView in figuring how your dataset as changed. Because the list of breeds do not change when the app is being used, it is safe to use it as a key to identify whether two items are the same. Improperly implementing this function can cause weird flickering and jumping issues.

override fun areItemsTheSame(oldItem: DogUiState, newItem: DogUiState): Boolean {
   //This is called first
   return oldItem.breed == newItem.breed
}

The animation below depicts how your app will look like if you always return false from areItemsTheSame().

override fun areItemsTheSame(oldItem: DogUiState, newItem: DogUiState): Boolean {
   //This is called first
   //return oldItem.breed == newItem.breed
   return false
}

Dog_App_Flickering.gif

The second function that we have overridden is areContentsTheSame(). This function is only called if areItemsTheSame() returns true. This is where we decide whether a Drawable has been loaded or not.

Race Conditions

When the list was being scrolled too fast, the StateFlows _uiState and _imageUrlCache might experience race conditions where the previous value of their state might be outdated, and work from one thread will override the value from the other.

I have experienced this in about 1 in 10 runs, so I have added a quick fix using a Mutex. The Mutex introduces a performance penalty, but the app felt smooth, so I did not feel like more optimization was needed.

Summary

In a real app, you primarily would want to perform IO operations in a Repository or UseCases instead. I have skipped the data layer in this tutorial to keep it simple.

Another approach to loading async data in use cases like this is to use the Paging 3 library. You can check out the tutorial on it here

The full project code can be found at https://github.com/dmitrilc/DaniwebAndroidListAdapterAsyncData.