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 Android, you have several techniques available for implementing custom 2D graphics and animations in views.

In addition to using drawables, you can create 2D drawings using the drawing methods of the Canvas class. The Canvas is a 2D drawing surface that provides methods for drawing. This is useful when your app needs to regularly redraw itself, because what the user sees changes over time. In this codelab, you learn how to create and draw on a canvas that is displayed in a View.

The types of operations you can perform on a canvas include:

How you can think of Android drawing (super-simplified!)

Drawing in Android or on any other modern system, is a complex process that includes layers of abstractions, and optimizations down to the hardware. How Android draws is a fascinating topic about which much has been written, and its details are beyond the scope of this codelab.

In the context of this codelab, and its app that draws on a canvas for display in a full-screen view, you can think of it in the following way.

  1. You need a view for displaying what you are drawing. This could be one of the views provided by the Android system. Or, In this codelab, you create a custom view that serves as the content view for your app (MyCanvasView).
  2. This view, as all views, comes with its own canvas (canvas).
  3. For the most basic way of drawing on the canvas of a view, you override its onDraw() method and draw on its canvas.
  4. When building drawing, you need to cache what you have drawn before. There are several ways of caching your data, one is in a bitmap (extraBitmap). Another is to save a history of what you drawn as coordinates and instructions.
  5. To draw to your caching bitmap (extraBitmap) using the canvas drawing API, you create a caching canvas (extraCanvas) for your caching bitmap.
  6. You then draw on your caching canvas (extraCanvas), which draws onto your caching bitmap (extraBitmap).
  7. To display everything drawn on the screen, you tell the view's canvas (canvas) to draw the caching bitmap (extraBitmap).

What you should already know

What you'll learn

What you'll do

The MiniPaint app uses a custom view to display a line in response to user touches, as shown in the screenshot below.

Step 1. Create the MiniPaint project

  1. Create a new Kotlin project called MiniPaint that uses the Empty Activity template.
  2. Open the app/res/values/colors.xml file and add the following two colors.
<color name="colorBackground">#FFFF5500</color>
<color name="colorPaint">#FFFFEB3B</color>
  1. Open styles.xml
  2. In the parent of the given AppTheme style, replace DarkActionBar with NoActionBar. This removes the action bar, so that you can draw fullscreen.
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">

Step 2. Create the MyCanvasView class

In this step you create a custom view, MyCanvasView, for drawing.

  1. In the app/java/com.example.android.minipaint package, create a New > Kotlin File/Class called MyCanvasView.
  2. Make the MyCanvasView class extend the View class and pass in the context: Context. Accept the suggested imports.
import android.content.Context
import android.view.View

class MyCanvasView(context: Context) : View(context) {
}

Step 3. Set MyCanvasView as the content view

To display what you will draw in MyCanvasView, you have to set it as the content view of the MainActivity.

  1. Open strings.xml and define a string to use for the view's content description.
<string name="canvasContentDescription">Mini Paint is a simple line drawing app.
   Drag your fingers to draw. Rotate the phone to clear.</string>
  1. Open MainActivity.kt
  2. In onCreate(), delete setContentView(R.layout.activity_main).
  3. Create an instance of MyCanvasView.
val myCanvasView = MyCanvasView(this)
  1. Below that, request the full screen for the layout of myCanvasView. Do this by setting the SYSTEM_UI_FLAG_FULLSCREEN flag on myCanvasView. In this way, the view completely fills the screen.
myCanvasView.systemUiVisibility = SYSTEM_UI_FLAG_FULLSCREEN
  1. Add a content description.
myCanvasView.contentDescription = getString(R.string.canvasContentDescription)
  1. Below that, set the content view to myCanvasView.
setContentView(myCanvasView)
  1. Run your app. You will see a completely white screen, because the canvas has no size and you have not drawn anything yet.

Step 1. Override onSizeChanged()

The onSizeChanged() method is called by the Android system whenever a view changes size. Because the view starts out with no size, the view's onSizeChanged() method is also called after the Activity first creates and inflates it. This onSizeChanged() method is therefore the ideal place to create and set up the view's canvas.

  1. In MyCanvasView, at the class level, define variables for a canvas and a bitmap. Call them extraCanvas and extraBitmap. These are your bitmap and canvas for caching what has been drawn before.
private lateinit var extraCanvas: Canvas
private lateinit var extraBitmap: Bitmap
  1. Define a class level variable backgroundColor for the background color of the canvas and initialize it to the colorBackground you defined earlier.
private val backgroundColor = ResourcesCompat.getColor(resources, R.color.colorBackground, null)
  1. In MyCanvasView, override the onSizeChanged() method. This callback method is called by the Android system with the changed screen dimensions, that is, with a new width and height (to change to) and the old width and height (to change from).
override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
   super.onSizeChanged(width, height, oldWidth, oldHeight)
}
  1. Inside onSizeChanged(), create an instance of Bitmap with the new width and height, which are the screen size, and assign it to extraBitmap. The third argument is the bitmap color configuration. ARGB_8888 stores each color in 4 bytes and is recommended.
extraBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
  1. Create a Canvas instance from extraBitmap and assign it to extraCanvas.
 extraCanvas = Canvas(extraBitmap)
  1. Specify the background color in which to fill extraCanvas.
extraCanvas.drawColor(backgroundColor)
  1. Looking at onSizeChanged(), a new bitmap and canvas are created every time the function executes. You need a new bitmap, because the size has changed. However, this is a memory leak, leaving the old bitmaps around. To fix this, recycle extraBitmap before creating the next one by adding this code right after the call to super.
if (::extraBitmap.isInitialized) extraBitmap.recycle()

Step 2. Override onDraw()

All drawing work for MyCanvasView happens in onDraw().

To start, display the canvas, filling the screen with the background color that you set in onSizeChanged().

  1. Override onDraw() and draw the contents of the cached extraBitmap on the canvas associated with the view. The drawBitmap() Canvas method comes in several versions. In this code, you provide the bitmap, the x and y coordinates (in pixels) of the top left corner, and null for the Paint, as you'll set that later.
override fun onDraw(canvas: Canvas) {
   super.onDraw(canvas)
canvas.drawBitmap(extraBitmap, 0f, 0f, null)
}


Notice that the canvas that is passed to onDraw() and used by the system to display the bitmap is different than the one you created in the onSizeChanged() method and used by you to draw on the bitmap.

  1. Run your app. You should see the whole screen filled with the specified background color.

In order to draw, you need a Paint object that specifies how things are styled when drawn, and a Path that specifies what is being drawn.

Step 1. Initialize a Paint object

  1. In MyCanvasView.kt, at the top file level, define a constant for the stroke width.
private const val STROKE_WIDTH = 12f // has to be float
  1. At the class level of MyCanvasView, define a variable drawColor for holding the color to draw with, and initialize it with the colorPaint resource you defined earlier.
private val drawColor = ResourcesCompat.getColor(resources, R.color.colorPaint, null)
  1. At the class level, below, add a variable paint for a Paint object and initialize it as follows.
// Set up the paint with which to draw.
private val paint = Paint().apply {
   color = drawColor
   // Smooths out edges of what is drawn without affecting shape.
   isAntiAlias = true
   // Dithering affects how colors with higher-precision than the device are down-sampled.
   isDither = true
   style = Paint.Style.STROKE // default: FILL
   strokeJoin = Paint.Join.ROUND // default: MITER
   strokeCap = Paint.Cap.ROUND // default: BUTT
   strokeWidth = STROKE_WIDTH // default: Hairline-width (really thin)
}

Step 2. Initialize a Path object

The Path is the path of what the user is drawing.

  1. In MyCanvasView, add a variable path and initialize it with a Path object to store the path that is being drawn when following the user's touch on the screen. Import android.graphics.Path for the Path.
private var path = Path()

Step 1. Respond to motion on the display

The onTouchEvent() method on a view is called whenever the user touches the display.

  1. In MyCanvasView, override the onTouchEvent() method to cache the x and y coordinates of the passed in event. Then use a when expression to handle motion events for touching down on the screen, moving on the screen, and releasing touch on the screen. These are the events of interest for drawing a line on the screen. For each event type, call a utility method, as shown in the code below. See the MotionEvent class documentation for a full list of touch events.
override fun onTouchEvent(event: MotionEvent): Boolean {
   motionTouchEventX = event.x
   motionTouchEventY = event.y

   when (event.action) {
       MotionEvent.ACTION_DOWN -> touchStart()
       MotionEvent.ACTION_MOVE -> touchMove()
       MotionEvent.ACTION_UP -> touchUp()
   }
   return true
}
  1. At the class level, add the missing motionTouchEventX and motionTouchEventY variables for caching the x and y coordinates of the current touch event (the MotionEvent coordinates). Initialize them to 0f.
private var motionTouchEventX = 0f
private var motionTouchEventY = 0f
  1. Create stubs for the three functions touchStart(), touchMove(), and touchUp().
private fun touchStart() {}

private fun touchMove() {}

private fun touchUp() {}
  1. Your code should build and run, but you won't see anything different from the colored background yet.

Step 2. Implement touchStart()

This method is called when the user first touches the screen.

  1. At the class level, add variables to cache the latest x and y values. After the user stops moving and lifts their touch, these are the starting point for the next path (the next segment of the line to draw).
private var currentX = 0f
private var currentY = 0f
  1. Implement the touchStart() method as follows. Reset the path, move to the x-y coordinates of the touch event (motionTouchEventX and motionTouchEventY), and assign currentX and currentY to that value.
private fun touchStart() {
   path.reset()
   path.moveTo(motionTouchEventX, motionTouchEventY)
   currentX = motionTouchEventX
   currentY = motionTouchEventY
}

Step 3. Implement touchMove()

  1. At the class level, add a touchTolerance variable and set it to ViewConfiguration.get(context).scaledTouchSlop.
private val touchTolerance = ViewConfiguration.get(context).scaledTouchSlop

Using a path, there is no need to draw every pixel and each time request a refresh of the display. Instead, you can (and will) interpolate a path between points for much better performance.

  1. Define the touchMove() method. Calculate the traveled distance (dx, dy), create a curve between the two points and store it in path, update the running currentX and currentY tally, and draw the path. Then call invalidate() to force redrawing of the screen with the updated path.
private fun touchMove() {
   val dx = Math.abs(motionTouchEventX - currentX)
   val dy = Math.abs(motionTouchEventY - currentY)
   if (dx >= touchTolerance || dy >= touchTolerance) {
       // QuadTo() adds a quadratic bezier from the last point,
       // approaching control point (x1,y1), and ending at (x2,y2).
       path.quadTo(currentX, currentY, (motionTouchEventX + currentX) / 2, (motionTouchEventY + currentY) / 2)
       currentX = motionTouchEventX
       currentY = motionTouchEventY
       // Draw the path in the extra bitmap to cache it.
       extraCanvas.drawPath(path, paint)
   }
   invalidate()
}

This method in more detail:

  1. Calculate the distance that has been moved (dx, dy).
  2. If the movement was further than the touch tolerance, add a segment to the path.
  3. Set the starting point for the next segment to the endpoint of this segment.
  4. Using quadTo() instead of lineTo() create a smoothly drawn line without corners. See Bezier Curves.
  5. Call invalidate() to (eventually call onDraw() and) redraw the view.

Step 4: Implement touchUp()

When the user lifts their touch, all that is needed is to reset the path so it does not get drawn again. Nothing is drawn, so no invalidation is needed.

  1. Implement the touchUp() method.
private fun touchUp() {
   // Reset the path so it doesn't get drawn again.
   path.reset()
}
  1. Run your code and use your finger to draw on the screen. Notice that if you rotate the device, the screen is cleared, because the drawing state is not saved. For this sample app, this is by design, to give the user a simple way to clear the screen.

Step 5: Draw a frame around the sketch

As the user draws on the screen, your app constructs the path and saves it in the bitmap extraBitmap. The onDraw() method displays the extra bitmap in the view's canvas. You can do more drawing in onDraw(). For example, you could draw shapes after drawing the bitmap.

In this step you draw a frame around the edge of the picture.

  1. In MyCanvasView, add a variable called frame that holds a Rect object.
private lateinit var frame: Rect
  1. At the end of onSizeChanged() define an inset, and add code to create the Rect that will be used for the frame, using the new dimensions and the inset.
// Calculate a rectangular frame around the picture.
val inset = 40
frame = Rect(inset, inset, width - inset, height - inset)
  1. In onDraw(), after drawing the bitmap, draw a rectangle.
// Draw a frame around the canvas.
canvas.drawRect(frame, paint)
  1. Run your app. Notice the frame.

Task (optional): Storing data in a Path

In the current app, the drawing information is stored in a bitmap. While this is a good solution, it is not the only possible way for storing drawing information. How you store your drawing history depends on the app, and your various requirements. For example, if you are drawing shapes, you could save a list of shapes with their location and dimensions. For the MiniPaint app, you could save the path as a Path. Below is the general outline on how to do that, if you want to try it.

  1. In MyCanvasView, remove all the code for extraCanvas and extraBitmap.
  2. Add variables for the path so far, and the path being drawn currently.
// Path representing the drawing so far
private val drawing = Path()

// Path representing what's currently being drawn
private val curPath = Path()
  1. In onDraw(), instead of drawing the bitmap, draw the stored and current paths.
// Draw the drawing so far
canvas.drawPath(drawing, paint)
// Draw any current squiggle
canvas.drawPath(curPath, paint)
// Draw a frame around the canvas
canvas.drawRect(frame, paint)
  1. In touchUp(), add the current path to the previous path and reset the current path.
// Add the current path to the drawing so far
drawing.addPath(curPath)
// Rewind the current path for the next touch
curPath.reset()
  1. Run your app, and yes, there should be no difference whatsoever.

Download the code for the finished codelab..

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


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:

This section lists possible homework assignments for students who are working through this codelab as part of a course led by an instructor. It's up to the instructor to do the following:

Instructors can use these suggestions as little or as much as they want, and should feel free to assign any other homework they feel is appropriate.

If you're working through this codelab on your own, feel free to use these homework assignments to test your knowledge.

Answer these questions

Question 1

Which of the following components are required for working with a Canvas? Select all that apply.

Bitmap

Paint

Path

View

Question 2

What does a call to invalidate() do (in general terms)?

▢ Invalidates and restarts your app.

▢ Erases the drawing from the bitmap.

▢ Indicates that the previous code should not be run.

▢ Tells the system that it has to redraw the screen.

Question 3

What is the function of the Canvas, Bitmap, and Paint objects?

▢ 2D drawing surface, bitmap displayed on the screen, styling information for drawing.

▢ 3D drawing surface, bitmap for caching the path, styling information for drawing.

▢ 2D drawing surface, bitmap displayed on the screen, styling for the view.

▢ Cache for drawing information, bitmap to draw on, styling information for drawing.

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