強化 Android 應用程式中的觸控筆支援功能

1. 事前準備

觸控筆是一種筆型工具,可協助使用者執行精確工作。在本程式碼研究室中,您可以瞭解如何使用 android.os 和 androidx 程式庫,打造自然的觸控筆體驗。您也會瞭解如何使用 MotionEvent 類別,支援偵測壓力、傾斜度、方向的功能,以及防手掌誤觸機制。此外,您還會學到如何使用 OpenGL 和 SurfaceView 類別,透過動作預測和低延遲繪圖機制,縮短觸控筆延遲時間。

必要條件

  • 具備 Kotlin 和 lambda 的經驗。
  • 具備使用 Android Studio 的基本知識。
  • 具備 Jetpack Compose 的基本知識。
  • 具備 OpenGL 低延遲繪圖的基本知識。

課程內容

  • 如何使用 MotionEvent 類別支援觸控筆。
  • 如何實作觸控筆功能,包括支援偵測壓力、傾斜度和方向的功能。
  • 如何在 Canvas 類別中繪圖。
  • 如何實作動作預測。
  • 如何使用 OpenGL 和 SurfaceView 類別算繪低延遲圖形。

軟硬體需求

2. 取得範例程式碼

如要取得含有範例應用程式主題設定和基本設定的程式碼,請按照下列步驟操作:

  1. 複製這個 GitHub 存放區:
git clone https://github.com/android/large-screen-codelabs
  1. 開啟 advanced-stylus 資料夾。start 資料夾含有範例程式碼,end 資料夾則含有解決方案程式碼。

3. 實作基本繪圖應用程式

首先,請為基本繪圖應用程式建構必要的版面配置,方便使用者繪圖,接著利用 Canvas Composable 函式在畫面上顯示觸控筆屬性,如下圖所示:

基本繪圖應用程式。上半部用於呈現輸入內容,下半部則用於繪圖。

上半部是 Canvas Composable 函式,用來呈現觸控筆輸入內容,並顯示觸控筆的不同屬性,例如方向、傾斜度和壓力等。下半部則是另一個 Canvas Composable 函式,可接收觸控筆輸入內容,並呈現簡單的筆劃。

如要實作繪圖應用程式的基本版面配置,請按照下列步驟操作:

  1. 在 Android Studio 中開啟複製的存放區。
  2. 依序點選「app」>「java」>「com.example.stylus」,然後按兩下「MainActivity」。MainActivity.kt 檔案會隨即開啟。
  3. 請留意 MainActivity 類別中的 StylusVisualization 和 DrawArea Composable 函式。本節將重點說明 DrawArea Composable 函式。

建立 StylusState 類別

  1. 在同一個 ui 目錄中,依序點選「File」>「New」>「Kotlin/Class file」。
  2. 在文字方塊中,將「Name」預留位置替換為 StylusState.kt,然後按下 Enter 鍵 (macOS 為 return 鍵)。
  3. 在 StylusState.kt 檔案中,建立 StylusState 資料類別,然後新增下表中的變數:

變數

類型

預設值

說明

pressure

Float

0F

介於 0 到 1.0 的值。

orientation

Float

0F

介於 -pi 到 pi 的弧度值。

tilt

Float

0F

介於 0 到 pi/2 的弧度值。

path

Path

Path()

儲存 Canvas Composable 函式使用 drawPath 方法算繪的線條。

StylusState.kt

package com.example.stylus.ui
import androidx.compose.ui.graphics.Path

data class StylusState(
   var pressure: Float = 0F,
   var orientation: Float = 0F,
   var tilt: Float = 0F,
   var path: Path = Path(),
)

顯示方向、傾斜度和壓力指標的資訊主頁檢視畫面

  1. 在 MainActivity.kt 檔案中找出 MainActivity 類別,然後使用 mutableStateOf() 函式新增觸控筆狀態:

MainActivity.kt

import androidx.compose.runtime.setValue
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import com.example.stylus.ui.StylusState

class MainActivity : ComponentActivity() {
    private var stylusState: StylusState by mutableStateOf(StylusState())

DrawPoint 類別

DrawPoint 類別會針對所有繪製在螢幕上的點儲存資料,連結這些點即可建立線條。此類別是模仿 Path 物件的運作方式。

DrawPoint 類別會擴充 PointF 類別,並包含以下資料:

參數

類型

說明

x

Float

協調中心

y

Float

協調中心

type

DrawPointType

點的類型

DrawPoint 物件分為兩種,由 DrawPointType 列舉描述:

類型

說明

START

將線條的開頭移至特定位置。

LINE

從上一個點開始連結線條。

DrawPoint.kt

import android.graphics.PointF
class DrawPoint(x: Float, y: Float, val type: DrawPointType): PointF(x, y)

將資料點算繪為路徑

在這個應用程式中,StylusViewModel 類別會保留線條資料,做好算繪資料的準備,並在 Path 物件上執行作業來支援防手掌誤觸功能。

  • 為保留線條的資料,請在 StylusViewModel 類別中,為 DrawPoint 物件建立可變動清單:

StylusViewModel.kt

import androidx.lifecycle.ViewModel
import com.example.stylus.data.DrawPoint

class StylusViewModel : ViewModel() {private var currentPath = mutableListOf<DrawPoint>()

如要將資料點算繪為路徑,請按照下列步驟操作:

  1. 在 StylusViewModel.kt 檔案的 StylusViewModel 類別中,新增 createPath 函式。
  2. 使用 Path() 建構函式建立類型為 Path 的 path 變數。
  3. 建立 for 迴圈,為 currentPath 變數中的每個資料點執行疊代作業。
  4. 如果資料點類型為 START,請呼叫 moveTo 方法,才能在指定的 x 和 y 座標開始算繪線條。
  5. 否則,請使用資料點的 x 和 y 座標呼叫 lineTo 方法,連結至上一個點。
  6. 傳回 path 物件。

StylusViewModel.kt

import androidx.compose.ui.graphics.Path
import com.example.stylus.data.DrawPoint
import com.example.stylus.data.DrawPointType


class StylusViewModel : ViewModel() {
   private var currentPath = mutableListOf<DrawPoint>()

   private fun createPath(): Path {
      val path = Path()

      for (point in currentPath) {
          if (point.type == DrawPointType.START) {
              path.moveTo(point.x, point.y)
          } else {
              path.lineTo(point.x, point.y)
          }
      }
      return path
   }

private fun cancelLastStroke() {
}

處理 MotionEvent 物件

觸控筆事件來自 MotionEvent 物件,該物件可提供所執行動作的資訊,以及與該動作相關聯的資料,例如指標位置和壓力。下表包含 MotionEvent 物件的一些常數及相關資料,可用於識別使用者在螢幕上執行的動作:

常數

資料

ACTION_DOWN

指標觸碰螢幕。這就是 MotionEvent 物件所回報的線條起始位置。

ACTION_MOVE

指標在螢幕上移動。這就是繪製的線條。

ACTION_UP

指標停止觸碰螢幕。這就是線條的結尾。

ACTION_CANCEL

偵測到不必要的觸碰。這會取消上一個筆劃。

應用程式收到新的 MotionEvent 物件時,畫面應隨之算繪,反映新的使用者輸入內容。

  • 如要處理 StylusViewModel 類別中的 MotionEvent 物件,請建立收集線條座標的函式:

StylusViewModel.kt

import android.view.MotionEvent

class StylusViewModel : ViewModel() {
   private var currentPath = mutableListOf<DrawPoint>()

   ...

   fun processMotionEvent(motionEvent: MotionEvent): Boolean {
      when (motionEvent.actionMasked) {
          MotionEvent.ACTION_DOWN -> {
              currentPath.add(
                  DrawPoint(motionEvent.x, motionEvent.y, DrawPointType.START)
              )
          }
          MotionEvent.ACTION_MOVE -> {
              currentPath.add(DrawPoint(motionEvent.x, motionEvent.y, DrawPointType.LINE))
          }
          MotionEvent.ACTION_UP -> {
              currentPath.add(DrawPoint(motionEvent.x, motionEvent.y, DrawPointType.LINE))
          }
          MotionEvent.ACTION_CANCEL -> {
              // Unwanted touch detected.
              cancelLastStroke()
          }
          else -> return false
      }
   
      return true
   }

將資料傳送至 UI

如要更新 StylusViewModel 類別,讓 UI 收集 StylusState 資料類別的變更,請按照下列步驟操作:

  1. 在 StylusViewModel 類別中,建立類別為 StylusState 的 MutableStateFlow 類型變數 _stylusState,以及類別為 StylusState 的 StateFlow 類型變數 stylusState。每當 StylusViewModel 類別中的觸控筆狀態有所變更,且 MainActivity 類別中的 UI 使用 stylusState 變數時,系統就會修改 _stylusState 變數。

StylusViewModel.kt

import com.example.stylus.ui.StylusState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow

class StylusViewModel : ViewModel() {

   private var _stylusState = MutableStateFlow(StylusState())
   val stylusState: StateFlow<StylusState> = _stylusState
  1. 建立可接受 StylusState 物件參數的 requestRendering 函式:

StylusViewModel.kt

import kotlinx.coroutines.flow.update

...

class StylusViewModel : ViewModel() {
   private var _stylusState = MutableStateFlow(StylusState())
   val stylusState: StateFlow<StylusState> = _stylusState

   ...
   
    private fun requestRendering(stylusState: StylusState) {
      // Updates the stylusState, which triggers a flow.
      _stylusState.update {
          return@update stylusState
      }
   }
  1. 在 processMotionEvent 函式的結尾,加入具有 StylusState 參數的 requestRendering 函式呼叫。
  2. 在 StylusState 參數中,從 motionEvent 變數擷取傾斜度、壓力和方向值,然後使用 createPath() 函式建立路徑。這會觸發流程事件,您稍後會在 UI 中連結該事件。

StylusViewModel.kt

...

class StylusViewModel : ViewModel() {

   ...

   fun processMotionEvent(motionEvent: MotionEvent): Boolean {

      ...
         else -> return false
      }


      requestRendering(
         StylusState(
             tilt = motionEvent.getAxisValue(MotionEvent.AXIS_TILT),
             pressure = motionEvent.pressure,
             orientation = motionEvent.orientation,
             path = createPath()
         )
      )
  1. 在 MainActivity 類別中,找出 onCreate 函式的 super.onCreate 函式,然後新增狀態收集作業。如要進一步瞭解狀態收集作業,請觀看「以生命週期感知方式收集流程」影片。

MainActivity.kt

import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.flow.onEach
import androidx.lifecycle.Lifecycle
import kotlinx.coroutines.flow.collect

...
class MainActivity : ComponentActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
      super.onCreate(savedInstanceState)

      lifecycleScope.launch {
          lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
              viewModel.stylusState
                  .onEach {
                      stylusState = it
                  }
                  .collect()
          }
      }

現在,每當 StylusViewModel 類別發布新的 StylusState 狀態,活動就會收到內容,而新的 StylusState 物件則會更新本機 MainActivity 類別的 stylusState 變數。

  1. 在 DrawArea Composable 函式的主體中,將 pointerInteropFilter 修飾符新增至 Canvas Composable 函式,藉此提供 MotionEvent 物件。
  1. 將 MotionEvent 物件傳送至 StylusViewModel 的 processMotionEvent 函式進行處理:

MainActivity.kt

import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.input.pointer.pointerInteropFilter

...
class MainActivity : ComponentActivity() {

   ...

@Composable
@OptIn(ExperimentalComposeUiApi::class)
fun DrawArea(modifier: Modifier = Modifier) {
   Canvas(modifier = modifier
       .clipToBounds()

 .pointerInteropFilter {
              viewModel.processMotionEvent(it)
          }

   ) {

   }
}
  1. 使用 stylusState path 屬性呼叫 drawPath 函式,然後提供顏色和筆劃樣式。

MainActivity.kt

class MainActivity : ComponentActivity() {

...

   @Composable
   @OptIn(ExperimentalComposeUiApi::class)
   fun DrawArea(modifier: Modifier = Modifier) {
      Canvas(modifier = modifier
          .clipToBounds()
          .pointerInteropFilter {
              viewModel.processMotionEvent(it)
          }
      ) {
          with(stylusState) {
              drawPath(
                  path = this.path,
                  color = Color.Gray,
                  style = strokeStyle
              )
          }
      }
   }
  1. 執行應用程式,就會發現您可以在螢幕上繪圖。

4. 實作壓力、方向和傾斜度支援功能

在上一節中,您已瞭解如何從 MotionEvent 物件擷取觸控筆資訊,例如壓力、方向和傾斜度。

StylusViewModel.kt

tilt = motionEvent.getAxisValue(MotionEvent.AXIS_TILT),
pressure = motionEvent.pressure,
orientation = motionEvent.orientation,

不過,這個快速指令只適用於第一個指標。如果偵測到多點觸控,系統就會偵測到多個指標,而且這個快速指令只會傳回第一個指標的值,或是螢幕上的第一個指標。如果想要求特定指標的資料,您可以使用 pointerIndex 參數:

StylusViewModel.kt

tilt = motionEvent.getAxisValue(MotionEvent.AXIS_TILT, pointerIndex),
pressure = motionEvent.getPressure(pointerIndex),
orientation = motionEvent.getOrientation(pointerIndex)

如要進一步瞭解指標和多點觸控,請參閱「處理多點觸控手勢」。

新增壓力、方向和傾斜度的視覺化效果

  1. 在 MainActivity.kt 檔案中找出 StylusVisualization Composable 函式,然後使用 StylusState 流程物件的資訊算繪視覺化效果:

MainActivity.kt

import StylusVisualization.drawOrientation
import StylusVisualization.drawPressure
import StylusVisualization.drawTilt
...
class MainActivity : ComponentActivity() {

   ...

   @Composable
   fun StylusVisualization(modifier: Modifier = Modifier) {
      Canvas(
          modifier = modifier
      ) {
          with(stylusState) {
              drawOrientation(this.orientation)
              drawTilt(this.tilt)
              drawPressure(this.pressure)
          }
      }
   }
  1. 執行應用程式。畫面頂端會顯示三個指標,分別代表方向、壓力和傾斜度。
  2. 使用觸控筆在螢幕上塗鴉,觀察系統如何呈現輸入內容的視覺化效果。

使用觸控筆寫出「hello」一詞時所呈現的方向、壓力和傾斜度視覺化效果

  1. 檢查 StylusVisualization.kt 檔案,瞭解每個視覺化效果的建構方式。

5. 實作防手掌誤觸功能

螢幕可能會註冊不必要的觸控動作,例如使用者在手寫時自然地將手靠在螢幕上,就會發生這種情況。

防手掌誤觸機制可偵測這種行為,並通知開發人員取消最後一組 MotionEvent 物件。這種 MotionEvent 物件組合的開頭為 ACTION_DOWN 常數。

也就是說,您必須保留輸入內容記錄,才能從螢幕上移除不必要的觸控內容,並重新算繪正確的使用者輸入內容。幸好,您已將這類記錄儲存在 StylusViewModel 類別中的 currentPath 變數。

Android 提供來自 MotionEvent 物件的 ACTION_CANCEL 常數,可通知開發人員有不必要的觸控動作。自 Android 13 起,MotionEvent 物件提供的 FLAG_CANCELED 常數應在 ACTION_POINTER_UP 常數上檢查。

實作 cancelLastStroke 函式

  • 如要從最後一個 START 資料點中移除資料點,請返回 StylusViewModel 類別,然後建立 cancelLastStroke 函式,找出最後一個 START 資料點的索引,並僅保留從第一個資料點到索引減一資料點的資料:

StylusViewModel.kt

...
class StylusViewModel : ViewModel() {
    ...

   private fun cancelLastStroke() {
      // Find the last START event.
      val lastIndex = currentPath.findLastIndex {
          it.type == DrawPointType.START
      }

      // If found, keep the element from 0 until the very last event before the last MOVE event.
      if (lastIndex > 0) {
          currentPath = currentPath.subList(0, lastIndex - 1)
      }
   }

新增 ACTION_CANCEL 和 FLAG_CANCELED 常數

  1. 在 StylusViewModel.kt 檔案中,找到 processMotionEvent 函式。
  2. 在 ACTION_UP 常數中建立 canceled 變數,檢查目前的 SDK 版本是否為 Android 13 以上版本,以及 FLAG_CANCELED 常數是否已啟用。
  3. 在下一行程式碼中建立條件式,檢查 canceled 變數是否為 true。如果為 true,請呼叫 cancelLastStroke 函式,移除最後一組 MotionEvent 物件。如果為 false,請呼叫 currentPath.add 方法,新增最後一組 MotionEvent 物件。

StylusViewModel.kt

import android.os.Build
...
class StylusViewModel : ViewModel() {
    ...
    fun processMotionEvent(motionEvent: MotionEvent): Boolean {
    ...
        MotionEvent.ACTION_POINTER_UP,
        MotionEvent.ACTION_UP -> {
           val canceled = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
           (motionEvent.flags and MotionEvent.FLAG_CANCELED) == MotionEvent.FLAG_CANCELED

           if(canceled) {
               cancelLastStroke()
           } else {
               currentPath.add(DrawPoint(motionEvent.x, motionEvent.y, DrawPointType.LINE))
           }
        }
  1. 在 ACTION_CANCEL 常數中,請留意 cancelLastStroke 函式:

StylusViewModel.kt

...
class StylusViewModel : ViewModel() {
    ...
    fun processMotionEvent(motionEvent: MotionEvent): Boolean {
        ...
        MotionEvent.ACTION_CANCEL -> {
           // unwanted touch detected
           cancelLastStroke()
        }

防手掌誤觸功能已實作完成!您可以在 palm-rejection 資料夾中找到有效程式碼。

6. 實作低延遲設計

在本節中,為改善效能,您將減少使用者輸入內容和畫面算繪作業之間的延遲時間。造成延遲的原因很多,其中一個是長繪圖管線。您可以利用「前緩衝區算繪」縮短繪圖管線。前緩衝區算繪功能可讓開發人員直接存取螢幕緩衝區,提供優質的手寫和素描結果。

androidx.graphics 程式庫提供的 GLFrontBufferedRenderer 類別會處理前緩衝區和雙緩衝區算繪。此類別會最佳化 SurfaceView 物件,使用 onDrawFrontBufferedLayer 回呼函式進行快速算繪,並利用 onDrawDoubleBufferedLayer 回呼函式進行一般算繪。GLFrontBufferedRenderer 類別和 GLFrontBufferedRenderer.Callback 介面運作時,可配合使用者提供的資料類型。在本程式碼研究室中,您會使用 Segment 類別。

如要開始使用,請按照下列步驟操作:

  1. 在 Android Studio 中開啟 low-latency 資料夾,取得所有必要檔案:
  2. 請留意專案中的下列新檔案:
  • 在 build.gradle 檔案中,已使用 implementation "androidx.graphics:graphics-core:1.0.0-alpha03" 宣告匯入 androidx.graphics 程式庫。
  • LowLatencySurfaceView 類別會擴充 SurfaceView 類別,將 OpenGL 程式碼算繪至螢幕畫面。
  • LineRenderer 類別會保留 OpenGL 程式碼,在螢幕上算繪線條。
  • FastRenderer 類別允許快速算繪,且會實作 GLFrontBufferedRenderer.Callback 介面。此類別也會攔截 MotionEvent 物件。
  • StylusViewModel 類別會使用 LineManager 介面保留資料點。
  • Segment 類別會定義線段,如下所示:
  • x1、y1:第一個點的座標
  • x2、y2:第二個點的座標

下圖顯示資料在各類別之間移動的方式:

MotionEvent 是由 LowLatencySurfaceView 擷取,然後傳送到 onTouchListener 進行處理。onTouchListener 會處理並要求使用前緩衝區或雙緩衝區算繪至 GLFrontBufferRenderer。GLFrontBufferRenderer 會算繪至 LowLatencySurfaceView。

建立低延遲介面和版面配置

  1. 在 MainActivity.kt 檔案中,找出 MainActivity 類別的 onCreate 函式。
  2. 在 onCreate 函式的主體中建立 FastRenderer 物件,然後傳入 viewModel 物件:

MainActivity.kt

class MainActivity : ComponentActivity() {
   ...
   override fun onCreate(savedInstanceState: Bundle?) {
      super.onCreate(savedInstanceState)

      fastRendering = FastRenderer(viewModel)

      lifecycleScope.launch {
      ...
  1. 在同一個檔案中,建立 DrawAreaLowLatency Composable 函式。
  2. 在此函式的主體中,使用 AndroidView API 納入 LowLatencySurfaceView 檢視畫面,然後提供 fastRendering 物件:

MainActivity.kt

import androidx.compose.ui.viewinterop.AndroidView
​​import com.example.stylus.gl.LowLatencySurfaceView

class MainActivity : ComponentActivity() {
   ...
   @Composable
   fun DrawAreaLowLatency(modifier: Modifier = Modifier) {
      AndroidView(factory = { context ->
          LowLatencySurfaceView(context, fastRenderer = fastRendering)
      }, modifier = modifier)
   }
  1. 在 Divider Composable 函式後方的 onCreate 函式,將 DrawAreaLowLatency Composable 函式新增至版面配置:

MainActivity.kt

class MainActivity : ComponentActivity() {
   ...
   override fun onCreate(savedInstanceState: Bundle?) {
   ...
   Surface(
      modifier = Modifier
          .fillMaxSize(),
      color = MaterialTheme.colorScheme.background
   ) {
      Column {
          StylusVisualization(
              modifier = Modifier
                  .fillMaxWidth()
                  .height(100.dp)
          )
          Divider(
              thickness = 1.dp,
              color = Color.Black,
          )
          DrawAreaLowLatency()
      }
   }
  1. 在 gl 目錄中開啟 LowLatencySurfaceView.kt 檔案,然後留意 LowLatencySurfaceView 類別中的下列內容:
  • LowLatencySurfaceView 類別會擴充 SurfaceView 類別。這會使用 fastRenderer 物件的 onTouchListener 方法。
  • 呼叫 onAttachedToWindow 函式時,請將透過 fastRenderer 類別提供的 GLFrontBufferedRenderer.Callback 介面附加至 SurfaceView 物件,這樣才能將回呼算繪至 SurfaceView 檢視畫面。
  • 呼叫 onDetachedFromWindow 函式時,請釋放透過 fastRenderer 類別提供的 GLFrontBufferedRenderer.Callback 介面。

LowLatencySurfaceView.kt

class LowLatencySurfaceView(context: Context, private val fastRenderer: FastRenderer) :
   SurfaceView(context) {

   init {
       setOnTouchListener(fastRenderer.onTouchListener)
   }

   override fun onAttachedToWindow() {
       super.onAttachedToWindow()
       fastRenderer.attachSurfaceView(this)
   }

   override fun onDetachedFromWindow() {
       fastRenderer.release()
       super.onDetachedFromWindow()
   }
}

使用 onTouchListener 介面處理 MotionEvent 物件

如要在偵測到 ACTION_DOWN 常數時處理 MotionEvent 物件,請按照下列步驟操作:

  1. 在 gl 目錄中,開啟 FastRenderer.kt 檔案。
  2. 在 ACTION_DOWN 常數的主體中,建立 currentX 變數來儲存 MotionEvent 物件的 x 座標,以及建立可儲存 y 座標的 currentY 變數。
  3. 建立可儲存 Segment 物件的 Segment 變數,該物件是線條的起點,因此可接受各兩個 currentX 參數和 currentY 參數的例項。
  4. 使用 segment 參數呼叫 renderFrontBufferedLayer 方法,觸發對 onDrawFrontBufferedLayer 函式的回呼。

FastRenderer.kt

class FastRenderer ( ... ) {
   ...
   val onTouchListener = View.OnTouchListener { view, event ->
   ...
   MotionEvent.ACTION_DOWN -> {
      // Ask that the input system not batch MotionEvent objects,
      // but instead deliver them as soon as they're available.
      view.requestUnbufferedDispatch(event)

      currentX = event.x
      currentY = event.y

      // Create a single point.
      val segment = Segment(currentX, currentY, currentX, currentY)

      frontBufferRenderer?.renderFrontBufferedLayer(segment)
   }

如要在偵測到 ACTION_MOVE 常數時處理 MotionEvent 物件,請按照下列步驟操作:

  1. 在 ACTION_MOVE 常數的主體中,建立可儲存 currentX 變數的 previousX 變數,以及可儲存 currentY 變數的 previousY 變數。
  2. 建立可儲存 MotionEvent 物件目前 x 座標的 currentX 變數,以及可儲存目前 y 座標的 currentY 變數。
  3. 建立 Segment 變數,用來儲存可接受 previousX、previousY、currentX 和 currentY 參數的 Segment 物件。
  4. 使用 segment 參數呼叫 renderFrontBufferedLayer 方法,觸發對 onDrawFrontBufferedLayer 函式的回呼,並執行 OpenGL 程式碼。

FastRenderer.kt

class FastRenderer ( ... ) {
   ...
   val onTouchListener = View.OnTouchListener { view, event ->
   ...   
   MotionEvent.ACTION_MOVE -> {
      previousX = currentX
      previousY = currentY
      currentX = event.x
      currentY = event.y

      val segment = Segment(previousX, previousY, currentX, currentY)
  
      // Send the short line to front buffered layer: fast rendering
      frontBufferRenderer?.renderFrontBufferedLayer(segment)
   }
  • 如要在偵測到 ACTION_UP 常數時處理 MotionEvent 物件,請呼叫 commit 方法,觸發對 onDrawDoubleBufferedLayer 函式的呼叫,並執行 OpenGL 程式碼:

FastRenderer.kt

class FastRenderer ( ... ) {
   ...
   val onTouchListener = View.OnTouchListener { view, event ->
   ...   
   MotionEvent.ACTION_UP -> {
      frontBufferRenderer?.commit()
   }

實作 GLFrontBufferedRenderer 回呼函式

在 FastRenderer.kt 檔案中,onDrawFrontBufferedLayer 和 onDrawDoubleBufferedLayer 回呼函式會執行 OpenGL 程式碼。在每個回呼函式的開頭,下列 OpenGL 函式會將 Android 資料對應至 OpenGL 工作區:

  • GLES20.glViewport 函式會定義矩形大小,用於算繪畫面。
  • Matrix.orthoM 函式會計算 ModelViewProjection 矩陣。
  • Matrix.multiplyMM 函式會執行矩陣乘法,將 Android 資料轉換為 OpenGL 參照,並提供 projection 矩陣的設定。

FastRenderer.kt

class FastRenderer( ... ) {
    ...
    override fun onDraw[Front/Double]BufferedLayer(
       eglManager: EGLManager,
       bufferInfo: BufferInfo,
       transform: FloatArray,
       params: Collection<Segment>
    ) {
        val bufferWidth = bufferInfo.width
        val bufferHeight = bufferInfo.height

        GLES20.glViewport(0, 0, bufferWidth, bufferHeight)
        // Map Android coordinates to OpenGL coordinates.
        Matrix.orthoM(
           mvpMatrix,
           0,
           0f,
           bufferWidth.toFloat(),
           0f,
           bufferHeight.toFloat(),
           -1f,
           1f
        )

        Matrix.multiplyMM(projection, 0, mvpMatrix, 0, transform, 0)

我們已為您設定好該部分的程式碼,因此您可以專注在執行實際算繪作業的程式碼。onDrawFrontBufferedLayer 回呼函式會算繪畫面中的小區域,並提供類型為 Segment 的 param 值,方便您快速算繪單一線段。LineRenderer 類別是筆刷的 OpenGL 轉譯器,可套用線條的顏色和大小。

如要實作 onDrawFrontBufferedLayer 回呼函式,請按照下列步驟操作:

  1. 在 FastRenderer.kt 檔案中,找到 onDrawFrontBufferedLayer 回呼函式。
  2. 在 onDrawFrontBufferedLayer 回呼函式的主體中,呼叫 obtainRenderer 函式來取得 LineRenderer 例項。
  3. 使用下列參數呼叫 LineRenderer 函式的 drawLine 方法:
  • 先前計算的 projection 矩陣。
  • Segment 物件清單,在本例中為單一線段。
  • 線條的 color。

FastRenderer.kt

import android.graphics.Color
import androidx.core.graphics.toColor

class FastRenderer( ... ) {
...
override fun onDrawFrontBufferedLayer(
   eglManager: EGLManager,
   bufferInfo: BufferInfo,
   transform: FloatArray,
   params: Collection<Segment>
) {
   ...
   
   Matrix.multiplyMM(projection, 0, mvpMatrix, 0, transform, 0)

   obtainRenderer().drawLine(projection, listOf(param), Color.GRAY.toColor())
}
  1. 執行應用程式。您會發現在螢幕上繪圖的延遲時間非常短。不過,您仍然需要實作 onDrawDoubleBufferedLayer 回呼函式,因此應用程式不會保留該線條。

系統會在 commit 函式後方呼叫 onDrawDoubleBufferedLayer 回呼函式,藉此保留該線條。回呼提供 params 值,其中包含一組 Segment 物件。為保留線條,前緩衝區的所有線段會在雙緩衝區中重播。

如要實作 onDrawDoubleBufferedLayer 回呼函式,請按照下列步驟操作:

  1. 在 StylusViewModel.kt 檔案中找出 StylusViewModel 類別,然後建立 openGlLines 變數,用於儲存 Segment 物件的可變動清單:

StylusViewModel.kt

import com.example.stylus.data.Segment

class StylusViewModel : ViewModel() {
    private var _stylusState = MutableStateFlow(StylusState())
    val stylusState: StateFlow<StylusState> = _stylusState

    val openGlLines = mutableListOf<List<Segment>>()

    private fun requestRendering(stylusState: StylusState) {
  1. 在 FastRenderer.kt 檔案中,找出 FastRenderer 類別的 onDrawDoubleBufferedLayer 回呼函式。
  2. 在 onDrawDoubleBufferedLayer 回呼函式的主體中,使用 GLES20.glClearColor 和 GLES20.glClear 方法清除螢幕內容,這樣就能從頭開始算繪畫面。接著,請將線條新增至 viewModel 物件,藉此保留線條:

FastRenderer.kt

class FastRenderer( ... ) {
   ...
   override fun onDrawDoubleBufferedLayer(
      eglManager: EGLManager,
      bufferInfo: BufferInfo,
      transform: FloatArray,
      params: Collection<Segment>
   ) {
      ...
      // Clear the screen with black.
      GLES20.glClearColor(0.0f, 0.0f, 0.0f, 1.0f) 
      GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)

      viewModel.openGlLines.add(params.toList())
  1. 建立 for 迴圈,為 viewModel 物件中的每個線條執行疊代和算繪作業:

FastRenderer.kt

class FastRenderer( ... ) {
   ...
   override fun onDrawDoubleBufferedLayer(
      eglManager: EGLManager,
      bufferInfo: BufferInfo,
      transform: FloatArray,
      params: Collection<Segment>
   ) {
      ...
      // Clear the screen with black.
      GLES20.glClearColor(0.0f, 0.0f, 0.0f, 1.0f) 
      GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)

      viewModel.openGlLines.add(params.toList())

      // Render the entire scene (all lines).
      for (line in viewModel.openGlLines) {
         obtainRenderer().drawLine(projection, line, Color.GRAY.toColor())
      }
   }
  1. 執行應用程式。您會發現您可以在螢幕上繪圖,且線條會在觸發 ACTION_UP 常數後保留。

7. 實作動作預測

您還可以使用 androidx.input 程式庫進一步改善觸控筆的延遲情形。該程式庫會分析觸控筆的軌跡,預測下一個點的位置,並插入該位置進行算繪。

如要設定動作預測功能,請按照下列步驟操作:

  1. 在 app/build.gradle 檔案的依附元件區段中匯入程式庫:

app/build.gradle

...
dependencies {
    ...
    implementation"androidx.input:input-motionprediction:1.0.0-beta01"
  1. 依序點選「File」>「Sync project with Gradle files」。
  2. 在 FastRendering.kt 檔案的 FastRendering 類別中,將 motionEventPredictor 物件宣告為屬性:

FastRenderer.kt

import androidx.input.motionprediction.MotionEventPredictor

class FastRenderer( ... ) {
   ...
   private var frontBufferRenderer: GLFrontBufferedRenderer<Segment>? = null
   private var motionEventPredictor: MotionEventPredictor? = null
  1. 在 attachSurfaceView 函式中,初始化 motionEventPredictor 變數:

FastRenderer.kt

class FastRenderer( ... ) {
   ...
   fun attachSurfaceView(surfaceView: SurfaceView) {
      frontBufferRenderer = GLFrontBufferedRenderer(surfaceView, this)
      motionEventPredictor = MotionEventPredictor.newInstance(surfaceView)
   }
  1. 在 onTouchListener 變數中呼叫 motionEventPredictor?.record 方法,讓 motionEventPredictor 物件取得動作資料:

FastRendering.kt

class FastRenderer( ... ) {
   ...
   val onTouchListener = View.OnTouchListener { view, event ->
      motionEventPredictor?.record(event)
      ...
      when (event?.action) {
   

下一步是使用 predict 函式預測 MotionEvent 物件。建議的預測時間為收到 ACTION_MOVE 常數時,以及記錄 MotionEvent 物件後。換句話說,您應事先預測筆劃。

  1. 使用 predict 方法預測人工 MotionEvent 物件。
  2. 建立 Segment,採用目前的和預測的 x 和 y 座標。
  3. 使用 frontBufferRenderer?.renderFrontBufferedLayer(predictedSegment) 方法,要求快速算繪預測的線段。

FastRendering.kt

class FastRenderer( ... ) {
   ...
   val onTouchListener = View.OnTouchListener { view, event ->
       motionEventPredictor?.record(event)
       ...
       when (event?.action) {
          ...
          MotionEvent.ACTION_MOVE -> {
              ...
              frontBufferRenderer?.renderFrontBufferedLayer(segment)

              val motionEventPredicted = motionEventPredictor?.predict()
              if(motionEventPredicted != null) {
                 val predictedSegment = Segment(currentX, currentY,
       motionEventPredicted.x, motionEventPredicted.y)
                 frontBufferRenderer?.renderFrontBufferedLayer(predictedSegment)
              }

          }
          ...
       }

系統會插入要算繪的預測事件,從而改善延遲情形。

  1. 執行應用程式,您會發現延遲時間縮短了。

改善延遲狀況後,就能為使用者提供更自然的觸控筆體驗。

8. 恭喜

恭喜!您知道如何專業地處理觸控筆了!

您已學到如何處理 MotionEvent 物件,擷取壓力、方向和傾斜度相關資訊。此外,您也學到如何實作 androidx.graphics 程式庫和 androidx.input 程式庫,從而改善延遲時間。同時實作這些強化功能後,就能打造更自然的觸控筆體驗。

瞭解詳情