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.
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:
Paint
object. The Paint
object holds the style and color information about how to draw geometries (such as line, rectangle, oval, and paths), or for example, the typeface of text. 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.
MyCanvasView
).canvas
).onDraw()
method and draw on its canvas. extraBitmap
). Another is to save a history of what you drawn as coordinates and instructions.extraBitmap
) using the canvas drawing API, you create a caching canvas (extraCanvas
) for your caching bitmap. extraCanvas
), which draws onto your caching bitmap (extraBitmap
). canvas
) to draw the caching bitmap (extraBitmap
).Canvas
and draw on it in response to user touch.The MiniPaint app uses a custom view to display a line in response to user touches, as shown in the screenshot below.
app/res/values/colors.xml
file and add the following two colors.<color name="colorBackground">#FFFF5500</color>
<color name="colorPaint">#FFFFEB3B</color>
styles.xml
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">
In this step you create a custom view, MyCanvasView
, for drawing.
app/java/com.example.android.minipaint
package, create a New > Kotlin File/Class called MyCanvasView
.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) {
}
To display what you will draw in MyCanvasView
, you have to set it as the content view of the MainActivity
.
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>
MainActivity.kt
onCreate()
, delete setContentView(R.layout.activity_main)
.MyCanvasView
. val myCanvasView = MyCanvasView(this)
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
myCanvasView.contentDescription = getString(R.string.canvasContentDescription)
myCanvasView
. setContentView(myCanvasView)
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.
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
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)
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)
}
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)
Canvas
instance from extraBitmap
and assign it to extraCanvas
. extraCanvas = Canvas(extraBitmap)
extraCanvas
. extraCanvas.drawColor(backgroundColor)
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()
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()
.
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.
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.
private const val STROKE_WIDTH = 12f // has to be float
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)
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)
}
color
of the paint
is the drawColor
you defined earlier. isAntiAlias
defines whether to apply edge smoothing. Setting isAntiAlias
to true
, smoothes out the edges of what is drawn without affecting the shape.isDither
, when true
, affects how colors with higher-precision than the device are down-sampled. For example, dithering is the most common means of reducing the color range of images down to the 256 (or fewer) colors.style
sets the type of painting to be done to a stroke, which is essentially a line. Paint.Style
specifies if the primitive being drawn is filled, stroked, or both (in the same color). The default is to fill the object to which the paint is applied. ("Fill" colors the inside of shape, while "stroke" follows its outline.)strokeJoin
of Paint.Join
specifies how lines and curve segments join on a stroked path. The default is MITER
.strokeCap
sets the shape of the end of the line to be a cap. Paint.Cap
specifies how the beginning and ending of stroked lines and paths. The default is BUTT
.strokeWidth
specifies the width of the stroke in pixels. The default is hairline width, which is really thin, so it's set to the STROKE_WIDTH
constant you defined earlier.The Path
is the path of what the user is drawing.
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()
The onTouchEvent()
method on a view is called whenever the user touches the display.
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
}
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
touchStart()
, touchMove()
, and touchUp()
.private fun touchStart() {}
private fun touchMove() {}
private fun touchUp() {}
This method is called when the user first touches the screen.
private var currentX = 0f
private var currentY = 0f
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
}
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.
touchTolerance
distance, don't draw.scaledTouchSlop
returns the distance in pixels a touch can wander before the system thinks the user is scrolling.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:
dx, dy
).quadTo()
instead of lineTo()
create a smoothly drawn line without corners. See Bezier Curves.invalidate()
to (eventually call onDraw()
and) redraw the view. 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.
touchUp()
method. private fun touchUp() {
// Reset the path so it doesn't get drawn again.
path.reset()
}
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.
MyCanvasView
, add a variable called frame
that holds a Rect
object.private lateinit var frame: Rect
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)
onDraw()
, after drawing the bitmap, draw a rectangle.// Draw a frame around the canvas.
canvas.drawRect(frame, paint)
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.
MyCanvasView
, remove all the code for extraCanvas
and extraBitmap
. // Path representing the drawing so far
private val drawing = Path()
// Path representing what's currently being drawn
private val curPath = Path()
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)
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()
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.
Canvas
is a 2D drawing surface that provides methods for drawing. Canvas
can be associated with a View
instance that displays it. Paint
object holds the style and color information about how to draw geometries (such as line, rectangle, oval, and paths) and text. onDraw()
and onSizeChanged()
methods. onTouchEvent()
method to capture user touches and respond to them by drawing things.Udacity course:
Android developer documentation:
Canvas
classBitmap
classView
classPaint
classBitmap.config
configurationsPath
classMotionEvent
ViewConfiguration.get(context).scaledTouchSlop
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.
Which of the following components are required for working with a Canvas
? Select all that apply.
▢ Bitmap
▢ Paint
▢ Path
▢ View
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.
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.