此 Codelab 是“使用 Kotlin 进行高级 Android 开发”课程的一部分。如果您按顺序学习这些 Codelab,您将会充分发掘课程的价值,但并不强制要求这样做。“使用 Kotlin 进行高级 Android 开发”Codelab 着陆页列出了所有课程 Codelab。
简介
在 Android 中,有多种技术可用于在视图中实现自定义 2D 图形和动画。
除了使用可绘制对象之外,您还可以使用 Canvas
类的绘图方法创建 2D 绘图。Canvas
是一个 2D 绘图界面,提供绘图方法。当您的应用需要定期重新绘制自身时,这很有用,因为用户看到的内容会随着时间的推移而变化。在此 Codelab 中,您将学习如何在 View
中显示的画布上创建和绘制。
您可以对画布执行的操作类型包括:
- 用颜色填充整个画布。
- 绘制形状,如
Paint
对象中定义的矩形、弧线和路径。Paint
对象包含有关如何绘制几何图形(如直线、矩形、椭圆形和路径)或文字样式的样式和颜色信息。 - 应用转换,例如转换、缩放或自定义转换。
- 裁剪,即向画布应用形状或路径以定义其可见部分。
您对 Android 绘图的理解如何(超级简化!)
在 Android 中或任何其他现代系统中绘制图像是一个复杂的过程,包括抽象层以及向下到硬件的优化。Android 如何绘制是一个很有趣的主题,关于它编写了哪些代码,它的详细信息不在此 Codelab 的范围内。
在此 Codelab 及其在画布上以全屏视图显示的应用环境中,您可以通过以下方式进行解读。
- 您需要一个视图来显示您绘制的内容。这可以是 Android 系统提供的视图之一。或者,在此 Codelab 中,您将创建一个自定义视图,该视图将用作应用 (
MyCanvasView
) 的内容视图。 - 与所有视图一样,此视图也拥有自己的画布 (
canvas
)。 - 对于在视图的画布上绘制的最基本方法,您可以替换其
onDraw()
方法并在画布上绘制。 - 构建绘图时,您需要缓存之前绘制的内容。缓存数据的方法有几种,一种是在位图 (
extraBitmap
) 中,另一种是将绘制内容的历史记录另存为坐标和指令。 - 如需使用 Canvas draw API 绘制到缓存位图 (
extraBitmap
),您需要为缓存位图创建缓存画布 (extraCanvas
)。 - 然后,您可以在缓存画布 (
extraCanvas
) 上绘制,该画布会利用您的缓存位图 (extraBitmap
)。 - 要显示屏幕上绘制的所有内容,您需要指示视图的画布 (
canvas
) 绘制缓存位图 (extraBitmap
)。
您应当已掌握的内容
- 如何创建具有 activity 和基本布局的应用,以及如何使用 Android Studio 运行该应用。
- 如何将事件处理程序与视图相关联。
- 如何创建自定义视图。
学习内容
- 如何创建
Canvas
并在其上响应用户触摸进行绘制。
您将执行的操作
- 创建一个在用户触摸屏幕时在屏幕上绘制线条的应用。
- 捕获动作事件,然后响应在屏幕上以全屏自定义视图形式显示的画布上绘制线条。
MiniPaint 应用使用自定义视图来显示线条以响应用户触摸,如以下屏幕截图所示。
第 1 步:创建 MiniPaint 项目
- 创建一个使用 Empty Activity 模板的名为 MiniPaint 的新 Kotlin 项目。
- 打开
app/res/values/colors.xml
文件并添加以下两个颜色。
<color name="colorBackground">#FFFF5500</color>
<color name="colorPaint">#FFFFEB3B</color>
- 打开
styles.xml
- 在指定
AppTheme
样式的父样式中,将DarkActionBar
替换为NoActionBar
。此操作会移除操作栏,以便您绘制全屏。
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
第 2 步:创建 MyCanvasView 类
在此步骤中,您将为绘图创建自定义视图 MyCanvasView
。
- 在
app/java/com.example.android.minipaint
软件包中,创建一个名为MyCanvasView
的 New > Kotlin File/Class。 - 使
MyCanvasView
类扩展View
类并传入context: Context
。接受建议的导入。
import android.content.Context
import android.view.View
class MyCanvasView(context: Context) : View(context) {
}
第 3 步:将 MyCanvasView 设置为内容视图
如需在 MyCanvasView
中显示要绘制的内容,您必须将其设置为 MainActivity
的内容视图。
- 打开
strings.xml
并定义一个用于视图内容说明的字符串。
<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()
中,删除setContentView(R.layout.activity_main)
。 - 创建
MyCanvasView
的实例。
val myCanvasView = MyCanvasView(this)
- 在它下面,请求全屏显示
myCanvasView
的布局。为此,您可以对myCanvasView
设置SYSTEM_UI_FLAG_FULLSCREEN
标志。这样,视图就会填满整个屏幕。
myCanvasView.systemUiVisibility = SYSTEM_UI_FLAG_FULLSCREEN
- 添加内容说明。
myCanvasView.contentDescription = getString(R.string.canvasContentDescription)
- 然后,将内容视图设置为
myCanvasView
。
setContentView(myCanvasView)
- 运行应用。您会看到一个完全白色的屏幕,因为画布没有大小,而且您尚未绘制任何内容。
第 1 步:重写 onSizeChanged()
每当视图大小发生变化时,Android 系统都会调用 onSizeChanged()
方法。由于该视图最初没有大小,因此还会在 Activity 首次创建并扩充该视图后调用其 onSizeChanged()
方法。因此,此 onSizeChanged()
方法是创建并设置视图画布的理想位置。
- 在
MyCanvasView
中的类级别上,为画布和位图定义变量。分别调用extraCanvas
和extraBitmap
。这些是用于缓存之前绘制内容的位图和画布。
private lateinit var extraCanvas: Canvas
private lateinit var extraBitmap: Bitmap
- 为画布背景颜色定义类级变量
backgroundColor
,并将其初始化为您之前定义的colorBackground
。
private val backgroundColor = ResourcesCompat.getColor(resources, R.color.colorBackground, null)
- 在
MyCanvasView
中,替换onSizeChanged()
方法。此回调方法由 Android 系统调用,并更改屏幕尺寸,即使用新的宽度和高度(要更改为)和旧的宽度和高度(要更改)。
override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
super.onSizeChanged(width, height, oldWidth, oldHeight)
}
- 在
onSizeChanged()
中,使用新的宽度和高度(即屏幕尺寸)创建一个Bitmap
实例,并将其分配给extraBitmap
。第三个参数是位图颜色配置。ARGB_8888
将每种颜色存储在 4 个字节中,因此建议使用该颜色。
extraBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
- 通过
extraBitmap
创建一个Canvas
实例并将其分配给extraCanvas
。
extraCanvas = Canvas(extraBitmap)
- 指定要填充
extraCanvas
的背景颜色。
extraCanvas.drawColor(backgroundColor)
- 对于
onSizeChanged()
,每当函数执行时都会创建一个新的位图和画布。由于大小已变更,您需要新的位图。不过,这是内存泄漏,留下了旧的位图。为修复此问题,请在调用super
后立即添加此代码,以回收extraBitmap
,然后再创建下一个。
if (::extraBitmap.isInitialized) extraBitmap.recycle()
第 2 步:替换 onDraw()
MyCanvasView
的所有绘制工作都发生在 onDraw()
中。
首先,显示您在画布中设置的背景颜色,并在画布中填充您在 onSizeChanged()
中设置的背景颜色。
- 替换
onDraw()
并在与视图关联的画布上绘制缓存的extraBitmap
的内容。drawBitmap()
Canvas
方法有多个版本。在该代码中,您需要提供位图、左上角的 x 坐标和 y 坐标(以像素为单位),并为Paint
提供null
(因为您稍后需要设置)。
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawBitmap(extraBitmap, 0f, 0f, null)
}
请注意,传递给 onDraw()
且系统用来显示位图的画布与您在 onSizeChanged()
方法中创建的画布用来在位图上绘图。
- 运行应用。您应该会看到指定屏幕背景颜色填充到整个屏幕上。
为了进行绘制,您需要一个指定绘制对象样式的 Paint
对象,以及一个指定绘制对象的 Path
。
第 1 步:初始化绘制对象
- 在 MyCanvasView.kt 中,在顶层文件级别定义描边宽度常量。
private const val STROKE_WIDTH = 12f // has to be float
- 在
MyCanvasView
的类级别上,定义一个变量drawColor
,用于存放要绘制的颜色,并使用您之前定义的colorPaint
资源对其进行初始化。
private val drawColor = ResourcesCompat.getColor(resources, R.color.colorPaint, null)
- 在下面的类级别上,为
Paint
对象添加变量paint
并对其进行初始化,如下所示。
// 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)
}
paint
的color
是您之前定义的drawColor
。isAntiAlias
定义是否应用边缘平滑。将isAntiAlias
设为true
可使绘制的边缘平滑,而不会影响形状。- 当
true
时,isDither
会影响精度高于设备降采样的颜色的方式。例如,抖动是将图片颜色范围缩小到 256(或更少)颜色的最常见方法。 style
用于设置要用笔画完成的绘制类型,实质上是线条。Paint.Style
指定所绘制的基元是填充了、已绘制,还是两者兼有(使用同一颜色)。默认是填充已应用渲染的对象。(“填充”表示形状内部的颜色,“描边”则描绘出其轮廓。)Paint.Join
的strokeJoin
指定线条和曲线段在描边路径上的连接方式。默认值为MITER
。strokeCap
用于将线条末端的形状设为上限。Paint.Cap
用于指定描边线条和路径的开始和结束方式。默认值为BUTT
。strokeWidth
用于指定描边的宽度(以像素为单位)。默认值是细线宽度,很细,因此设置为您之前定义的STROKE_WIDTH
常量。
第 2 步:初始化 Path 对象
Path
是用户绘制内容的路径。
- 在
MyCanvasView
中,添加一个变量path
并使用Path
对象对其进行初始化,以存储跟随用户轻触屏幕时绘制的路径。为Path
导入android.graphics.Path
。
private var path = Path()
第 1 步:响应显示屏上的动作
每当用户轻触屏幕时,系统都会调用视图上的 onTouchEvent()
方法。
- 在
MyCanvasView
中,替换onTouchEvent()
方法以缓存传入event
中x
和y
坐标。然后,使用when
表达式处理以下动作动作事件:在屏幕上触摸、在屏幕上移动和在屏幕上释放触摸。这些是在屏幕上绘制线条感兴趣的事件。对于每种事件类型,请调用实用工具方法,如以下代码所示。如需查看触摸事件的完整列表,请参阅MotionEvent
类文档。
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
和motionTouchEventY
变量,以缓存当前触摸事件的 x 坐标和 y 坐标(MotionEvent
坐标)。将它们初始化为0f
。
private var motionTouchEventX = 0f
private var motionTouchEventY = 0f
- 为
touchStart()
、touchMove()
和touchUp()
这三个函数创建桩。
private fun touchStart() {}
private fun touchMove() {}
private fun touchUp() {}
- 您的代码应该可以构建并运行,但无法看到任何与彩色背景不同的内容。
第 2 步:实现 touchStart()
此方法会在用户首次轻触屏幕时调用。
- 在类级别,添加变量以缓存最新的 x 值和 y 值。用户停止移动并抬起手指后,它们将是下一个路径的起点(要绘制的下一个线段)。
private var currentX = 0f
private var currentY = 0f
- 按以下方式实现
touchStart()
方法。重置path
,移至触摸事件的 x-y 坐标(motionTouchEventX
和motionTouchEventY
),并将currentX
和currentY
分配给该值。
private fun touchStart() {
path.reset()
path.moveTo(motionTouchEventX, motionTouchEventY)
currentX = motionTouchEventX
currentY = motionTouchEventY
}
第 3 步:实现 touchMove()
- 在类级别上,添加一个
touchTolerance
变量并将其设置为ViewConfiguration.get(context).scaledTouchSlop
。
private val touchTolerance = ViewConfiguration.get(context).scaledTouchSlop
使用路径时,无需绘制每个像素,也无需每次都请求刷新显示时。相反,您可以(并且将)插入点之间的路径,从而大幅提升效果。
- 如果手指几乎移动,则无需绘图。
- 如果手指移动的距离小于
touchTolerance
的距离,请勿绘图。 scaledTouchSlop
表示触摸事件在系统认为用户滚动之前可以移动的距离(以像素为单位)。
- 定义
touchMove()
方法。计算行程距离(dx
、dy
),在两点之间创建曲线并将其存储在path
中,更新正在运行的currentX
和currentY
的记录,然后绘制path
。然后,调用invalidate()
以使用更新后的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()
}
此方法更为详细:
- 计算已移动的距离 (
dx, dy
)。 - 如果移动幅度大于触摸公差,请在路径中添加路段。
- 将下一个细分的起点设为此细分的端点。
- 使用
quadTo()
而不是lineTo()
可以创建平滑的线条,而不带拐角。请参阅贝塞尔曲线。 - 调用
invalidate()
以(最终调用onDraw()
)并重新绘制视图。
第 4 步:实现 touchUp()
当用户松开手指时,只需要重置路径,使其不会再次绘制。没有任何绘制,因此不需要失效。
- 实现
touchUp()
方法。
private fun touchUp() {
// Reset the path so it doesn't get drawn again.
path.reset()
}
- 运行您的代码,并使用手指在屏幕上绘制。请注意,如果您旋转设备,屏幕就会被清除,因为绘图状态不会保存。对于此示例应用,这是设计为为用户提供清除屏幕的简单方法。
第 5 步:在素描周围画一帧
当用户在屏幕上绘图时,您的应用会构建路径并将其保存在位图 extraBitmap
中。onDraw()
方法会在视图的画布中显示额外的位图。您可以用 onDraw()
绘图。例如,您可以在绘制位图后绘制形状。
在此步骤中,您将围绕图片的边缘绘制帧。
- 在
MyCanvasView
中,添加一个名为frame
的变量,用于保存Rect
对象。
private lateinit var frame: Rect
- 在
onSizeChanged()
末尾,定义一个边衬区,然后添加代码以使用新的尺寸和边衬区创建将用于帧的Rect
。
// Calculate a rectangular frame around the picture.
val inset = 40
frame = Rect(inset, inset, width - inset, height - inset)
- 在
onDraw()
中,在绘制位图后,绘制一个矩形。
// Draw a frame around the canvas.
canvas.drawRect(frame, paint)
- 运行应用。注意帧。
任务(可选):在路径中存储数据
在当前应用中,绘图信息存储在位图中。尽管这是一个很好的解决方案,但它并非存储绘图信息的唯一方式。绘图历史记录的存储方式取决于相关应用以及您的各种要求。例如,如果您要绘制形状,可以保存形状及其位置和尺寸的列表。对于 MiniPaint 应用,您可以将路径另存为 Path
。下文介绍了尝试实现方法时的总体情况。
- 在
MyCanvasView
中,移除extraCanvas
和extraBitmap
的所有代码。 - 为截至目前的路径和当前绘制的路径添加变量。
// Path representing the drawing so far
private val drawing = Path()
// Path representing what's currently being drawn
private val curPath = Path()
- 在
onDraw()
中,请绘制存储的和当前路径,而不是绘制位图。
// 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 drawing so far
drawing.addPath(curPath)
// Rewind the current path for the next touch
curPath.reset()
- 运行应用,是的,没有任何区别。
下载已完成的 Codelab 的代码。
$ git clone https://github.com/googlecodelabs/android-kotlin-drawing-canvas
另一种方法是,以 Zip 文件的形式下载代码库,将其解压缩,然后在 Android Studio 中打开它。
Udacity 课程:
Android 开发者文档:
Canvas
类Bitmap
类View
类Paint
类Bitmap.config
配置Path
类- 贝塞尔曲线 维基百科页面
- 画布和可绘制对象
- 图形架构系列文章(高级)
- 可绘制对象
- Draw
- onSizeChanged()
MotionEvent
ViewConfiguration.get(context).scaledTouchSlop
此部分列出了在由讲师主导的课程中,学生学习此 Codelab 后可能需要完成的家庭作业。讲师自行决定是否执行以下操作:
- 根据需要布置作业。
- 告知学生如何提交家庭作业。
- 给家庭作业评分。
讲师可以酌情采纳这些建议,并且可以自由布置自己认为合适的任何其他家庭作业。
如果您是在自学此 Codelab,可随时通过这些家庭作业来检测您的知识掌握情况。
回答以下问题
问题 1
使用 Canvas
需要以下哪些组件?请选择所有适用的选项。
▢ Bitmap
▢ Paint
▢ Path
▢ View
问题 2
(一般而言)对 invalidate()
的调用有什么作用?
▢ 使您的应用失效并重启。
▢ 从位图中清除绘图。
▢ 表示不应运行之前的代码。
▢ 告知系统必须重绘屏幕。
问题 3
Canvas
、Bitmap
和 Paint
对象的函数是什么?
▢ 2D 绘图界面、屏幕上显示的位图、绘图的样式信息。
▢ 3D 绘图表面、用于缓存路径的位图、绘图的样式信息。
▢ 2D 绘图表面,屏幕上显示的位图,视图样式。
▢ 绘制信息的缓存、要绘制的位图、绘图的样式信息。
如需查看本课程中其他 Codelab 的链接,请参阅“使用 Kotlin 进行高级 Android 开发”的 Codelab 着陆页。