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.

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:

What you'll learn

What you'll do

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

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:

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.

  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.

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

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.

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 icon 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.

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:

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.

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)

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)

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):

Sample shader image:

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.

  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.

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.

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

X = TileMode.REPEAT

Y = TileMode.CLAMP

X = TileMode.CLAMP

Y = TileMode.REPEAT

X = TileMode.REPEAT

Y = 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 on 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 -> {} 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 -> {} 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.

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.

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

Udacity course:

Android developer documentation:

Other:

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