在 Canvas 物件上繪圖

這個程式碼研究室是「Android Kotlin 進階功能」課程的一部分。如果您按部就班完成每一堂程式碼研究室課程,就能充分體驗到本課程的價值,但這不是強制要求。如要查看所有課程程式碼研究室,請前往 Android Kotlin 進階功能程式碼研究室登陸頁面

簡介

在 Android 中,您可以使用多種技術,在檢視區塊中實作自訂 2D 圖像和動畫。

除了使用可繪項目,您也可以使用 Canvas 類別的繪圖方法建立 2D 繪圖。Canvas 是 2D 繪圖表面,提供繪圖方法。如果應用程式需要定期重新繪製自身內容 (因為使用者看到的內容會隨時間改變),這項功能就很有用。在本程式碼研究室中,您將瞭解如何建立畫布並在畫布上繪圖,然後在 View 中顯示畫布。

您可以在畫布上執行的作業類型包括:

  • 將整個畫布填滿顏色。
  • 繪製形狀,例如矩形、弧形和路徑,樣式則定義於 Paint 物件中。Paint 物件會保存幾何圖形 (例如線條、矩形、橢圓和路徑) 的繪製樣式和顏色資訊,或是文字的字體。
  • 套用轉換,例如翻譯、縮放或自訂轉換。
  • 裁剪:在畫布上套用形狀或路徑,定義可見部分。

Android 繪圖的簡化概念

在 Android 或任何其他新式系統中繪圖是複雜的程序,包括抽象層和硬體最佳化。Android 的繪製方式是個很有趣的主題,相關著作相當多,但詳細內容超出本程式碼研究室的範圍。

在本程式碼研究室的脈絡下,以及在畫布上繪製內容並以全螢幕檢視模式顯示的應用程式中,您可以將其視為下列項目。

  1. 您需要檢視畫面來顯示繪製內容。這可能是 Android 系統提供的其中一個檢視區塊。或者,在本程式碼研究室中,您會建立做為應用程式內容檢視區塊的自訂檢視區塊 (MyCanvasView)。
  2. 這個檢視畫面與所有檢視畫面一樣,都有自己的畫布 (canvas)。
  3. 如要在檢視區塊的畫布上繪圖,最基本的方法是覆寫 onDraw() 方法,並在畫布上繪圖。
  4. 建構繪圖時,您需要快取先前繪製的內容。快取資料的方法有很多種,其中一種是使用點陣圖 (extraBitmap),另一種則是將繪製內容的歷史記錄儲存為座標和指令。
  5. 如要使用畫布繪圖 API 繪製快取點陣圖 (extraBitmap),請為快取點陣圖建立快取畫布 (extraCanvas)。
  6. 接著,您會在快取畫布 (extraCanvas) 上繪圖,這會繪製到快取點陣圖 (extraBitmap) 上。
  7. 如要顯示在畫面上繪製的所有內容,請告知檢視區塊的畫布 (canvas) 繪製快取點陣圖 (extraBitmap)。

必備知識

  • 如何使用 Android Studio 建立含有活動和基本版面配置的應用程式,並執行該應用程式。
  • 如何將事件處理常式與檢視區塊建立關聯。
  • 如何建立自訂檢視畫面。

課程內容

  • 如何建立 Canvas,並在使用者觸控時繪製內容。

學習內容

  • 建立應用程式,讓使用者在觸控螢幕時,螢幕上會繪製線條。
  • 擷取動作事件,並在畫布上繪製線條,這些線條會顯示在螢幕上的全螢幕自訂檢視區塊中。

MiniPaint 應用程式會使用自訂檢視區塊,根據使用者觸控操作顯示線條,如下方螢幕截圖所示。

步驟 1:建立 MiniPaint 專案

  1. 使用「Empty Activity」範本建立名為「MiniPaint」的新 Kotlin 專案。
  2. 開啟 app/res/values/colors.xml 檔案,並新增下列兩種顏色。
<color name="colorBackground">#FFFF5500</color>
<color name="colorPaint">#FFFFEB3B</color>
  1. 開啟「styles.xml
  2. 在指定 AppTheme 樣式的父項中,將 DarkActionBar 替換為 NoActionBar。這會移除動作列,方便您繪製全螢幕。
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">

步驟 2:建立 MyCanvasView 類別

在本步驟中,您會建立用於繪圖的自訂檢視區塊 MyCanvasView

  1. app/java/com.example.android.minipaint 套件中,建立名為 MyCanvasView 的「New」>「Kotlin File/Class」
  2. MyCanvasView 類別擴充 View 類別,並傳入 context: Context。接受建議的匯入項目。
import android.content.Context
import android.view.View

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

步驟 3:將 MyCanvasView 設為內容檢視區塊

如要顯示您在 MyCanvasView 中繪製的內容,必須將其設為 MainActivity 的內容檢視區塊。

  1. 開啟 strings.xml,並定義要用於檢視區塊內容說明的字串。
<string name="canvasContentDescription">Mini Paint is a simple line drawing app.
   Drag your fingers to draw. Rotate the phone to clear.</string>
  1. 開啟「MainActivity.kt
  2. onCreate() 中刪除 setContentView(R.layout.activity_main)
  3. 建立 MyCanvasView 的執行個體。
val myCanvasView = MyCanvasView(this)
  1. 接著,要求 myCanvasView 版面配置進入全螢幕模式。方法是在 myCanvasView 上設定 SYSTEM_UI_FLAG_FULLSCREEN 旗標。這樣一來,檢視畫面就會填滿整個螢幕。
myCanvasView.systemUiVisibility = SYSTEM_UI_FLAG_FULLSCREEN
  1. 新增內容說明。
myCanvasView.contentDescription = getString(R.string.canvasContentDescription)
  1. 接著將內容檢視區塊設為 myCanvasView
setContentView(myCanvasView)
  1. 執行應用程式。您會看到全白的畫面,因為畫布沒有大小,而且您尚未繪製任何內容。

步驟 1:覆寫 onSizeChanged()

每當檢視區塊變更大小時,Android 系統就會呼叫 onSizeChanged() 方法。由於檢視區塊一開始沒有大小,因此在 Activity 首次建立並擴充檢視區塊後,系統也會呼叫檢視區塊的 onSizeChanged() 方法。因此,這個 onSizeChanged() 方法是建立及設定檢視區塊畫布的理想位置。

  1. MyCanvasView 中,於類別層級定義畫布和點陣圖的變數。呼叫 extraCanvasextraBitmap。這是點陣圖和畫布,用於快取先前繪製的內容。
private lateinit var extraCanvas: Canvas
private lateinit var extraBitmap: Bitmap
  1. 定義畫布背景顏色的類別層級變數 backgroundColor,並初始化為您先前定義的 colorBackground
private val backgroundColor = ResourcesCompat.getColor(resources, R.color.colorBackground, null)
  1. MyCanvasView 中,覆寫 onSizeChanged() 方法。Android 系統會呼叫這個回呼方法,並提供變更後的螢幕尺寸,也就是新寬度和高度 (要變更為) 以及舊寬度和高度 (要變更自)。
override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
   super.onSizeChanged(width, height, oldWidth, oldHeight)
}
  1. onSizeChanged() 內,使用新的寬度和高度 (即螢幕大小) 建立 Bitmap 的執行個體,並指派給 extraBitmap。第三個引數是點陣圖顏色設定ARGB_8888 會將每種顏色儲存在 4 個位元組中,建議使用這個格式。
extraBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
  1. extraBitmap 建立 Canvas 例項,並指派給 extraCanvas
 extraCanvas = Canvas(extraBitmap)
  1. 指定要填滿 extraCanvas 的背景顏色。
extraCanvas.drawColor(backgroundColor)
  1. 查看 onSizeChanged(),每次執行函式時都會建立新的點陣圖和畫布。由於大小已變更,因此您需要新的點陣圖。不過,這會造成記憶體外洩,導致舊點陣圖仍存在。如要修正這個問題,請在呼叫 super 後新增下列程式碼,在建立下一個 extraBitmap 前回收 extraBitmap
if (::extraBitmap.isInitialized) extraBitmap.recycle()

步驟 2:覆寫 onDraw()

MyCanvasView 的所有繪圖作業都在 onDraw() 中進行。

首先,請顯示畫布,並以您在 onSizeChanged() 中設定的背景顏色填滿畫面。

  1. 覆寫 onDraw(),並在與檢視區塊相關聯的畫布上繪製快取 extraBitmap 的內容。drawBitmap() Canvas 方法有多個版本。在這段程式碼中,您會提供點陣圖、左上角的 x 和 y 座標 (以像素為單位),以及 null (因為您稍後會設定)。Paint
override fun onDraw(canvas: Canvas) {
   super.onDraw(canvas)
canvas.drawBitmap(extraBitmap, 0f, 0f, null)
}


請注意,傳遞至 onDraw() 並由系統用來顯示點陣圖的畫布,與您在 onSizeChanged() 方法中建立並用來在點陣圖上繪製內容的畫布不同。

  1. 執行應用程式。您應該會看到整個畫面填滿指定的背景顏色。

如要繪製內容,您需要 Paint 物件 (指定繪製時的樣式) 和 Path (指定要繪製的內容)。

步驟 1:初始化 Paint 物件

  1. MyCanvasView.kt 的頂層檔案層級,定義筆觸寬度的常數。
private const val STROKE_WIDTH = 12f // has to be float
  1. MyCanvasView 的類別層級,定義用於保存繪製顏色值的 drawColor 變數,並使用您先前定義的 colorPaint 資源初始化該變數。
private val drawColor = ResourcesCompat.getColor(resources, R.color.colorPaint, null)
  1. 在類別層級下方,新增 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)
}
  • color 是您先前定義的 drawColorpaint
  • isAntiAlias 定義是否要套用邊緣平滑處理。將 isAntiAlias 設為 true 可平滑化繪製內容的邊緣,但不會影響形狀。
  • isDither,當 true 時,會影響顏色向下取樣的精確度 (高於裝置)。舉例來說,最常見的手段是使用半色調技術,將圖片的色域縮減至 256 種 (或更少) 顏色。
  • style 會設定要對筆觸 (本質上是線條) 執行的繪製類型。Paint.Style 指定要繪製的圖元是否要填滿、描邊或兩者皆是 (使用相同顏色)。根據預設,系統會填滿套用顏料的物件。(「填滿」會為形狀內部著色,「筆觸」則會沿著形狀輪廓著色)。
  • strokeJoinPaint.Join 會指定線條和曲線線段在筆觸路徑上的接合方式。預設值為 MITER
  • strokeCap 會將線條的結尾形狀設為端點。Paint.Cap 會指定筆觸線條和路徑的開頭和結尾。預設值為 BUTT
  • strokeWidth 可以像素為單位,指定筆劃的寬度。預設值為髮絲寬度,也就是非常細的線條,因此請將其設為您先前定義的 STROKE_WIDTH 常數。

步驟 2:初始化 Path 物件

Path 是使用者繪製內容的路徑。

  1. MyCanvasView 中新增 path 變數,並使用 Path 物件初始化,以儲存使用者在螢幕上觸控時繪製的路徑。匯入 android.graphics.Path 做為 Path
private var path = Path()

步驟 1:回應螢幕上的動作

每當使用者觸控螢幕時,系統就會呼叫檢視區塊的 onTouchEvent() 方法。

  1. MyCanvasView 中,覆寫 onTouchEvent() 方法,以快取傳入 eventxy 座標。然後使用 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
}
  1. 在類別層級,新增缺少的 motionTouchEventXmotionTouchEventY 變數,用於快取目前觸控事件的 x 和 y 座標 (MotionEvent 座標)。將這些變數初始化為 0f
private var motionTouchEventX = 0f
private var motionTouchEventY = 0f
  1. 為三個函式 touchStart()touchMove()touchUp() 建立存根。
private fun touchStart() {}

private fun touchMove() {}

private fun touchUp() {}
  1. 您的程式碼應會建構及執行,但您目前不會看到任何與彩色背景不同的內容。

步驟 2:實作 touchStart()

使用者第一次觸控螢幕時,系統會呼叫這個方法。

  1. 在類別層級新增變數,以快取最新的 x 和 y 值。使用者停止移動並移開觸控點後,這些點就會成為下一個路徑的起點 (要繪製的下一條線段)。
private var currentX = 0f
private var currentY = 0f
  1. 實作 touchStart() 方法,如下所示。重設 path,移至觸控事件的 x-y 座標 (motionTouchEventXmotionTouchEventY),並將 currentXcurrentY 指派給該值。
private fun touchStart() {
   path.reset()
   path.moveTo(motionTouchEventX, motionTouchEventY)
   currentX = motionTouchEventX
   currentY = motionTouchEventY
}

步驟 3:實作 touchMove()

  1. 在類別層級新增 touchTolerance 變數,並設為 ViewConfiguration.get(context).scaledTouchSlop
private val touchTolerance = ViewConfiguration.get(context).scaledTouchSlop

使用路徑時,不必繪製每個像素,也不必每次都要求重新整理螢幕。您可以 (也應該) 在點之間插補路徑,以大幅提升效能。

  • 如果手指幾乎沒有移動,就不必繪製。
  • 如果手指移動的距離小於 touchTolerance,請勿繪製。
  • scaledTouchSlop 會回傳觸控動作可移動的像素距離,超過這個距離後,系統就會認為使用者正在捲動。
  1. 定義 touchMove() 方法。計算行駛距離 (dxdy)、在兩點之間建立曲線並儲存在 path 中、更新 currentXcurrentY 的累計值,然後繪製 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()
}

這個方法詳情如下:

  1. 計算移動的距離 (dx, dy)。
  2. 如果移動距離超過觸控容差,請在路徑中新增區隔。
  3. 將下一個片段的起點設為這個片段的終點。
  4. 使用 quadTo() 而非 lineTo() 建立平滑的線條,沒有轉角。請參閱「貝茲曲線」。
  5. 呼叫 invalidate() (最終呼叫 onDraw() 並) 重新繪製檢視區塊。

步驟 4:實作 touchUp()

使用者放開觸控筆後,只需重設路徑,即可避免再次繪製。沒有任何繪製內容,因此不需要失效。

  1. 實作 touchUp() 方法。
private fun touchUp() {
   // Reset the path so it doesn't get drawn again.
   path.reset()
}
  1. 執行程式碼,然後用手指在螢幕上繪圖。請注意,如果您旋轉裝置,螢幕會清除內容,因為繪圖狀態不會儲存。就這個範例應用程式而言,這是刻意設計的行為,目的是讓使用者能輕鬆清除畫面。

步驟 5:在草圖周圍繪製框架

使用者在畫面上繪圖時,應用程式會建構路徑並儲存在點陣圖 extraBitmap 中。onDraw() 方法會在檢視區塊的畫布中顯示額外的點陣圖。你可以在 onDraw() 中繪製更多內容。舉例來說,您可以在繪製點陣圖後繪製形狀。

在這個步驟中,您會在圖片邊緣繪製外框。

  1. MyCanvasView 中,新增名為 frame 的變數,該變數會保留 Rect 物件。
private lateinit var frame: Rect
  1. onSizeChanged() 結尾定義插邊,然後新增程式碼,使用新尺寸和插邊建立用於影格的 Rect
// Calculate a rectangular frame around the picture.
val inset = 40
frame = Rect(inset, inset, width - inset, height - inset)
  1. onDraw() 中繪製點陣圖後,請繪製矩形。
// Draw a frame around the canvas.
canvas.drawRect(frame, paint)
  1. 執行應用程式,並注意影格。

工作 (選用):將資料儲存在路徑中

在目前的應用程式中,繪圖資訊會儲存在點陣圖中。雖然這是個不錯的解決方案,但並非儲存繪圖資訊的唯一方式。儲存繪圖記錄的方式取決於應用程式和您的各種需求。舉例來說,如果您正在繪製形狀,可以儲存形狀清單,以及形狀的位置和尺寸。以 MiniPaint 應用程式為例,您可以將路徑儲存為 Path。如要嘗試,請參閱下方的概要說明。

  1. MyCanvasView 中,移除 extraCanvasextraBitmap 的所有程式碼。
  2. 為目前的路徑和目前繪製的路徑新增變數。
// Path representing the drawing so far
private val drawing = Path()

// Path representing what's currently being drawn
private val curPath = Path()
  1. 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)
  1. touchUp() 中,將目前路徑新增至先前路徑,然後重設目前路徑。
// Add the current path to the drawing so far
drawing.addPath(curPath)
// Rewind the current path for the next touch
curPath.reset()
  1. 執行應用程式,您會發現兩者完全相同。

下載完成的程式碼研究室程式碼。

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


您也可以將存放區下載為 ZIP 檔案、將其解壓縮,並在 Android Studio 中開啟。

下載 ZIP 檔

  • Canvas 是 2D 繪圖表面,提供繪圖方法。
  • Canvas 可以與顯示該項目的 View 執行個體建立關聯。
  • Paint 物件會保存幾何圖形 (例如線條、矩形、橢圓和路徑) 和文字的繪製樣式和顏色資訊。
  • 使用畫布的常見模式是建立自訂檢視區塊,並覆寫 onDraw()onSizeChanged() 方法。
  • 覆寫 onTouchEvent() 方法,擷取使用者觸控動作,並透過繪製項目來回應。
  • 您可以使用額外的點陣圖,快取隨時間變化的繪圖資訊。或者,您也可以儲存形狀或路徑。

Udacity 課程:

Android 開發人員說明文件:

本節列出的作業可由課程講師指派給學習本程式碼研究室的學員。講師可自由採取以下行動:

  • 視需要指派作業。
  • 告知學員如何繳交作業。
  • 為作業評分。

講師可以視需求使用全部或部分建議內容,也可以自由指派任何其他合適的作業。

如果您是自行學習本程式碼研究室,不妨利用這些作業驗收學習成果。

回答問題

第 1 題

使用 Canvas 時,下列哪些元件為必要條件?請選取所有適用選項。

Bitmap

Paint

Path

View

第 2 題

呼叫 invalidate() 會執行什麼動作 (一般而言)?

▢ 使應用程式失效並重新啟動。

▢ 從點陣圖中清除繪圖。

▢ 表示不應執行先前的程式碼。

▢ 指示系統重新繪製畫面。

第 3 題

CanvasBitmapPaint 物件的用途為何?

▢ 2D 繪圖表面、螢幕上顯示的點陣圖、繪圖的樣式資訊。

▢ 3D 繪圖表面、用於快取路徑的點陣圖,以及用於繪圖的樣式資訊。

▢ 2D 繪圖介面、螢幕上顯示的點陣圖、檢視區塊的樣式。

▢ 繪圖資訊的快取、要繪製的點陣圖、繪圖的樣式資訊。

如要查看本課程其他程式碼研究室的連結,請參閱 Android Kotlin 進階功能程式碼研究室登陸頁面。