Advanced Android in Kotlin 02.4: Creating Effects with Shaders

This codelab is part of the Advanced Android in Kotlin course. You'll get the most value out of this course if you work through the codelabs in sequence, but it is not mandatory. All the course codelabs are listed on the Advanced Android in Kotlin codelabs landing page.

Introduction

In computer graphics, a shader is a type of computer program that was originally used for shading (the production of appropriate levels of light, darkness, and color within an image), but which now performs a variety of specialized functions in various fields of computer graphics special effects.

In Android, Shader defines the color(s) or the texture with which the Paint object should draw (other than a bitmap). Android defines several subclasses of Shader for Paint to use, such as BitmapShader, ComposeShader, LinearGradient, RadialGradient, and SweepGradient.

For example, you can use a BitmapShader to define a bitmap as a texture to the Paint object. This allows you to implement custom themes with translucent effects, and custom views with a bitmap as a texture. You can also use masks with transition animations to create impressive visual effects. To draw images in different shapes (rounded rectangle in this example), you can define a BitmapShader for your Paint object and use the drawRoundRect() method to draw a rectangle with rounded corners, as shown below.

b2bf099e7cdab79.png

In this codelab, you will use a BitmapShader to set a bitmap as a texture to the Paint object, instead of a simple color.

What you should already know

You should be familiar with:

  • Creating a custom View.
  • Drawing on a Canvas.
  • Adding event handlers to views.

What you'll learn

  • How to set a Shader for a Paint and use it to modify what is being drawn.

What you'll do

  • Create a simple game with a spotlight to find a hidden Android image.

The FindMe app lets you search for an Android image 495f3c01e0b4d742.pngon a dark phone screen using a "spotlight."

  • At app startup, a dialog with instructions on how to play with a play d7776efda01e9291.png button is displayed.
  • Once the play button has been clicked, a black screen is displayed.
  • The player has to touch and hold the screen to reveal the "spotlight" (white circle).
  • While the user drags their finger, the white circle follows the touch.
  • When the white circle overlaps with the hidden Android image, and the user lifts their finger, the screen lights up to reveal the complete image.
  • When the user touches the screen again, the screen turns black and the Android image is hidden in a new random location.

The following screenshots show the FindMe app at startup, when the user is dragging their finger, and after the user has found the Android image by moving around the spotlight.

Additional features:

  • Spotlight is not centered under the finger, so that the user can see what's inside the circle.

In this task, you create the FindMe app from scratch, so you will set up a project and define some strings.

Step: Create the FindMe project

  1. Open Android Studio.
  2. Create a new Kotlin project called FindMe that uses the Empty Activity template.
  3. Open strings.xml and add the following strings for the app title and game instructions.
<resources>
   <string name="app_name">Find the Android</string>

   <string name="instructions_title">
   <b>How to play:</b>
   </string>

   <string name="instructions">
       \t \u2022 Find the Android hidden behind the dark surface. \n
       \t \u2022 Touch and hold the screen for the spotlight. \n
       \t \u2022 Once you find the Android, lift your finger to end the game. \n \n
       \t \u2022 To restart the game touch the screen again.
   </string>
</resources>
  1. Get the image of an Android on a skateboard from GitHub and add it to your drawable folder. Alternatively, you can use a small image of your own choice with dimensions close to 120 X 120 pixels, named android, and add it to your drawable folder.

8a6e06936ec322b3.png

  1. Download the mask image (https://github.com/googlecodelabs/android-drawing-shaders/blob/master/app/src/main/res/drawable/mask.png) and add it to your drawable folder. You will be using this image for masking the spotlight. You will learn about masking later in this codelab.

34d7694edbcd79e.png

In this task, you create a custom ImageView, SpotLightImageView, and declare some helper variables. This class is where game play takes place. SpotLightImageView

  • responds to motion events on the screen.
  • draws the game screen, with the spotlight at the current position of the user's finger.
  • displays the Android image when winning conditions are met.

Step: Create the SpotLightImageView class

  1. Create a new Kotlin class called SpotLightImageView.
  2. Extend the SpotLightImageView class from AppCompatImageView. Import androidx.appcompat.widget.AppCompatImageView when prompted.
class SpotLightImageView : AppCompatImageView {
}
  1. Click on AppCompatImageView, and then click the red bulb. Choose Add Android View constructors using ‘@JvmOverloads'. The @JvmOverloads annotation instructs the Kotlin compiler to generate one additional overload for every parameter with a default value, which has this parameter and all parameters to the right of it in the parameter list removed.

3baca21c6bdddab.png

After Android Studio adds the constructor from the AppCompatImageView class, the generated code should look like the code below.

class SpotLightImageView @JvmOverloads constructor(
   context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : AppCompatImageView(context, attrs, defStyleAttr) {
}
  1. In SpotLightImageView, declare and define the following class variables.
private var paint = Paint()
private var shouldDrawSpotLight = false
private var gameOver = false

private lateinit var winnerRect: RectF
private var androidBitmapX = 0f
private var androidBitmapY = 0f
  1. In SpotLightImageView, create and initialize variables for the Android image and the mask.
private val bitmapAndroid = BitmapFactory.decodeResource(
   resources,
   R.drawable.android
)
private val spotlight = BitmapFactory.decodeResource(resources, R.drawable.mask)

Android Image

Mask

  1. Open activity_main.xml. (In Android Studio 4.0 and later, click the Split 297446720c3b6f44.pngicon in the top-right corner to show both the XML code and the preview pane.)
  2. Replace the Hello world! text view with the custom view, SpotLightImageView, as shown in the code below. Make sure your package name and custom view name match.
<com.example.android.findme.SpotLightImageView
   android:id="@+id/spotLightImageView"
   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" />
  1. Run your app. Notice a blank white screen, because you are not drawing anything in the SpotLightImageView yet.

ac90f6cb663bb6d3.png

A Shader defines the texture for a Paint object. A subclass of Shader is installed in a Paint by calling paint.setShader(shader). After that, any object (other than a bitmap) that is drawn with that Paint will get its color(s) from the shader.

Android provides the following subclasses of Shader for Paint to use:

  • LinearGradient draws a linear gradient using two or more given colors.

a885c83379280920.png

  • RadialGradient draws a radial gradient using the given (two or more) colors, the center, and the radius. The colors are distributed between the center and edge of the circle.

de2bc2fd8bf90bf4.png

  • SweepGradient, draws a sweeping gradient around a center point with the specified colors.

2bdcf142fe2a39b5.png

  • ComposeShader is a composition of two shaders. ComposeShader and composing modes are beyond the scope of this codelab, please read the ComposeShader documentation for further learning.
  • BitmapShader draws a bitmap drawable as a texture. The bitmap can be repeated or mirrored by setting the TileMode mode. You will learn more about BitmapShader and TileMode later in this codelab.

Concept: PorterDuff.Mode

The PorterDuff.Mode class provides several Alpha compositing and blending modes. Alpha compositing is the process of compositing (or combining) a source image with a destination image to create the appearance of partial or full transparency. Transparency is defined by the alpha channel. The alpha channel represents the degree of transparency of a color, that is, for its red, green and blue channels.

To learn about blending modes, see the blending modes documentation.

For example consider the following source and destination images.

Source Image

Destination Image

Here is a table defining some of the Alpha compositing modes:

PorterDuff.Mode

Result of composting

DST

The source pixels are discarded, leaving the destination intact.

DST_ATOP

The destination pixels that are not covered by source pixels are discarded.

DST_IN

The destination pixels that cover source pixels, are kept, and the remaining source and destination pixels are discarded.

DST_OUT

The destination pixels that are not covered by source pixels are kept.

You will use the DST_OUT PorterDuff Mode to invert the mask asset to be a black rectangle with a spotlight. To learn more about the other modes, refer to the Alpha compositing modes documentation.

In this task, you create a texture using a mask bitmap for the shader to use.

  • Create a bitmap of the same size as the spotlight (mask) bitmap.
  • Create a canvas with that bitmap for drawing on the bitmap. Color the background black.
  • Create the texture using the BitmapShader.
  • Create the spotlight; composite (combine) the new bitmap with the spotlight (mask) bitmap you defined earlier using the PorterDuff.Mode.
  • Fill the entire screen with the texture. The created texture bitmap is smaller than the screen, so use the CLAMP TileMode to draw the spotlight once and fill in the rest of the screen with black.

4ad43553108bfae3.png

Step 1: Create the destination bitmap

  1. In SpotLightImageView, add an init block.
  2. Inside the init block, create a bitmap of the same size as the spotlight bitmap you created from the mask image, using createBitmap().
init {
   val bitmap = Bitmap.createBitmap(spotlight.width, spotlight.height, Bitmap.Config.ARGB_8888)
}
  1. At the end of the init block, create and initialize a Canvas object with the new bitmap.
  2. Below, create and initialize a Paint object.
val canvas = Canvas(bitmap)
val shaderPaint = Paint(Paint.ANTI_ALIAS_FLAG)
  1. Create the bitmap texture and color the bitmap black. In a later step, you will create the spotlight effect by compositing the mask image you downloaded earlier. Draw a black rectangle of the same size as the spotlight bitmap.
// Draw a black rectangle.
shaderPaint.color = Color.BLACK
canvas.drawRect(0.0f, 0.0f, spotlight.width.toFloat(), spotlight.height.toFloat(), shaderPaint)

bed3c1e8b4b89c22.png

Destination

Step 2: Mask out the spotlight from the black rectangle

  1. At the end of the init block, use the DST_OUT compositing mode to mask out the spotlight from the black rectangle.
shaderPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT)
canvas.drawBitmap(spotlight, 0.0f, 0.0f, shaderPaint)

edaf021ca6f2f91c.png

Result texture

Step 3: Create the BitmapShader

TileMode

The tiling mode, TileMode, defined in the Shader, specifies how the bitmap drawable is repeated or mirrored in the X and Y directions if the bitmap drawable being used for texture is smaller than the screen. Android provides three different ways to repeat (tile) the bitmap drawable (texture):

  • REPEAT : Repeats the bitmap shader's image horizontally and vertically.
  • CLAMP : The edge colors will be used to fill the extra space outside of the shader's image bounds.
  • MIRROR : The shader's image is mirrored horizontally and vertically.

Sample shader image: 726122d180f4656d.png

Example outputs for the different tilemodes:

Repeat Tilemode

Clamp Tilemode

Mirror Tilemode

  1. In SpotLightImageView, declare a private lateinit class variable of type Shader.
private var shader: Shader
  1. At the end of the init block, create a bitmap shader using the constructor, BitmapShader(). Pass in the texture bitmap, bitmap, and the tiling mode as CLAMP. Clamp tilemode will draw everything outside of the circle with the edge color of the texture, which in this case is black.
shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)

The created shader with the CLAMP tilemode looks similar to the image below, and the edge color, black, is drawn to fill the remaining space.

248ccb2818a7ebf8.png

  1. Add the shader you created above to the paint object. The shader contains the texture and instructions (like tiling) on how to apply the texture,.
paint.shader = shader
  1. The completed init block should now look like the code below.
init {
   val bitmap = Bitmap.createBitmap(spotlight.width, spotlight.height, Bitmap.Config.ARGB_8888)
   val canvas = Canvas(bitmap)
   val shaderPaint = Paint(Paint.ANTI_ALIAS_FLAG)

   // Draw a black rectangle.
   shaderPaint.color = Color.BLACK
   canvas.drawRect(0.0f, 0.0f, spotlight.width.toFloat(), spotlight.height.toFloat(), shaderPaint)

   // Use the DST_OUT compositing mode to mask out the spotlight from the black rectangle.
   shaderPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT)
   canvas.drawBitmap(spotlight, 0.0f, 0.0f, shaderPaint)

   shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
   paint.shader = shader
}

Your bitmap shader is ready to use, and you will learn how to use it in a later task.

In this task you will update the app so you can see the texture you created in the previous task. You will also experiment with tiling modes. This task is optional, and code you add in this task should be reverted or commented out when you are done.

Step 1: Draw the texture

  1. In SpotLightImageView, override the onDraw() method.
  2. In onDraw(), color the background yellow using the canvas.
  3. Draw a rectangle of the same size as the texture using the Paint object with the Shader. This should draw the texture once.
override fun onDraw(canvas: Canvas) {
   super.onDraw(canvas)

   // Color the background yellow.
   canvas.drawColor(Color.YELLOW)
   canvas.drawRect(0.0f, 0.0f,spotlight.width.toFloat(), spotlight.height.toFloat(), paint)
}
  1. Run the app. Notice how the bitmap texture you created is drawn in the top left corner of the screen with a yellow background.

ca4c2adaefacca12.png

Step 2: Experiment with tiling modes

If the size of the object being drawn (like the rectangle in the above step) is larger than the texture, which is usually the case. You can tile the bitmap texture in different ways - CLAMP, REPEAT, and MIRROR. The tiling mode for the shader you created in the previous task is CLAMP, since you only want to draw the spotlight once and fill in the rest with black.

To see the shader you created in action:

  1. In SpotLightImageView, inside the onDraw() method, update the drawRect() method call. Draw the rectangle of the size of the screen.
override fun onDraw(canvas: Canvas) {
   super.onDraw(canvas)

   // Color the background Yellow.
   canvas.drawColor(Color.YELLOW)
   // canvas.drawRect(0.0f, 0.0f,spotlight.width.toFloat(), spotlight.height.toFloat(), paint)
   canvas.drawRect(0.0f, 0.0f, width.toFloat(), height.toFloat(), paint)
}
  1. Run the app. Notice that the spotlight texture is drawn only once, and the rest of the rectangle is filled with the edge color, black.

2795355b2a1fce32.png

  1. Experiment with different tiling modes in X and Y directions.

X = TileMode.REPEATY = TileMode.CLAMP

X = TileMode.CLAMPY = TileMode.REPEAT

X = TileMode.REPEATY = TileMode.REPEAT

Step 3: Translate Shader matrix

In this step you learn how to translate the texture (shader) to any location on the screen and draw it.

Matrix translation

When the user touches and holds the screen for the spotlight, instead of calculating where the spotlight needs to be drawn, you move the shader matrix; that is, the texture/shader coordinate system, and then draw the texture (the spotlight) at the same location in the translated coordinate system. The resulting effect will seem as if you are drawing the spotlight texture at a different location, which is the same as the shader matrix translated location. This is simpler and slightly more efficient.

  1. In SpotLightImageView, create a class variable of type Matrix and initialize it.
private val shaderMatrix = Matrix()
  1. Update the BitmapShader in the init block. Change the tile mode to CLAMP in the X and Y directions. This will draw the texture only once, which makes observing the Shader matrix translation straightforward.
shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
  1. Inside the onDraw() method, before the call to drawRect(), translate the shader matrix to random X and Y values.
  2. Set the shader's local matrix to translated shaderMatrix.
shaderMatrix.setTranslate(
   100f,
   550f
)
shader.setLocalMatrix(shaderMatrix)
  1. Draw some arbitrary shape using the paint you defined previouslyl. This code will draw a rectangle of the size of half the screen.
override fun onDraw(canvas: Canvas) {
       super.onDraw(canvas)
       canvas.drawColor(Color.YELLOW)
       shaderMatrix.setTranslate(
           100f,
           550f
       )
       shader.setLocalMatrix(shaderMatrix)
       canvas.drawRect(0.0f, 0.0f, width.toFloat(), height.toFloat()/2, paint)
   }
  1. Run your app. Try experimenting by translating the shader matrix to different locations, changing the tile modes, and by drawing different shapes and sizes with the paint. Below are some effects you might create.

  1. Revert or comment out the code changes made in this task.

In this task, you will calculate a random location on the screen for the Android image bitmap that the player has to find. You also need a way to calculate whether the user has found the bitmap.

  1. In SpotLightImageView, add a new private method called setupWinnerRect().
  2. Set androidBitmapX and androidBitmapY to random x and y positions that fall inside the screen. Import kotlin.math.floor and kotlin.random.Random, when prompted.
  3. At the end of setupWinnerRect(), initialize winnerRect with a rectangular bounding box that contains the Android image.
private fun setupWinnerRect() {
   androidBitmapX = floor(Random.nextFloat() * (width - bitmapAndroid.width))
   androidBitmapY = floor(Random.nextFloat() * (height - bitmapAndroid.height))

winnerRect = RectF(
   (androidBitmapX),
   (androidBitmapY),
   (androidBitmapX + bitmapAndroid.width),
   (androidBitmapY + bitmapAndroid.height)
)
}

In this task, you will override and implement onSizeChanged() and onDraw() in SpotLightImageView.

Step 1: Override onSizeChanged()

  1. In SpotLightImageView, override onSizeChanged(). Call setupWinnerRect() from it.
override fun onSizeChanged(
       newWidth: Int,
       newHeight: Int,
       oldWidth: Int,
       oldHeight: Int
) {
   super.onSizeChanged(newWidth, newHeight, oldWidth, oldHeight)
   setupWinnerRect()
}

Step 2: Override onDraw()

In this step you will draw the Android image 495f3c01e0b4d742.pngon a white background, using the Paint object with the bitmap shader.

  1. In SpotLightImageView, override onDraw().
  2. In onDraw(), remove the code for showing the texture that you added in a previous task.
  3. In onDraw(), color the canvas white and draw the Android image at the random positions androidBitmapX, androidBitmapY that you calculated in onSizeChanged().
override fun onDraw(canvas: Canvas) {
   super.onDraw(canvas)
   canvas.drawColor(Color.WHITE)
   canvas.drawBitmap(bitmapAndroid, androidBitmapX, androidBitmapY, paint)
}
  1. Run the app. Change the device/emulator screen orientation to restart the activity. Every time the activity restarts, the Android image is displayed in a different random position.

For the game to work, your app needs to detect and respond to the user's motions on the screen. You will translate the spotlight shader matrix in response to user touch events, so that the spotlight follows the user touch.

Step 1: Override and implement the onTouchEvent() method

  1. In SpotLightImageView, override the onTouchEvent() method. Create motionEventX and motionEventY to store the user's touch coordinates.
  2. You need to return a boolean. Since you are handling the motion event, have the method return true.
override fun onTouchEvent(motionEvent: MotionEvent): Boolean {
   val motionEventX = motionEvent.x
   val motionEventY = motionEvent.y
   return true
 }
  1. Just before the return statement, add a when block on motionEvent.action. Add case blocks for MotionEvent.ACTION_DOWN and MotionEvent.ACTION_UP.
when (motionEvent.action) {
   MotionEvent.ACTION_DOWN -> {
       
   }
   MotionEvent.ACTION_UP -> {
      
   }
}
  1. Inside the MotionEvent.ACTION_DOWN -&gt; {} case, set shouldDrawSpotLight to true. Check if gameOver is true, and if so, reset it to false and call setupWinnerRect() to restart the game.
  2. Inside the MotionEvent.ACTION_UP -&gt; {} case, set shouldDrawSpotLight to false. Check whether the spotlight center is inside the winning rectangle.
when (motionEvent.action) {
   MotionEvent.ACTION_DOWN -> {
       shouldDrawSpotLight = true
       if (gameOver) {
           gameOver = false
           setupWinnerRect()
       }
   }
   MotionEvent.ACTION_UP -> {
       shouldDrawSpotLight = false
       gameOver = winnerRect.contains(motionEventX, motionEventY)
   }
}

Step 2: Translate the shader matrix

Matrix translation (refresher)

When the user touches and holds the screen for the spotlight, instead of calculating where the spotlight needs to be drawn, you move the shader matrix.; that is, the texture/shader coordinate system, and then draw the spotlight texture at the same location in the translated coordinate system. The resulting effect appears as if you are drawing the spotlight texture at a different location, which is same as the shader matrix translated location.

  1. In SpotLightImageView, add a new variable to save the shader matrix. Import android.graphics.Matrix, when prompted.
private val shaderMatrix = Matrix()
  1. At the end of the onTouchEvent() method, before the return statement, translate the shaderMatrix to the new position based on the user touch event.
shaderMatrix.setTranslate(
   motionEventX - spotlight.width / 2.0f,
   motionEventY - spotlight.height / 2.0f
)
  1. Set the shader's local matrix to the new shaderMatrix.
shader.setLocalMatrix(shaderMatrix)
  1. Call invalidate() to trigger a call to onDraw(), which redraws the shader in the new position.
  2. The complete method should look like this:
override fun onTouchEvent(motionEvent: MotionEvent): Boolean {
   val motionEventX = motionEvent.x
   val motionEventY = motionEvent.y

   when (motionEvent.action) {
       MotionEvent.ACTION_DOWN -> {
           shouldDrawSpotLight = true
           if (gameOver) {
               // New Game
               gameOver = false
               setupWinnerRect()
           }
       }
       MotionEvent.ACTION_UP -> {
           shouldDrawSpotLight = false
           gameOver = winnerRect.contains(motionEventX, motionEventY)
       }
   }
   shaderMatrix.setTranslate(
       motionEventX - spotlight.width / 2.0f,
       motionEventY - spotlight.height / 2.0f
   )
   shader.setLocalMatrix(shaderMatrix)
   invalidate()
   return true
}
  1. Run your app. Notice the Android image on the white background. Your game app is almost ready. The only implementation missing is the black screen with the spotlight.
  2. Click on the Android image, to simulate that you found the Android and won and the game.
  3. Click elsewhere on the screen to restart the game, and now the Android image will be displayed in a different random location.

450717878b32d32d.png

In this task, you will draw a full-screen dark rectangle with the spotlight using the BitmapShader with the texture you created.

  1. At the end of the onDraw() method, check if gameOver is false. If so, check if shouldDrawSpotLight is true, and if so, draw the full screen rectangle using the paint object with the updated bitmap shader.
  2. If shouldDrawSpotLight is false, color the canvas black.
  3. The complete method should look like this:
override fun onDraw(canvas: Canvas) {
   super.onDraw(canvas)
   canvas.drawColor(Color.WHITE)
   canvas.drawBitmap(bitmapAndroid, androidBitmapX, androidBitmapY, paint)

   if (!gameOver) {
       if (shouldDrawSpotLight) {
           canvas.drawRect(0.0f, 0.0f, width.toFloat(), height.toFloat(), paint)
       } else {
           canvas.drawColor(Color.BLACK)
       }
   }
}
  1. Run your app and GAME ON!
  2. After you win, tap the screen to play again.

In this task, you will add an alert dialog with instructions on how to play the game.

  1. In MainActivity, add a method createInstructionsDialog() to create an AlertDialog. Import androidx.appcompat.app.AlertDialog, when prompted.
private fun createInstructionsDialog(): Dialog {
   val builder = AlertDialog.Builder(this)
   builder.setIcon(R.drawable.android)
           .setTitle(R.string.instructions_title)
           .setMessage(R.string.instructions)
           .setPositiveButtonIcon(ContextCompat.getDrawable(this, android.R.drawable.ic_media_play))
   return builder.create()
}
  1. In MainActivity, at the end of the onCreate() method, display the alert dialog.
val dialog = createInstructionsDialog()
dialog.show()
  1. Run your app. You should see a dialog with instructions and a play button. Tap the play button and play the game.

a1404474c68d70ac.png

Download the code for the finished codelab.

$  git clone https://github.com/googlecodelabs/android-drawing-shaders

Alternatively you can download the repository as a Zip file, unzip it, and open it in Android Studio.

Download Zip

  • In Android, Shader defines the color(s) or the texture with which the Paint object should draw (other than a bitmap).
  • Android defines several subclasses of Shader for Paint to use, such as BitmapShader, ComposeShader, LinearGradient, RadialGradient, SweepGradient.
  • A Shader defines the content for a Paint object which should be drawn. A subclass of Shader is installed in a Paint by calling paint.setShader(shader).
  • Alpha compositing is the process of compositing (or combining) a source image with a destination image to create the appearance of partial or full transparency. The amount of transparency is defined by the alpha channel.
  • BitmapShader draws a bitmap drawable as a texture. The bitmap can be repeated or mirrored by setting the TileMode mode.
  • The tiling mode, TileMode, defined in the Shader, specifies how the bitmap drawable is repeated in the X and Y directiona. Android provides three ways to repeat the bitmap drawable: REPEAT, CLAMP, MIRROR.

Udacity course:

Android developer documentation:

Other:

For links to other codelabs in this course, see the Advanced Android in Kotlin codelabs landing page.