Améliorer la prise en charge des stylets dans une application Android

1. Avant de commencer

Un stylet est un outil en forme de crayon qui permet aux utilisateurs d'effectuer des tâches précises. Dans cet atelier de programmation, vous allez apprendre à implémenter des expériences de stylet naturelles avec les bibliothèques android.os et androidx. Nous verrons également comment utiliser la classe MotionEvent pour gérer la pression, l'inclinaison et l'orientation, ainsi que la façon d'éviter l'activation tactile par la paume de la main afin d'éviter les gestes involontaires. Enfin, vous apprendrez à réduire la latence du stylet grâce à la prévision de mouvement et aux graphiques à faible latence avec OpenGL et la classe SurfaceView.

Conditions préalables

  • Vous connaissez le langage Kotlin et les lambdas.
  • Vous disposez de connaissances de base concernant l'utilisation d'Android Studio.
  • Vous disposez de connaissances de base concernant Jetpack Compose.
  • Vous disposez de connaissances de base concernant OpenGL pour les graphiques à faible latence.

Points abordés

  • Comment utiliser la classe MotionEvent pour le stylet.
  • Comment implémenter les fonctionnalités du stylet, en particulier la prise en charge de la pression, de l'inclinaison et de l'orientation.
  • Comment dessiner sur la classe Canvas.
  • Comment implémenter une prévision de mouvement.
  • Comment afficher des graphiques à faible latence avec OpenGL et la classe SurfaceView.

Ce dont vous avez besoin

2. Télécharger le code de démarrage

Pour obtenir le code contenant la thématisation et la configuration de base de l'application de démarrage, procédez comme suit :

  1. Clonez le dépôt GitHub suivant :
git clone https://github.com/android/large-screen-codelabs
  1. Ouvrez le dossier advanced-stylus. Le dossier start contient le code de démarrage et end le code de solution.

3. Implémenter une application de dessin de base

Tout d'abord, créez la mise en page nécessaire à une application de dessin de base qui permet aux utilisateurs de dessiner tout en affichant les attributs de stylet à l'écran avec la fonction Composable Canvas. Elle se présente comme suit :

Application de dessin de base. La partie supérieure est destinée à la visualisation et la partie inférieure au dessin.

La partie supérieure est une fonction Composable Canvas dans laquelle vous dessinez la visualisation du stylet et affichez ses différents attributs, tels que l'orientation, l'inclinaison et la pression. La partie inférieure correspond à une autre fonction Canvas Composable qui reçoit les informations provenant du stylet et les convertit en traits simples.

Pour implémenter la mise en page de base de l'application de dessin, procédez comme suit :

  1. Dans Android Studio, ouvrez le dépôt cloné.
  2. Cliquez sur app > java > com.example.stylus, puis double-cliquez sur MainActivity. Le fichier MainActivity.kt s'ouvre.
  3. Dans la classe MainActivity, vous pouvez trouver les fonctions Composable StylusVisualization et DrawArea. Dans cette section, vous allez vous concentrer sur la fonction Composable DrawArea.

Créer une classe StylusState

  1. Dans le même répertoire ui, cliquez sur File > New > Kotlin/Class file (Fichier > Nouveau > Fichier/Classe Kotlin).
  2. Dans la zone de texte, remplacez l'espace réservé Name (Nom) par StylusState.kt, puis appuyez sur Enter (ou return sous macOS).
  3. Dans le fichier StylusState.kt, créez la classe de données StylusState, puis ajoutez les variables du tableau suivant :

Variable

Type

Valeur par défaut

Description

pressure

Float

0F

Valeur comprise entre 0 et 1.0.

orientation

Float

0F

Valeur en degrés radians allant de -pi à pi.

tilt

Float

0F

Valeur en degrés radians comprise entre 0 et pi/2.

path

Path

Path()

Stocke les lignes générées par la fonction Composable Canvas avec la méthode 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(),
)

Vue du tableau de bord avec les mesures d'orientation, d'inclinaison et de pression

  1. Dans le fichier MainActivity.kt, recherchez la classe MainActivity, puis ajoutez l'état du stylet avec la fonction 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())

La classe DrawPoint

La classe DrawPoint stocke les données de chaque point dessiné à l'écran. Lorsque vous associez ces points, des lignes sont créées. Ce fonctionnement est semblable à celui de l'objet Path.

La classe DrawPoint étend la classe PointF. Elle contient les données suivantes :

Paramètres

Type

Description

x

Float

Coordonnée

y

Float

Coordonnée

type

DrawPointType

Type de point

Il existe deux types d'objets DrawPoint, décrits par l'énumération DrawPointType :

Type

Description

START

Déplace le début d'une ligne vers une position.

LINE

Trace une ligne à partir du point précédent.

DrawPoint.kt

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

Effectuer le rendu des points de données dans un tracé

Pour cette application, la classe StylusViewModel stocke les données de la ligne, prépare les données pour le rendu et effectue certaines opérations sur l'objet Path pour la désactivation de l'interaction avec la paume de la main.

  • Pour conserver les données des lignes, dans la classe StylusViewModel, créez une liste modifiable d'objets DrawPoint :

StylusViewModel.kt

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

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

Pour effectuer le rendu des points de données dans un tracé, procédez comme suit :

  1. Dans la classe StylusViewModel du fichier StylusViewModel.kt, ajoutez une fonction createPath.
  2. Créez une variable path de type Path avec le constructeur Path().
  3. Créez une boucle for dans laquelle vous itérez chaque point de données de la variable currentPath.
  4. Si le point de données est de type START, appelez la méthode moveTo pour commencer une ligne aux coordonnées x et y spécifiées.
  5. Sinon, appelez la méthode lineTo avec les coordonnées x et y du point de données à associer au point précédent.
  6. Renvoyez l'objet 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() {
}

Traiter les objets MotionEvent

Les événements de stylet proviennent d'objets MotionEvent, qui fournissent des informations sur l'action effectuée et les données associées, telles que la position du pointeur et la pression appliquée. Le tableau suivant contient certaines des constantes de l'objet MotionEvent et les données correspondantes. Vous pouvez les utiliser pour identifier les actions de l'utilisateur sur l'écran :

Constante

Données

ACTION_DOWN

Le pointeur touche l'écran. Il s'agit du début d'une ligne à la position indiquée par l'objet MotionEvent.

ACTION_MOVE

Le pointeur se déplace à l'écran. Une ligne est tracée.

ACTION_UP

Le pointeur cesse de toucher l'écran. C'est la fin de la ligne.

ACTION_CANCEL

Interaction tactile involontaire détectée. Annule le dernier trait.

Lorsque l'application reçoit un nouvel objet MotionEvent, l'écran doit effectuer le rendu pour refléter les nouvelles entrées utilisateur.

  • Pour traiter des objets MotionEvent dans la classe StylusViewModel, créez une fonction qui collecte les coordonnées des lignes :

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
   }

Envoyer des données à l'interface utilisateur

Pour mettre à jour la classe StylusViewModel afin que l'interface utilisateur soit informée des modifications apportées à la classe de données StylusState, procédez comme suit :

  1. Dans la classe StylusViewModel, créez une variable _stylusState de type MutableStateFlow pour la classe StylusState et une variable stylusState de type StateFlow pour la même classe. La variable _stylusState est modifiée chaque fois que l'état du stylet est modifié dans la classe StylusViewModel et que la variable stylusState est utilisée par l'UI dans la classe MainActivity.

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. Créez une fonction requestRendering qui reçoit un paramètre d'objet StylusState :

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. À la fin de la fonction processMotionEvent, ajoutez un appel de fonction requestRendering avec un paramètre StylusState.
  2. Dans le paramètre StylusState, récupérez les valeurs d'inclinaison, de pression et d'orientation à partir de la variable motionEvent, puis créez le tracé avec une fonction createPath(). Cette action déclenche un événement de flux, que vous connecterez ultérieurement à l'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. Dans la classe MainActivity, recherchez la fonction super.onCreate de la fonction onCreate, puis ajoutez la collecte d'état. Pour en savoir plus sur la collecte d'état, consultez la vidéo Collecting flows in a lifecycle-aware manner (Collecter des flux en tenant compte du cycle de vie).

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

Désormais, chaque fois que la classe StylusViewModel publie un nouvel état StylusState, l'activité le reçoit et le nouvel objet StylusState met à jour la variable local stylusState de la classe MainActivity.

  1. Dans le corps de la fonction Composable DrawArea, ajoutez le modificateur pointerInteropFilter à la fonction Composable Canvas pour fournir des objets MotionEvent.
  1. Envoyez l'objet MotionEvent à la fonction processMotionEvent du StylusViewModel pour traitement :

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. Appelez la fonction drawPath avec l'attribut path de stylusState, puis indiquez un style de trait et une couleur.

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. Exécutez l'application. Vous pouvez maintenant dessiner à l'écran.

4. Implémenter la prise en charge de la pression, de l'orientation et de l'inclinaison

Dans la section précédente, vous avez vu comment récupérer des informations provenant du stylet, tels que la pression, l'orientation et l'inclinaison, à partir d'objets MotionEvent.

StylusViewModel.kt

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

Toutefois, ce raccourci ne fonctionne que pour le premier pointeur. Lorsqu'une interaction multipoint est détectée, plusieurs pointeurs sont activés. Or, ce raccourci ne renvoie que la valeur du premier pointeur à l'écran. Pour demander des données sur un pointeur spécifique, vous pouvez utiliser le paramètre pointerIndex :

StylusViewModel.kt

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

Pour en savoir plus sur les pointeurs et l'interaction multipoint, consultez Gérer les gestes tactiles multipoints.

Ajouter une visualisation de la pression, de l'orientation et de l'inclinaison

  1. Dans le fichier MainActivity.kt, recherchez la fonction Composable StylusVisualization, puis utilisez ces informations pour permettre à l'objet de flux StylusState d'afficher la visualisation :

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. Exécutez l'application. Trois indicateurs situés en haut de l'écran indiquent l'orientation, la pression et l'inclinaison.
  2. Dessinez à l'écran avec le stylet, puis observez la manière dont chaque visualisation réagit à vos entrées.

Orientation, pression et inclinaison visualisées du mot "hello" écrit avec un stylet

  1. Inspectez le fichier StylusVisualization.kt pour comprendre comment chaque visualisation est construite.

5. Désactiver l'interaction avec la paume de la main

L'écran peut enregistrer des interactions tactiles involontaires. C'est par exemple le cas lorsqu'un utilisateur pose naturellement sa main sur l'écran en écrivant du texte.

La désactivation de l'interaction avec la paume de la main est un mécanisme qui détecte ce comportement et avertit le développeur qu'il doit annuler le dernier ensemble d'objets MotionEvent. Un ensemble d'objets MotionEvent commence par la constante ACTION_DOWN.

Vous devez donc conserver un historique des entrées afin de pouvoir supprimer les interactions involontaires de l'écran et afficher à nouveau les entrées utilisateur volontaires. Heureusement, l'historique est déjà stocké dans la classe StylusViewModel, dans la variable currentPath.

Android fournit la constante ACTION_CANCEL de l'objet MotionEvent pour informer le développeur des gestes involontaires. Depuis Android 13, l'objet MotionEvent fournit la constante FLAG_CANCELED qui doit être vérifiée sur la constante ACTION_POINTER_UP.

Implémenter la fonction cancelLastStroke

  • Pour supprimer un point de données du dernier point START, revenez à la classe StylusViewModel, puis créez une fonction cancelLastStroke qui recherche l'index du dernier point de données START et ne conserve que les données du premier point jusqu'à l'index moins un :

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

Ajouter les constantes ACTION_CANCEL et FLAG_CANCELED

  1. Dans le fichier StylusViewModel.kt, recherchez la fonction processMotionEvent.
  2. Dans la constante ACTION_UP, créez une variable canceled qui vérifie si la version actuelle du SDK est Android 13 ou une version ultérieure, et si la constante FLAG_CANCELED est activée.
  3. Sur la ligne suivante, créez une structure conditionnelle qui vérifie que la variable canceled est bien "true". Si tel est le cas, appelez la fonction cancelLastStroke pour supprimer le dernier ensemble d'objets MotionEvent. Dans le cas contraire, appelez la méthode currentPath.add pour ajouter le dernier ensemble d'objets 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. Dans la constante ACTION_CANCEL, vous pouvez voir la fonction cancelLastStroke :

StylusViewModel.kt

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

La désactivation de l'interaction avec la paume de la main est implémentée ! Le code fonctionnel se trouve dans le dossier palm-rejection.

6. Implémenter une faible latence

Dans cette section, vous allez réduire la latence entre l'entrée utilisateur et le rendu à l'écran afin d'améliorer les performances. La latence peut avoir plusieurs causes, parmi lesquelles un pipeline graphique trop long. Vous pouvez résoudre ce problème grâce au rendu du tampon d'affichage. Cette méthode permet aux développeurs d'accéder directement à la mémoire tampon de l'écran, ce qui permet d'obtenir de bien meilleurs résultats en termes d'écriture manuscrite et de dessin.

La classe GLFrontBufferedRenderer fournie par la bibliothèque androidx.graphics s'occupe du rendu du tampon d'affichage, mais aussi du rendu doublé du tampon. Elle optimise un objet SurfaceView pour permettre un rendu plus rapide avec la fonction de rappel onDrawFrontBufferedLayer et un rendu normal avec la fonction de rappel onDrawDoubleBufferedLayer. La classe GLFrontBufferedRenderer et l'interface GLFrontBufferedRenderer.Callback fonctionnent avec un type de données fourni par l'utilisateur. Dans cet atelier de programmation, vous utiliserez la classe Segment.

Pour l'activer, procédez comme suit :

  1. Dans Android Studio, ouvrez le dossier low-latency pour obtenir tous les fichiers requis :
  2. Vous pouvez remarquer que le projet dispose de nouveaux fichiers :
  • Dans le fichier build.gradle, la bibliothèque androidx.graphics a été importée avec la déclaration implementation "androidx.graphics:graphics-core:1.0.0-alpha03".
  • La classe LowLatencySurfaceView étend la classe SurfaceView pour afficher le code OpenGL à l'écran.
  • La classe LineRenderer contient le code OpenGL permettant d'afficher une ligne à l'écran.
  • La classe FastRenderer permet d'accélérer le rendu et implémente l'interface GLFrontBufferedRenderer.Callback. Elle intercepte également les objets MotionEvent.
  • La classe StylusViewModel contient les points de données avec une interface LineManager.
  • La classe Segment définit un segment comme suit :
  • x1, y1 : coordonnées du premier point
  • x2, y2 : coordonnées du second point

Les images suivantes montrent comment les données se déplacent entre chaque classe :

Les objets MotionEvent sont capturés par LowLatencySurfaceView et envoyés à onTouchListener pour traitement. onTouchListener effectue le traitement et envoie une requête de rendu de tampon d'affichage ou doublé à GLFrontBufferRenderer. GLFrontBufferRenderer effectue le rendu sur l'élément LowLatencySurfaceView.

Créer une surface et une mise en page à faible latence

  1. Dans le fichier MainActivity.kt, recherchez la fonction onCreate de la classe MainActivity.
  2. Dans le corps de la fonction onCreate, créez un objet FastRenderer, puis transmettez un objet viewModel :

MainActivity.kt

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

      fastRendering = FastRenderer(viewModel)

      lifecycleScope.launch {
      ...
  1. Dans le même fichier, créez une fonction Composable DrawAreaLowLatency.
  2. Dans le corps de la fonction, utilisez l'API AndroidView pour encapsuler la vue LowLatencySurfaceView, puis fournissez l'objet 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. Dans la fonction onCreate, après la fonction Divider Composable, ajoutez la fonction Composable DrawAreaLowLatency à la mise en page :

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. Dans le répertoire gl, ouvrez le fichier LowLatencySurfaceView.kt. Vous pouvez faire les observations suivantes au sujet de la classe LowLatencySurfaceView :
  • La classe LowLatencySurfaceView étend la classe SurfaceView. Elle utilise la méthode onTouchListener de l'objet fastRenderer.
  • L'interface GLFrontBufferedRenderer.Callback via la classe fastRenderer doit être associée à l'objet SurfaceView lorsque la fonction onAttachedToWindow est appelée, afin que les rappels puissent être affichés dans la vue SurfaceView.
  • L'interface GLFrontBufferedRenderer.Callback via la classe fastRenderer doit être libérée lorsque la fonction onDetachedFromWindow est appelée.

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

Gérer les objets MotionEvent avec l'interface onTouchListener.

Pour gérer les objets MotionEvent lorsque la constante ACTION_DOWN est détectée, procédez comme suit :

  1. Dans le répertoire gl, ouvrez le fichier FastRenderer.kt.
  2. Dans le corps de la constante ACTION_DOWN, créez une variable currentX qui stocke la coordonnée x de l'objet MotionEvent et une variable currentY qui stocke sa coordonnée y.
  3. Créez une variable Segment qui stocke un objet Segment. L'objet reçoit deux instances du paramètre currentX et deux autres du paramètre currentY, car il s'agit du début de la ligne.
  4. Appelez la méthode renderFrontBufferedLayer avec un paramètre segment pour déclencher un rappel sur la fonction 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)
   }

Pour gérer les objets MotionEvent lorsque la constante ACTION_MOVE est détectée, procédez comme suit :

  1. Dans le corps de la constante ACTION_MOVE, créez une variable previousX qui stocke la variable currentX et une variable previousY qui stocke la variable currentY.
  2. Créez une variable currentX qui enregistre la coordonnée x actuelle de l'objet MotionEvent et une variable currentY qui enregistre sa coordonnée y.
  3. Créez une variable Segment qui stocke un objet Segment recevant les paramètres previousX, previousY, currentX et currentY.
  4. Appelez la méthode renderFrontBufferedLayer avec un paramètre segment pour déclencher un rappel sur la fonction onDrawFrontBufferedLayer et exécuter le code 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)
   }
  • Pour gérer des objets MotionEvent lorsque la constante ACTION_UP est détectée, appelez la méthode commit pour déclencher un appel sur la fonction onDrawDoubleBufferedLayer et exécuter le code OpenGL :

FastRenderer.kt

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

Implémenter les fonctions de rappel GLFrontBufferedRenderer

Dans le fichier FastRenderer.kt, les fonctions de rappel onDrawFrontBufferedLayer et onDrawDoubleBufferedLayer exécutent le code OpenGL. Au début de chaque fonction de rappel, les fonctions OpenGL suivantes mappent les données Android à l'espace de travail OpenGL :

  • La fonction GLES20.glViewport définit la taille du rectangle dans lequel vous affichez la scène.
  • La fonction Matrix.orthoM calcule la matrice ModelViewProjection.
  • La fonction Matrix.multiplyMM effectue une multiplication matricielle pour transformer les données Android en références OpenGL et fournit la configuration pour la matrice 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)

Une fois cette partie du code configurée, vous pouvez vous concentrer sur le code qui effectue le rendu réel. La fonction de rappel onDrawFrontBufferedLayer affiche une petite zone de l'écran. Elle fournit une valeur param de type Segment afin que vous puissiez afficher rapidement un seul segment. La classe LineRenderer est un moteur de rendu OpenGL pour le pinceau qui applique la couleur et l'épaisseur de la ligne.

Pour implémenter la fonction de rappel onDrawFrontBufferedLayer, procédez comme suit :

  1. Dans le fichier FastRenderer.kt, recherchez la fonction de rappel onDrawFrontBufferedLayer.
  2. Dans le corps de la fonction de rappel onDrawFrontBufferedLayer, appelez la fonction obtainRenderer pour obtenir l'instance LineRenderer.
  3. Appelez la méthode drawLine de la fonction LineRenderer avec les paramètres suivants :
  • La matrice projection précédemment calculée
  • Une liste d'objets Segment (un seul segment dans ce cas)
  • La valeur color de la ligne

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. Exécutez l'application. Vous pouvez maintenant dessiner à l'écran avec une latence fortement réduite. Cependant, l'application ne conservera pas la ligne, car vous devez toujours implémenter la fonction de rappel onDrawDoubleBufferedLayer.

La fonction de rappel onDrawDoubleBufferedLayer est appelée après la fonction commit pour permettre la persistance de la ligne. Le rappel fournit des valeurs params, qui contiennent une collection d'objets Segment. Tous les segments du tampon d'affichage sont relancés dans le double tampon pour permettre leur persistance.

Pour implémenter la fonction de rappel onDrawDoubleBufferedLayer, procédez comme suit :

  1. Dans le fichier StylusViewModel.kt, recherchez la classe StylusViewModel, puis créez une variable openGlLines qui stocke une liste modifiable d'objets 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. Dans le fichier FastRenderer.kt, recherchez la fonction de rappel onDrawDoubleBufferedLayer de la classe FastRenderer.
  2. Dans le corps de la fonction de rappel onDrawDoubleBufferedLayer, effacez le contenu de l'écran à l'aide des méthodes GLES20.glClearColor et GLES20.glClear pour que la scène puisse être entièrement recréée, puis ajoutez les lignes à l'objet viewModel pour qu'elles soient conservées :

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. Créez une boucle for qui affiche chaque ligne par itération à partir de l'objet 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. Exécutez l'application. Vous pouvez maintenant dessiner à l'écran et la ligne est préservée après le déclenchement de la constante ACTION_UP.

7. Implémenter la prévision de mouvement

Vous pouvez encore réduire la latence avec la bibliothèque androidx.input, qui analyse le parcours du stylet et prévoit l'emplacement du point suivant pour lancer le rendu.

Pour configurer la prévision de mouvement :

  1. Dans le fichier app/build.gradle, importez la bibliothèque dans la section des dépendances :

app/build.gradle

...
dependencies {
    ...
    implementation"androidx.input:input-motionprediction:1.0.0-beta01"
  1. Cliquez sur File > Sync Project with Gradle Files (Fichier > Synchroniser le projet avec les fichiers Gradle).
  2. Dans la classe FastRendering du fichier FastRendering.kt, déclarez l'objet motionEventPredictor en tant qu'attribut :

FastRenderer.kt

import androidx.input.motionprediction.MotionEventPredictor

class FastRenderer( ... ) {
   ...
   private var frontBufferRenderer: GLFrontBufferedRenderer<Segment>? = null
   private var motionEventPredictor: MotionEventPredictor? = null
  1. Dans la fonction attachSurfaceView, initialisez la variable motionEventPredictor :

FastRenderer.kt

class FastRenderer( ... ) {
   ...
   fun attachSurfaceView(surfaceView: SurfaceView) {
      frontBufferRenderer = GLFrontBufferedRenderer(surfaceView, this)
      motionEventPredictor = MotionEventPredictor.newInstance(surfaceView)
   }
  1. Dans la variable onTouchListener, appelez la méthode motionEventPredictor?.record pour que l'objet motionEventPredictor reçoive les données de mouvement :

FastRendering.kt

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

L'étape suivante consiste à prévoir un objet MotionEvent avec la fonction predict. Nous vous recommandons de lancer la prévision au moment où une constante ACTION_MOVE est reçue et après l'enregistrement de l'objet MotionEvent. En d'autres termes, vous devez lancer la prévision dès qu'un trait commence.

  1. Prévoyez un objet MotionEvent artificiel avec la méthode predict.
  2. Créez un objet Segment qui utilise les coordonnées x et y actuelles et prévues.
  3. Demandez un rendu rapide du segment prévu à l'aide de la méthode 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)
              }

          }
          ...
       }

Les événements prévus sont insérés dans le moteur de rendu, ce qui réduit la latence.

  1. Exécutez l'application et faites quelques tests. Vous pouvez observer que la latence est réduite.

Réduire la latence offrira aux utilisateurs de stylet une expérience plus naturelle.

8. Félicitations

Félicitations ! Vous savez gérer le stylet comme un pro.

Vous avez appris à traiter des objets MotionEvent pour extraire des informations sur la pression, l'orientation et l'inclinaison. Vous avez également appris à améliorer la latence en implémentant les bibliothèques androidx.graphics et androidx.input. Ensemble, ces améliorations permettent d'offrir une expérience plus naturelle lors de l'utilisation d'un stylet.

En savoir plus