Cómo mejorar la compatibilidad con la pluma stylus en una app para Android

1. Antes de comenzar

Una pluma stylus es una herramienta con forma de lápiz que ayuda a los usuarios a realizar tareas precisas. En este codelab, aprenderás a implementar experiencias orgánicas de la pluma stylus con las bibliotecas android.os y androidx. También aprenderás a usar la clase MotionEvent para admitir la presión, la inclinación y la orientación, así como el rechazo de la palma para evitar toques no deseados. Además, aprenderás a reducir la latencia de la pluma stylus con la predicción de movimiento y gráficos de baja latencia con OpenGL y la clase SurfaceView.

Requisitos previos

  • Experiencia con Kotlin y lambdas
  • Conocimientos básicos de Android Studio
  • Conocimientos básicos de Jetpack Compose
  • Conocimientos básicos de OpenGL para gráficos de baja latencia

Qué aprenderás

  • Cómo usar la clase MotionEvent para la pluma stylus
  • Cómo implementar las capacidades de la pluma stylus, incluida la compatibilidad con la presión, la inclinación y la orientación
  • Cómo dibujar en la clase Canvas
  • Cómo implementar la predicción de movimiento
  • Cómo renderizar gráficos de baja latencia con OpenGL y la clase SurfaceView

Requisitos

2. Obtén el código de partida

Para obtener el código que contiene los temas y la configuración básica de la app de partida, sigue estos pasos:

  1. Clona este repositorio de GitHub:
git clone https://github.com/android/large-screen-codelabs
  1. Abre la carpeta advanced-stylus. La carpeta start incluye el código de partida, y la carpeta end, el código de la solución.

3. Implementa una app básica de dibujo

Primero, compilarás el diseño necesario para una app de dibujo básica que les permite a los usuarios dibujar y muestra atributos de la pluma stylus en la pantalla con la función Composable de Canvas. Se ve como la siguiente imagen:

La app de dibujo básica. La parte superior es para visualizar, y la inferior, para dibujar.

La parte superior es una función Composable de Canvas en la que dibujas la visualización de la pluma stylus y muestras sus diferentes atributos, como su orientación, inclinación y presión. La parte inferior es otra función Composable de Canvas que recibe la entrada de la pluma stylus y dibuja trazos simples.

Para implementar el diseño básico de la app de dibujo, sigue estos pasos:

  1. En Android Studio, abre el repositorio clonado.
  2. Haz clic en app > java > com.example.stylus y haz doble clic en MainActivity. Se abrirá el archivo MainActivity.kt.
  3. En la clase MainActivity, observa las funciones Composable de StylusVisualization y DrawArea. En esta sección, te enfocas en la función Composable de DrawArea.

Crea una clase StylusState.

  1. En el mismo directorio ui, haz clic en File > New > Kotlin/Class file.
  2. En el cuadro de texto, reemplaza el marcador de posición Name por StylusState.kt y, luego, presiona Enter (o return en macOS).
  3. En el archivo StylusState.kt, crea la clase de datos StylusState y, luego, agrega las variables de la siguiente tabla:

Variable

Tipo

Valor predeterminado

Descripción

pressure

Float

0F

Es un valor que varía de 0 a 1.0.

orientation

Float

0F

Es un valor de radianes que varía de -pi a pi.

tilt

Float

0F

Es un valor de radianes que varía de 0 a pi/2.

path

Path

Path()

Almacena líneas renderizadas por la función Composable de Canvas con el método 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(),
)

Una vista de panel de las métricas de orientación, inclinación y presión

  1. En el archivo MainActivity.kt, busca la clase MainActivity y, luego, agrega el estado de la pluma stylus con la función 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 clase DrawPoint

La clase DrawPoint almacena datos sobre cada punto dibujado en la pantalla; cuando vincules estos puntos, crearás líneas. Imita cómo funciona el objeto Path.

La clase DrawPoint extiende la clase PointF. Contiene los siguientes datos:

Parámetros

Tipo

Descripción

x

Float

Coordenada

y

Float

Coordenada

type

DrawPointType

Tipo de punto

Existen dos tipos de objetos DrawPoint, que describe el enum DrawPointType:

Tipo

Descripción

START

Mueve el inicio de una línea a una posición.

LINE

Traza una línea desde el punto anterior.

DrawPoint.kt

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

Renderiza los datos en una ruta de acceso

En esta app, la clase StylusViewModel contiene datos de la línea, prepara datos para la renderización y realiza algunas operaciones en el objeto Path para el rechazo de la palma.

  • Para contener los datos de las líneas, en la clase StylusViewModel, crea una lista mutable de objetos DrawPoint:

StylusViewModel.kt

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

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

Para renderizar los datos en una ruta, sigue estos pasos:

  1. En la clase StylusViewModel del archivo StylusViewModel.kt, agrega una función createPath.
  2. Crea una variable path de tipo Path con el constructor Path().
  3. Crea un bucle for en el que iteres a través de cada dato en la variable currentPath.
  4. Si los datos son del tipo START, llama al método moveTo para iniciar una línea en las coordenadas x y y especificadas.
  5. De lo contrario, llama al método lineTo con las coordenadas x y y de los datos para vincularlos al punto anterior.
  6. Devuelve el objeto 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() {
}

Procesa objetos MotionEvent

Los eventos de la pluma stylus llegan a través de los objetos MotionEvent, que proporcionan información sobre la acción realizada y los datos asociados a esta, como la posición del puntero y la presión. En la siguiente tabla, se incluyen algunas de las constantes del objeto MotionEvent y sus datos, que puedes usar para identificar lo que hace el usuario en la pantalla:

Constante

Datos

ACTION_DOWN

El puntero toca la pantalla. Es el inicio de una línea en la posición que informa el objeto MotionEvent.

ACTION_MOVE

El puntero se mueve en la pantalla. Es la línea que se dibuja.

ACTION_UP

El puntero deja de tocar la pantalla. Es el final de la línea.

ACTION_CANCEL

Se detectó un toque no deseado. Cancela el último trazo.

Cuando la app recibe un nuevo objeto MotionEvent, se debe renderizar la pantalla para reflejar la nueva entrada del usuario.

  • Para procesar objetos MotionEvent en la clase StylusViewModel, crea una función que reúna las coordenadas de las líneas:

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
   }

Envía datos a la IU

Para actualizar la clase StylusViewModel, de modo que la IU pueda recopilar cambios en la clase de datos StylusState, sigue estos pasos:

  1. En la clase StylusViewModel, crea una variable _stylusState de un tipo MutableStateFlow de la clase StylusState y una variable stylusState de un tipo StateFlow de la clase StylusState. La variable _stylusState se modifica cada vez que se cambia el estado de la pluma stylus en la clase StylusViewModel y la IU consume la variable stylusState en la clase 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. Crea una función requestRendering que acepte un parámetro del objeto 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. Al final de la función processMotionEvent, agrega una llamada a la función requestRendering con un parámetro StylusState.
  2. En el parámetro StylusState, recupera los valores de inclinación, presión y orientación de la variable motionEvent y, luego, crea la ruta con una función createPath(). De esta manera, se activa un evento de flujo, que se conectará más adelante en la IU.

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. En la clase MainActivity, busca la función super.onCreate de la función onCreate y, luego, agrega la recopilación de estado. Para obtener más información sobre la recopilación de estados, consulta el video Collecting flows in a lifecycle-aware manner (Cómo recopilar flujos de manera optimizada para ciclos de vida).

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

Ahora, cada vez que la clase StylusViewModel publica un nuevo estado StylusState, la actividad lo recibe, y el nuevo objeto StylusState actualiza la variable stylusState de la clase MainActivity local.

  1. En el cuerpo de la función Composable de DrawArea, agrega el modificador pointerInteropFilter a la función Composable de Canvas para proporcionar objetos MotionEvent.
  1. Envía el objeto MotionEvent a la función processMotionEvent de StylusViewModel para su procesamiento:

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. Llama a la función drawPath con el atributo path de stylusState y, luego, proporciona un color y un estilo de trazo.

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. Ejecuta la app y, luego, observa que puedes dibujar en la pantalla.

4. Implementa la compatibilidad con la presión, la orientación y la inclinación

En la sección anterior, viste cómo recuperar información de la pluma stylus desde objetos MotionEvent, como la presión, la orientación y la inclinación.

StylusViewModel.kt

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

Sin embargo, esta combinación de teclas solo funciona para el primer puntero. Cuando se detectan varios toques, se detectan varios punteros, y este acceso directo solo muestra el valor del primer puntero (o del primer puntero en la pantalla). Para solicitar datos sobre un puntero específico, puedes usar el parámetro pointerIndex:

StylusViewModel.kt

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

Para obtener más información sobre los punteros y varios toques, consulta Cómo controlar gestos de varios toques.

Agrega visualización para la presión, la orientación y la inclinación

  1. En el archivo MainActivity.kt, busca la función Composable de StylusVisualization y, luego, usa la información del objeto de flujo StylusState para renderizar la visualización:

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. Ejecuta la app. Verás tres indicadores en la parte superior de la pantalla que indican la orientación, la inclinación y la presión.
  2. Con una pluma stylus, haz garabatos en la pantalla y observa cómo reacciona cada visualización con las entradas.

La orientación, la presión y la inclinación visualizadas de la palabra "hello" escrita con una pluma stylus

  1. Inspecciona el archivo StylusVisualization.kt para comprender cómo se construye cada visualización.

5. Implementa el rechazo de la palma

La pantalla puede registrar toques no deseados. Por ejemplo, ocurre cuando un usuario apoya naturalmente la mano en la pantalla para descansar mientras escribe.

El rechazo de la palma es un mecanismo que detecta este comportamiento y notifica al desarrollador para que cancele el último conjunto de objetos MotionEvent. Un conjunto de objetos MotionEvent comienza con la constante ACTION_DOWN.

Es decir, debes mantener un historial de las entradas para que puedas quitar los toques no deseados de la pantalla y volver a renderizar las verdaderas entradas de los usuarios. Afortunadamente, ya tienes el historial almacenado en la clase StylusViewModel de la variable currentPath.

Android proporciona la constante ACTION_CANCEL del objeto MotionEvent para informarle al desarrollador el toque no deseado. A partir de Android 13, el objeto MotionEvent proporciona la constante FLAG_CANCELED que debe verificarse en la constante ACTION_POINTER_UP.

Implementa la función cancelLastStroke

  • Para quitar un dato del último dato START, regresa a la clase StylusViewModel y, luego, crea una función cancelLastStroke que encuentre el índice del último dato START y que solo conserve los datos desde el primer dato hasta el índice menos uno:

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

Agrega las constantes ACTION_CANCEL y FLAG_CANCELED.

  1. En el archivo StylusViewModel.kt, busca la función processMotionEvent.
  2. En la constante ACTION_UP, crea una variable canceled que verifica si la versión actual del SDK es Android 13 o una posterior, y si la constante FLAG_CANCELED está activada.
  3. En la siguiente línea, crea un condicional que verifique si la variable canceled es verdadera. Si es así, llama a la función cancelLastStroke para quitar el último conjunto de objetos MotionEvent. De lo contrario, llama al método currentPath.add para agregar el último conjunto de objetos 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. En la constante ACTION_CANCEL, observa la función cancelLastStroke:

StylusViewModel.kt

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

Se implementó el rechazo de la palma. Puedes encontrar el código que funciona en la carpeta palm-rejection.

6. Implementa la latencia baja

En esta sección, reducirás la latencia entre la entrada del usuario y la renderización de la pantalla para mejorar el rendimiento. La latencia tiene varias causas, y una de ellas es la canalización gráfica larga. Reduce la canalización gráfica con la renderización del búfer frontal, que les brinda a los desarrolladores acceso directo al búfer de la pantalla, lo que proporciona excelentes resultados para la escritura a mano y los esbozos.

La clase GLFrontBufferedRenderer que proporciona la biblioteca androidx.graphics se encarga de la renderización del búfer frontal y doble. Optimiza un objeto SurfaceView para una renderización rápida con la función de devolución de llamada onDrawFrontBufferedLayer y la renderización normal con la función de devolución de llamada onDrawDoubleBufferedLayer. La clase GLFrontBufferedRenderer y la interfaz GLFrontBufferedRenderer.Callback funcionan con un tipo de datos proporcionado por el usuario. En este codelab, usarás la clase Segment.

Para comenzar, sigue estos pasos:

  1. En Android Studio, abre la carpeta low-latency para obtener todos los archivos necesarios:
  2. Observa los siguientes archivos nuevos en el proyecto:
  • En el archivo build.gradle, se importó la biblioteca androidx.graphics con la declaración implementation "androidx.graphics:graphics-core:1.0.0-alpha03".
  • La clase LowLatencySurfaceView extiende la clase SurfaceView para renderizar el código OpenGL en la pantalla.
  • La clase LineRenderer contiene el código OpenGL para renderizar una línea en la pantalla.
  • La clase FastRenderer permite una renderización rápida e implementa la interfaz GLFrontBufferedRenderer.Callback. También intercepta objetos MotionEvent.
  • La clase StylusViewModel contiene los datos con una interfaz LineManager.
  • La clase Segment define un segmento de la siguiente manera:
  • x1, y1: coordenadas del primer punto
  • x2, y2: coordenadas del segundo punto

En las siguientes imágenes, se muestra cómo se mueven los datos entre cada clase:

LowLatencySurfaceView captura MotionEvent, que luego se envía a onTouchListener para su procesamiento. onTouchListener procesa y solicita la renderización de búfer frontal o doble en GLFrontBufferRenderer. GLFrontBufferRenderer se renderiza en LowLatencySurfaceView.

Crea una superficie y un diseño de baja latencia

  1. En el archivo MainActivity.kt, busca la función onCreate de la clase MainActivity.
  2. En el cuerpo de la función onCreate, crea un objeto FastRenderer y, luego, pasa un objeto viewModel:

MainActivity.kt

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

      fastRendering = FastRenderer(viewModel)

      lifecycleScope.launch {
      ...
  1. En el mismo archivo, crea una función Composable de DrawAreaLowLatency.
  2. En el cuerpo de la función, usa la API de AndroidView para unir la vista LowLatencySurfaceView y, luego, proporcionar el objeto 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. En la función onCreate después de la función Composable de Divider, agrega la función Composable de DrawAreaLowLatency al diseño:

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. En el directorio gl, abre el archivo LowLatencySurfaceView.kt y, luego, observa lo siguiente en la clase LowLatencySurfaceView:
  • La clase LowLatencySurfaceView extiende la clase SurfaceView. Usa el método onTouchListener del objeto fastRenderer.
  • La interfaz GLFrontBufferedRenderer.Callback a través de la clase fastRenderer debe adjuntarse al objeto SurfaceView cuando se llama a la función onAttachedToWindow para que las devoluciones de llamada se puedan renderizar en la vista SurfaceView.
  • La interfaz GLFrontBufferedRenderer.Callback a través de la clase fastRenderer debe liberarse cuando se llama a la función onDetachedFromWindow.

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

Controla objetos MotionEvent con la interfaz de onTouchListener

Para controlar objetos MotionEvent cuando se detecta la constante ACTION_DOWN, sigue estos pasos:

  1. En el directorio gl, abre el archivo FastRenderer.kt.
  2. En el cuerpo de la constante ACTION_DOWN, crea una variable currentX que almacene la coordenada x del objeto MotionEvent y una variable currentY que almacene su coordenada y.
  3. Crea una variable Segment que almacene un objeto Segment que acepte dos instancias del parámetro currentX y dos instancias del parámetro currentY porque es el inicio de la línea.
  4. Llama al método renderFrontBufferedLayer con un parámetro segment para activar una devolución de llamada en la función 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)
   }

Para controlar objetos MotionEvent cuando se detecta la constante ACTION_MOVE, sigue estos pasos:

  1. En el cuerpo de la constante ACTION_MOVE, crea una variable previousX que almacene la variable currentX y una variable previousY que almacene la variable currentY.
  2. Crea una variable currentX que guarda la coordenada x actual del objeto MotionEvent y una variable currentY que guarda la coordenada y actual.
  3. Crea una variable Segment que almacene un objeto Segment que acepte los parámetros previousX, previousY, currentX y currentY.
  4. Llama al método renderFrontBufferedLayer con un parámetro segment para activar una devolución de llamada en la función onDrawFrontBufferedLayer y ejecutar el código 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)
   }
  • Para controlar objetos MotionEvent cuando se detecta la constante ACTION_UP, llama al método commit para activar una llamada en la función onDrawDoubleBufferedLayer y ejecutar el código OpenGL:

FastRenderer.kt

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

Implementa las funciones de devolución de llamada GLFrontBufferedRenderer.

En el archivo FastRenderer.kt, las funciones de devolución de llamada onDrawFrontBufferedLayer y onDrawDoubleBufferedLayer ejecutan el código OpenGL. Al comienzo de cada función de devolución de llamada, las siguientes funciones de OpenGL asignan datos de Android al espacio de trabajo de OpenGL:

  • La función GLES20.glViewport define el tamaño del rectángulo en el que renderizas la escena.
  • La función Matrix.orthoM calcula la matriz ModelViewProjection.
  • La función Matrix.multiplyMM realiza la multiplicación de matrices para transformar los datos de Android en la referencia de OpenGL y proporciona la configuración de la matriz 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)

Con esa parte del código configurada, puedes enfocarte en el código que realiza la renderización real. La función de devolución de llamada onDrawFrontBufferedLayer renderiza un área pequeña de la pantalla. Proporciona un valor param de tipo Segment para que puedas renderizar un solo segmento con rapidez. La clase LineRenderer es un procesador OpenGL para el pincel que aplica el color y el tamaño de la línea.

Para implementar la función de devolución de llamada onDrawFrontBufferedLayer, sigue estos pasos:

  1. En el archivo FastRenderer.kt, busca la función de devolución de llamada onDrawFrontBufferedLayer.
  2. En el cuerpo de la función de devolución de llamada onDrawFrontBufferedLayer, llama a la función obtainRenderer para obtener la instancia LineRenderer.
  3. Llama al método drawLine de la función LineRenderer con los siguientes parámetros:
  • La matriz projection calculada previamente.
  • Una lista de objetos Segment, que es un solo segmento en este caso.
  • El color de la línea.

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. Ejecuta la app y, luego, observa que puedes dibujar en la pantalla con la latencia mínima. Sin embargo, la app no conservará la línea porque aún debes implementar la función de devolución de llamada onDrawDoubleBufferedLayer.

Se llama a la función de devolución de llamada onDrawDoubleBufferedLayer después de la función commit para permitir la persistencia de la línea. La devolución de llamada proporciona valores params, que contienen una colección de objetos Segment. Todos los segmentos del búfer frontal se vuelven a reproducir en el búfer doble para mantener la persistencia.

Para implementar la función de devolución de llamada onDrawDoubleBufferedLayer, sigue estos pasos:

  1. En el archivo StylusViewModel.kt, busca la clase StylusViewModel y, luego, crea una variable openGlLines que almacene una lista mutable de objetos 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. En el archivo FastRenderer.kt, busca la función de devolución de llamada onDrawDoubleBufferedLayer de la clase FastRenderer.
  2. En el cuerpo de la función de devolución de llamada onDrawDoubleBufferedLayer, borra la pantalla con los métodos GLES20.glClearColor y GLES20.glClear para que se pueda renderizar la escena desde cero y agrega las líneas al objeto viewModel para que se conserven:

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. Crea un bucle for que itere y renderice cada línea desde el objeto 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. Ejecuta la app y, luego, observa que puedes dibujar en la pantalla, y la línea se conserva después de que se activa la constante ACTION_UP.

7. Implementa la predicción de movimiento

Puedes mejorar aún más la latencia con la biblioteca androidx.input, que analiza el recorrido de la pluma stylus, predice la ubicación del siguiente punto y la inserta para su renderización.

Para configurar la predicción de movimiento, sigue estos pasos:

  1. En el archivo app/build.gradle, importa la biblioteca en la sección de dependencias:

app/build.gradle

...
dependencies {
    ...
    implementation"androidx.input:input-motionprediction:1.0.0-beta01"
  1. Haz clic en File > Sync Project with Gradle Files.
  2. En la clase FastRendering del archivo FastRendering.kt, declara el objeto motionEventPredictor como un atributo:

FastRenderer.kt

import androidx.input.motionprediction.MotionEventPredictor

class FastRenderer( ... ) {
   ...
   private var frontBufferRenderer: GLFrontBufferedRenderer<Segment>? = null
   private var motionEventPredictor: MotionEventPredictor? = null
  1. En la función attachSurfaceView, inicializa la variable motionEventPredictor:

FastRenderer.kt

class FastRenderer( ... ) {
   ...
   fun attachSurfaceView(surfaceView: SurfaceView) {
      frontBufferRenderer = GLFrontBufferedRenderer(surfaceView, this)
      motionEventPredictor = MotionEventPredictor.newInstance(surfaceView)
   }
  1. En la variable onTouchListener, llama al método motionEventPredictor?.record para que el objeto motionEventPredictor obtenga datos de movimiento:

FastRendering.kt

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

El siguiente paso es predecir un objeto MotionEvent con la función predict. Recomendamos predecir cuándo se recibe una constante ACTION_MOVE y después de registrar el objeto MotionEvent. En otras palabras, debes predecir cuándo se realiza un trazo.

  1. Predice un objeto MotionEvent artificial con el método predict
  2. Crea un objeto Segment que use las coordenadas x e y previstas y actuales.
  3. Solicita una renderización rápida del segmento previsto con el método 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)
              }

          }
          ...
       }

Los eventos previstos se insertan para renderizar, lo que mejora la latencia.

  1. Ejecuta la app y, luego, observa la mejora en la latencia.

Si mejoras la latencia, los usuarios tendrán una experiencia más natural con la pluma stylus.

8. Felicitaciones

¡Felicitaciones! ¡Sabes manejar la pluma stylus como un profesional!

Aprendiste a procesar objetos MotionEvent para extraer información sobre la presión, la orientación y la inclinación. También aprendiste a mejorar la latencia con la implementación de la biblioteca androidx.graphics y la biblioteca androidx.input. Todas estas mejoras implementadas ofrecen una experiencia más orgánica de la pluma stylus.

Más información