在 Canvas 物件上繪圖

本程式碼研究室是 Kotlin 進階課程的一部分。只要您按部就班完成程式碼研究室,就能發揮本課程的最大效益。不過,您不一定要這麼做。所有課程程式碼研究室清單均列於進階 Android 版的 Kotlin 程式碼研究室到達網頁中。

引言

在 Android 中,您可以透過幾種技術在資料檢視中導入自訂 2D 圖形和動畫。

除了使用 drawables,您還可以使用 Canvas 類別的繪圖方法建立 2D 繪圖。Canvas 是 2D 繪圖介面,可提供繪圖方法。如果應用程式需要定期自行重新整理,這個方法就很實用,因為使用者看到的內容會隨著時間改變。在這個程式碼研究室中,您將瞭解如何在 View 上建立及繪製畫布。

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

  • 讓整個畫布充滿色彩。
  • 繪製形狀,例如矩形、弧形,以及依照 Paint 物件定義的路徑樣式。Paint 物件會保存有關繪製幾何圖形 (例如線條、矩形、橢圓形和路徑) 的樣式和顏色資訊,例如文字的字體。
  • 套用轉換,例如翻譯、縮放或自訂轉換。
  • 剪輯是指對畫布套用形狀或路徑,定義其可見部分。

您對於 Android 繪圖的運用方式 (超級簡化!)

在 Android 或任何其他現代化系統上繪圖時,這個過程相當複雜,其中包含層次抽象層和最佳化的硬體。「Android 繪圖」是令人驚嘆的主題,用來說明撰寫的內容數量,且細節不在本程式碼研究室的涵蓋範圍內。

在這個程式碼研究室和其以畫布上呈現的應用程式上,以全螢幕檢視的方式可以採用以下方法。

  1. 您需要一個用來顯示繪圖的視圖。這可能是 Android 系統提供的其中一種檢視模式。或者,在這個程式碼研究室中,您可以建立自訂檢視,做為應用程式 (MyCanvasView) 的內容檢視。
  2. 這個檢視畫面和所有資料檢視一樣都具備專屬畫布 (canvas)。
  3. 如要在視圖上繪製最基本的方法,您可以覆寫其 onDraw() 方法,並在其畫布上繪圖。
  4. 建立繪圖時,您必須快取先前繪圖的內容。快取資料的方法有很多種,一個是位在點陣圖 (extraBitmap) 中,另一種方式則是將您所繪製的繪圖記錄儲存為座標和指示。
  5. 如要使用 CanvasDraw API 繪圖到快取點陣圖 (extraBitmap),請為您的快取點陣圖建立快取畫布 (extraCanvas)。
  6. 然後,您就可以透過快取畫布 (extraCanvas) 繪圖,進而繪製到快取點陣圖 (extraBitmap) 上。
  7. 如要在螢幕上顯示所有項目,請告知檢視面板 (canvas) 繪製快取點陣圖 (extraBitmap)。

須知事項

  • 如何使用「活動」和基本版面配置建立應用程式,並透過 Android Studio 執行應用程式。
  • 如何將事件處理常式與檢視表建立關聯。
  • 如何建立自訂檢視模式。

課程內容

  • 如何建立 Canvas,並配合使用者觸控操作繪製圖表。

執行步驟

  • 建立應用程式,以便根據使用者輕觸螢幕來繪製線條。
  • 擷取動作事件及做出回應,在畫布上以全螢幕自訂畫面的形式顯示線條。

MiniPaint 應用程式會使用自訂檢視模式,根據使用者的觸控狀態顯示線條,如以下螢幕截圖所示。

步驟 1:建立 MiniPaint 專案

  1. 建立名為 MiniPaint 的新 Kotlin 專案,該專案使用 Empty 活動範本。
  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 套件中,建立名為 New > Kotlin File/Class 並命名為 MyCanvasView
  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() 方法。由於視圖一開始沒有任何大小,因此在「活動」開始建立和內嵌之後,也會呼叫檢視的 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
if (::extraBitmap.isInitialized) extraBitmap.recycle()

步驟 2. 覆寫 onDraw()

MyCanvasView」的所有繪圖工作將於 onDraw()後開始。

如要開始顯示畫布,請先顯示畫布,然後在畫面上顯示您在 onSizeChanged() 中設定的背景顏色。

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


請注意,傳遞至 onDraw() 的系統,以及用來顯示點陣圖的畫布,與您在 onSizeChanged() 方法中建立的點陣圖和在點陣圖中所使用的畫布不同。

  1. 執行您的應用程式。系統應該會顯示以指定背景顏色填滿的整個畫面。

繪製繪圖時,您需要一個 Paint 物件,指定繪圖的樣式,以及一個用來繪製所繪製內容的 Path

步驟 1:初始化繪製物件

  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)
}
  • paintcolor 是您先前定義的 drawColor
  • isAntiAlias 可定義是否要套用邊緣平滑處理功能。將 isAntiAlias 設為 true 可使繪製的邊緣平滑,而不會影響形狀。
  • isDither 會在 true 會影響精確度小於裝置螢幕色彩的色彩品質。例如,將圖片的色彩範圍縮小到 256 (或更低) 是最常見的方法。
  • style 會將繪畫類型設為筆劃,基本上就是線條。Paint.Style 指定繪製的原狀是填充、筆觸,還是兩者皆有 (相同顏色)。預設會填滿套用繪製的物件。(「填充」表示形狀內部色彩,「筆觸」則在外框的外框內填滿)。
  • Paint.JoinstrokeJoin 會指定線條和曲線區隔在筆觸路徑上的合併方式。預設值為 MITER
  • strokeCap 會將線條末端的形狀設定為大寫。Paint.Cap 指定筆觸線條和路徑的起點和結尾方式。預設值為 BUTT
  • strokeWidth 指定筆劃的寬度 (以像素為單位)。預設值為寬度線,這個長度真的很精簡,因此會設為您稍早定義的 STROKE_WIDTH 常數。

步驟 2. 初始化路徑物件

Path 是使用者繪圖的路徑。

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

步驟 1:對螢幕上的動作做出反應

每次使用者輕按螢幕時,系統就會呼叫視圖上的 onTouchEvent() 方法。

  1. MyCanvasView 中,覆寫 onTouchEvent() 方法可快取 event 中傳遞的 xy 座標。然後使用 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 變數,以便快取目前觸控事件 (MotionEvent 座標) 的 X 和 Y 座標。請將其初始化為 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,移至觸控事件 (motionTouchEventXmotionTouchEventY) 的 x-y 座標,並將 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() 可輕鬆建立沒有圓角的平滑線條。請參閱 Bezier Curves
  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 繪圖介面、螢幕上的點陣圖、視圖樣式。

▢ 用於繪圖資訊的快取、可繪製的點陣圖、繪圖的樣式資訊。

如要瞭解本課程中其他程式碼研究室的連結,請參閱 Kotlin 的進階 Android 程式碼研究室到達網頁。