Android アプリでのタッチペン サポートの強化

1. 始める前に

タッチペンはペンの形をしたツールで、正確なタスクを実行するのに役立ちます。この Codelab では、android.os ライブラリと androidx ライブラリを使用してオーガニックなタッチペン エクスペリエンスを実装する方法を学びます。また、MotionEvent クラスを使用して、圧力、傾斜、向き、パーム リジェクションをサポートし、不要なタップを防ぐ方法も学びます。さらに、モーション予測でタッチペンのレイテンシを低減し、OpenGL と SurfaceView クラスで低レイテンシのグラフィックスを取得する方法についても学びます。

前提条件

  • Kotlin とラムダの使用経験。
  • Android Studio の使用方法についての基礎知識があること。
  • Jetpack Compose に関する基本的な知識。
  • 低レイテンシ グラフィックのための OpenGL に関する基礎知識。

学習内容

  • タッチペンに MotionEvent クラスを使用する方法。
  • タッチペン機能(圧力、傾斜、向きのサポートなど)を実装する方法。
  • Canvas クラスを描画する方法。
  • モーション予測を実装する方法
  • OpenGL と SurfaceView クラスを使用して低レイテンシのグラフィックをレンダリングする方法。

必要なもの

  • 最新バージョンの Android Studio
  • ラムダを含む Kotlin 構文の使用経験。
  • Compose に関する基本的な経験。Compose に慣れていない場合は、Jetpack Compose の基本の Codelab を修了してください。
  • タッチペン対応のデバイス。
  • アクティブなタッチペン。
  • Git。

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()

drawPath メソッドで Canvas Composable 関数によってレンダリングされた線を保存します。

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

Coordinate

y

Float

Coordinate

type

DrawPointType

ポイントの種類

DrawPointType 列挙型で記述される DrawPoint オブジェクトには、次の 2 種類があります。

種類

説明

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. currentPath 変数内の各データポイントを反復処理する for ループを作成します。
  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 にデータを送信する

UI が StylusState データクラスの変更を収集できるように StylusViewModel クラスを更新する手順は次のとおりです。

  1. StylusViewModel クラスで、StylusState クラスの MutableStateFlow 型の _stylusState 変数と、StylusState クラスの StateFlow 型の stylusState 変数を作成します。_stylusState 変数は、StylusViewModel クラスでタッチペンの状態が変更されるたびに変更され、stylusState 変数は MainActivity クラスの UI によって使用されます。

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 関数の本体で、Canvas Composable 関数に pointerInteropFilter 修飾子を追加して 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. アプリを実行します。画面上部に、向き、圧力、傾斜を示す 3 つのインジケーターが表示されます。
  2. 画面をタッチペンでスクリブルし、各ビジュアリゼーションが入力にどのように反応するかを確認します。

タッチペンで書かれた「hello」という語の向き、圧力、傾斜が可視化されています

  1. StylusVisualization.kt ファイルを調べて、各可視化がどのように構成されているかを理解します。

5. パーム リジェクションを実装する

画面で不要なタップを登録できる。たとえば、これはユーザーが手書き入力のサポート時に自然に画面に手に置いた場合に発生します。

パーム リジェクションは、この動作を検出するためのメカニズムであり、最後の MotionEvent オブジェクト セットをキャンセルするようデベロッパーに通知します。MotionEvent オブジェクトのセットは ACTION_DOWN 定数で始まります。

つまり、画面から不要なタップを取り除き、正当なユーザー入力を再レンダリングできるように、入力の履歴を維持する必要があります。幸い、履歴はすでに currentPath 変数の StylusViewModel クラスに保存されています。

Android には MotionEvent オブジェクトからの ACTION_CANCEL 定数が用意されており、不要なタップについてデベロッパーに通知できるようになっています。Android 13 以降では、MotionEvent オブジェクトは FLAG_CANCELED 定数を提供しており、ACTION_POINTER_UP 定数を確認する必要があります。

cancelLastStroke 関数を実装します。

  • 最後の START データポイントからデータポイントを削除するには、StylusViewModel クラスに戻り、最後の START データポイントのインデックスを見つけて、インデックスから 1 を引いた値になるまで最初のデータポイントからそのデータのみを保持する cancelLastStroke 関数を作成します。

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_CANCELFLAG_CANCELED定数を追加する

  1. StylusViewModel.kt ファイルで、processMotionEvent 関数を見つけます。
  2. ACTION_UP 定数で、現在の SDK バージョンが Android 13 以降かどうか、および FLAG_CANCELED 定数がアクティブになっているかどうかを確認する canceled 変数を作成します。
  3. 次の線で、canceled 変数が true であるかどうかを確認する条件を作成します。その場合は、cancelLastStroke 関数を呼び出して、MotionEvent オブジェクトの最後のセットを削除します。それ以外の場合は、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 クラスは、フロントおよびダブル バッファ レンダリングを処理します。また、onDrawFrontBufferedLayer コールバック関数による高速レンダリングと onDrawDoubleBufferedLayer コールバック関数による通常のレンダリングのために SurfaceView オブジェクトを最適化します。GLFrontBufferedRenderer クラスと GLFrontBufferedRenderer.Callback インターフェースは、ユーザー提供データ型とやり取りします。この Codelab では、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 クラスは、次のようにセグメントを定義します。
  • x1y1: 第 1 点の座標
  • x2y2: 第 2 点の座標

次の図は、各クラス間でデータがどのように移動するかを示しています。

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. onCreate 関数で、Divider Composable 関数の後に、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 メソッドを使用します。
  • コールバックで SurfaceView ビューにレンダリングできるよう、onAttachedToWindow 関数が呼び出されたときに、fastRenderer クラスを介した GLFrontBufferedRenderer.Callback インターフェースを 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 定数の本体で、MotionEvent オブジェクトの x 座標を格納する currentX 変数と、y 座標を格納する currentY 変数を作成します。
  3. currentX パラメータの 2 つのインスタンスと currentY パラメータの 2 つのインスタンスを受け入れる Segment オブジェクトを格納する Segment 変数を作成します。これは線の先頭であるためです。
  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. previousXpreviousYcurrentXcurrentY の各パラメータを受け入れる Segment オブジェクトを格納する 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 オブジェクトのリスト。この場合は 1 つのセグメントです。
  • 行の 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 コールバック関数を引き続き実装する必要があるため、アプリで線が維持されません。

onDrawDoubleBufferedLayer コールバック関数は、commit 関数の後に呼び出され、線の永続化を可能にします。このコールバックは、Segment オブジェクトのコレクションを含む params 値を提供します。永続化のために、フロント バッファのすべてのセグメントがダブルバッファでリプレイされます。

onDrawDoubleBufferedLayer コールバック関数を実装する手順は次のとおりです。

  1. StylusViewModel.kt ファイルで StylusViewModel クラスを探し、Segment オブジェクトの変更可能なリストを格納する openGlLines 変数を作成します。

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. viewModel オブジェクトのそれぞれの線を反復してレンダリングする for ループを作成します。

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 ファイルで、dependencies セクションのライブラリをインポートします。

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. 現在および予測される x 座標と y 座標を使用する Segment オブジェクトを作成します。
  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 ライブラリの両方を実装して、レイテンシを改善する方法についても学習しました。これらの機能強化は、よりオーガニックなタッチペン体験を実現します。

詳細