Melhorar o suporte à stylus em um app Android

1. Antes de começar

Uma stylus é uma ferramenta de caneta que ajuda os usuários a realizar tarefas precisas. Neste codelab, você vai aprender a implementar experiências orgânicas da stylus com as bibliotecas android.os e androidx. Você também vai aprender a usar a classe MotionEvent para oferecer suporte a pressão, inclinação e orientação e rejeição de palmas para evitar toques indesejados. Além disso, você vai aprender a reduzir a latência da stylus com previsão de movimento e gráficos de baixa latência com o OpenGL e a classe SurfaceView.

Pré-requisitos

  • Experiência com Kotlin e lambdas.
  • Conhecimentos básicos sobre como usar o Android Studio.
  • Conhecimentos básicos sobre o Jetpack Compose.
  • Noções básicas do OpenGL para gráficos de baixa latência.

O que você vai aprender

  • Como usar a classe MotionEvent para a stylus.
  • Como implementar os recursos da stylus, incluindo suporte a pressão, inclinação e orientação.
  • Como desenhar na classe Canvas.
  • Como implementar a previsão de movimento.
  • Como renderizar gráficos de baixa latência com o OpenGL e a classe SurfaceView.

O que é necessário

2. Acessar o código inicial

Para receber o código que contém os temas e a configuração básica do app inicial, siga estas etapas:

  1. Clone este repositório do GitHub:
git clone https://github.com/android/large-screen-codelabs
  1. Abra a pasta advanced-stylus. A pasta start contém o código inicial e a pasta end contém o código da solução.

3. Implementar um app básico de desenho

Primeiro, você cria o layout necessário para um app básico de desenho que permite aos usuários desenhar e mostra os atributos da stylus na tela com a função Canvas Composable. Ela tem a seguinte aparência:

O app básico de desenho. A parte de cima é para visualização e a parte de baixo é para desenho.

A parte de cima é uma função Composable Canvas onde você desenha a visualização da stylus e mostra os diferentes atributos dela, como orientação, inclinação e pressão. A parte de baixo é outra função Composable Canvas que recebe entrada da stylus e desenha traços simples.

Para implementar o layout básico do app de desenho, siga estas etapas:

  1. No Android Studio, abra o repositório clonado.
  2. Clique em app > java > com.example.stylus e clique duas vezes em MainActivity. O arquivo MainActivity.kt será aberto.
  3. Na classe MainActivity, observe as funções Composable StylusVisualization e DrawArea. Nesta seção, você se concentra na função Composable DrawArea.

Criar uma classe StylusState

  1. No mesmo diretório ui, clique em File > New > Kotlin/Class file.
  2. Na caixa de texto, substitua o marcador Name por StylusState.kt e pressione Enter (ou return no macOS).
  3. No arquivo StylusState.kt, crie a classe de dados StylusState e adicione as variáveis da seguinte tabela:

Variável

Tipo

Valor padrão

Descrição

pressure

Float

0F

Um valor de 0 a 1,0.

orientation

Float

0F

Um valor de radiano de -pi a pi.

tilt

Float

0F

Um valor de radiano de 0 a pi/2.

path

Path

Path()

Armazena linhas renderizadas pela função Canvas Composable com o 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(),
)

Visualização no painel das métricas de orientação, inclinação e pressão

  1. No arquivo MainActivity.kt, encontre a classe MainActivity e adicione o estado da stylus com a função 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())

A classe DrawPoint

A classe DrawPoint armazena dados sobre cada ponto desenhado na tela. Ao vincular esses pontos, você cria linhas. Ela imita o funcionamento do objeto Path.

A classe DrawPoint estende a classe PointF. Ela contém os seguintes dados:

Parâmetros

Tipo

Descrição

x

Float

Coordenadas

y

Float

Coordenadas

type

DrawPointType

Tipo de ponto

Há dois tipos de objetos DrawPoint, que são descritos pelo enum DrawPointType:

Tipo

Descrição

START

Move o início de uma linha para uma posição.

LINE

Traça uma linha a partir do ponto anterior.

DrawPoint.kt

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

Renderizar os pontos de dados em um caminho

Para este app, a classe StylusViewModel contém dados da linha, prepara dados para renderização e executa algumas operações no objeto Path para rejeição de palmas.

  • Para armazenar os dados das linhas, na classe StylusViewModel, crie uma lista mutável 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 os pontos de dados em um caminho, siga estas etapas:

  1. Na classe StylusViewModel do arquivo StylusViewModel.kt, adicione uma função createPath.
  2. Crie uma variável path do tipo Path com o construtor Path().
  3. Crie um loop for em que você itera para cada ponto de dados na variável currentPath.
  4. Se o ponto de dados for do tipo START, chame o método moveTo para iniciar uma linha nas coordenadas x e y especificadas.
  5. Caso contrário, chame o método lineTo com as coordenadas x e y do ponto de dados para vincular ao ponto anterior.
  6. Retorne o 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() {
}

Processar objetos MotionEvent

Os eventos da stylus vêm por objetos MotionEvent, que fornecem informações sobre a ação realizada e os dados associados a ela, como a posição do ponteiro e a pressão. A tabela a seguir contém algumas constantes do objeto MotionEvent e os respectivos dados, que você pode usar para identificar o que o usuário faz na tela:

Constante

Dados

ACTION_DOWN

O ponteiro toca na tela. É o início de uma linha na posição informada pelo objeto MotionEvent.

ACTION_MOVE

O ponteiro se move na tela. É a linha desenhada.

ACTION_UP

O ponteiro para de tocar na tela. É o fim da linha.

ACTION_CANCEL

Um toque indesejado foi detectado. O último traço é cancelado.

Quando o app recebe um novo objeto MotionEvent, a tela precisa renderizar para refletir a nova entrada do usuário.

  • Para processar objetos MotionEvent na classe StylusViewModel, crie uma função que reúna as coordenadas da linha:

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
   }

Enviar dados para a interface

Para atualizar a classe StylusViewModel para que a interface possa coletar mudanças na classe de dados StylusState, siga estas etapas:

  1. Na classe StylusViewModel, crie uma variável _stylusState do tipo MutableStateFlow da classe StylusState e uma variável stylusState do tipo StateFlow da classe StylusState. A variável _stylusState é modificada sempre que o estado da stylus é modificado na classe StylusViewModel e a variável stylusState é consumida pela interface na 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. Crie uma função requestRendering que aceite um parâmetro de 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. No final da função processMotionEvent, adicione uma chamada de função requestRendering com um parâmetro StylusState.
  2. No parâmetro StylusState, recupere os valores de inclinação, pressão e orientação da variável motionEvent e crie o caminho com uma função createPath(). Isso aciona um evento de fluxo, que você vai conectar na interface mais tarde.

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. Na classe MainActivity, encontre a função super.onCreate da função onCreate e adicione a coleção de estados. Para saber mais sobre a coleta de estados, consulte Como coletar fluxos de modo ciente do ciclo de vida (link em inglês).

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

Agora, sempre que a classe StylusViewModel postar um novo estado StylusState, a atividade vai receber e o novo objeto StylusState vai atualizar a variável stylusState da classe local MainActivity.

  1. No corpo da função DrawArea Composable, adicione o modificador pointerInteropFilter à função Canvas Composable para fornecer objetos MotionEvent.
  1. Envie o objeto MotionEvent para a função processMotionEvent do StylusViewModel para processamento:

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. Chame a função drawPath com o atributo stylusState path e forneça uma cor e um estilo de traço.

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. Execute o app e observe que você pode desenhar na tela.

4. Implementar suporte para pressão, orientação e inclinação

Na seção anterior, você aprendeu a recuperar informações da stylus de objetos MotionEvent, como pressão, orientação e inclinação.

StylusViewModel.kt

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

No entanto, esse atalho funciona apenas para o primeiro ponteiro. Quando o recurso multitoque é detectado, vários ponteiros são detectados, e esse atalho só retorna o valor do primeiro ponteiro ou do primeiro ponteiro na tela. Para solicitar dados sobre um ponteiro específico, use o parâmetro pointerIndex:

StylusViewModel.kt

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

Para saber mais sobre ponteiros e multitoque, consulte Gerenciar gestos multitoque.

Adicionar visualização de pressão, orientação e inclinação

  1. No arquivo MainActivity.kt, encontre a função Composable StylusVisualization e use as informações do objeto de fluxo StylusState para renderizar a visualização:

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. Execute o app. Observe três indicadores na parte de cima da tela que indicam orientação, pressão e inclinação.
  2. Rabisque na tela com a stylus e observe como cada visualização reage com suas entradas.

A orientação, a pressão e a inclinação visualizadas para a palavra "hello" escrita com uma stylus

  1. Inspecione o arquivo StylusVisualization.kt para entender como cada visualização é construída.

5. Implementar rejeição de palmas

A tela pode registrar toques indesejados. Por exemplo, isso acontece quando um usuário apoia a mão naturalmente na tela enquanto escreve à mão.

A rejeição de palmas é um mecanismo que detecta esse comportamento e notifica o desenvolvedor para cancelar o último conjunto de objetos MotionEvent. Um conjunto de objetos MotionEvent começa com a constante ACTION_DOWN.

Isso significa que você precisa manter um histórico das entradas para remover toques indesejados da tela e renderizar novamente as entradas legítimas do usuário. Felizmente, o histórico já está armazenado na classe StylusViewModel na variável currentPath.

O Android oferece a constante ACTION_CANCEL do objeto MotionEvent para informar ao desenvolvedor sobre toques indesejados. Desde o Android 13, o objeto MotionEvent fornece a constante FLAG_CANCELED que precisa ser verificada na constante ACTION_POINTER_UP.

Implementar a função cancelLastStroke

  • Para remover um ponto de dados do último START, volte à classe StylusViewModel e crie uma função cancelLastStroke que encontra o índice do último ponto de dados START e mantém apenas os dados do primeiro ponto até o índice menos um:

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

Adicionar as constantes ACTION_CANCEL e FLAG_CANCELED

  1. No arquivo StylusViewModel.kt, encontre a função processMotionEvent.
  2. Na constante ACTION_UP, crie uma variável canceled que verifica se a versão atual do SDK é o Android 13 ou mais recente e se a constante FLAG_CANCELED está ativada.
  3. Na próxima linha, crie uma condicional para verificar se a variável canceled é verdadeira. Nesse caso, chame a função cancelLastStroke para remover o último conjunto de objetos MotionEvent. Caso contrário, chame o método currentPath.add para adicionar o ú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. Na constante ACTION_CANCEL, observe a função cancelLastStroke:

StylusViewModel.kt

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

A rejeição de palmas foi implementada. Você pode encontrar o código em funcionamento na pasta palm-rejection.

6. Implementar baixa latência

Nesta seção, você reduz a latência entre a entrada do usuário e a renderização da tela para melhorar o desempenho. A latência tem várias causas, sendo uma delas o pipeline gráfico longo. Você reduz o pipeline gráfico com a renderização do buffer frontal. A renderização do buffer frontal fornece aos desenvolvedores acesso direto ao buffer de tela, gerando ótimos resultados para escrita à mão e desenho.

A classe GLFrontBufferedRenderer fornecida pela biblioteca androidx.graphics cuida da renderização do buffer frontal e duplicado. Ela otimiza um objeto SurfaceView para renderização rápida com a função de callback onDrawFrontBufferedLayer e a renderização normal com a função de callback onDrawDoubleBufferedLayer. A classe GLFrontBufferedRenderer e a interface GLFrontBufferedRenderer.Callback funcionam com um tipo de dados fornecido pelo usuário. Neste codelab, você vai usar a classe Segment.

Para começar, siga estas etapas:

  1. No Android Studio, abra a pasta low-latency para receber todos os arquivos necessários:
  2. Observe os seguintes arquivos novos no projeto:
  • No arquivo build.gradle, a biblioteca androidx.graphics foi importada com a declaração implementation "androidx.graphics:graphics-core:1.0.0-alpha03".
  • A classe LowLatencySurfaceView estende a classe SurfaceView para renderizar o código OpenGL na tela.
  • A classe LineRenderer contém o código do OpenGL para renderizar uma linha na tela.
  • A classe FastRenderer permite a renderização rápida e implementa a interface GLFrontBufferedRenderer.Callback. Ela também intercepta objetos MotionEvent.
  • A classe StylusViewModel contém os pontos de dados com uma interface LineManager.
  • A classe Segment define um segmento da seguinte maneira:
  • x1, y1: coordenadas do primeiro ponto
  • x2, y2: coordenadas do segundo ponto

As imagens a seguir mostram como os dados se movem entre cada classe:

MotionEvents são capturados por LowLatencySurfaceView e enviados ao onTouchListener para processamento. O onTouchListener processa e solicita a renderização do buffer frontal ou duplicado para GLFrontBufferRenderer. O GLFrontBufferRenderer é renderizado para LowLatencySurfaceView.

Criar uma plataforma e um layout de baixa latência

  1. No arquivo MainActivity.kt, encontre a função onCreate da classe MainActivity.
  2. No corpo da função onCreate, crie um objeto FastRenderer e transmita um objeto viewModel:

MainActivity.kt

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

      fastRendering = FastRenderer(viewModel)

      lifecycleScope.launch {
      ...
  1. No mesmo arquivo, crie uma função DrawAreaLowLatency Composable.
  2. No corpo da função, use a API AndroidView para unir a visualização LowLatencySurfaceView e fornecer o 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. Na função onCreate, depois do Composable Divider, adicione o Composable DrawAreaLowLatency ao layout:

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. No diretório gl, abra o arquivo LowLatencySurfaceView.kt e observe o seguinte na classe LowLatencySurfaceView:
  • A classe LowLatencySurfaceView estende a classe SurfaceView. Ela usa o método onTouchListener do objeto fastRenderer.
  • A interface GLFrontBufferedRenderer.Callback pela da classe fastRenderer precisa ser anexada ao objeto SurfaceView quando a função onAttachedToWindow é chamada. Assim, os callbacks podem ser renderizados na visualização SurfaceView.
  • A interface GLFrontBufferedRenderer.Callback pela da classe fastRenderer precisa ser liberada quando a função onDetachedFromWindow é chamada.

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

Gerenciar objetos MotionEvent com a interface onTouchListener

Para processar objetos MotionEvent quando a constante ACTION_DOWN for detectada, siga estas etapas:

  1. No diretório gl, abra o arquivo FastRenderer.kt.
  2. No corpo da constante ACTION_DOWN, crie uma variável currentX que armazene a coordenada x do objeto MotionEvent e uma variável currentY que armazene a coordenada y.
  3. Crie uma variável Segment que armazene um objeto Segment que aceite duas instâncias do parâmetro currentX e duas instâncias do parâmetro currentY por ser o início da linha.
  4. Chame o método renderFrontBufferedLayer com um parâmetro segment para acionar um callback na função 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 processar objetos MotionEvent quando a constante ACTION_MOVE for detectada, siga estas etapas:

  1. No corpo da constante ACTION_MOVE, crie uma variável previousX que armazene a variável currentX e outra previousY que armazene currentY.
  2. Crie uma variável currentX que salve a coordenada x atual do objeto MotionEvent e uma variável currentY que salve a coordenada y atual.
  3. Crie uma variável Segment que armazene um objeto Segment que aceite os parâmetros previousX, previousY, currentX e currentY.
  4. Chame o método renderFrontBufferedLayer com um parâmetro segment para acionar um callback na função onDrawFrontBufferedLayer e executar o 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 processar objetos MotionEvent quando a constante ACTION_UP for detectada, chame o método commit para acionar uma chamada na função onDrawDoubleBufferedLayer e execute o código OpenGL:

FastRenderer.kt

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

Implementar as funções de callback de GLFrontBufferedRenderer

No arquivo FastRenderer.kt, as funções de callback onDrawFrontBufferedLayer e onDrawDoubleBufferedLayer executam o código OpenGL. No início de cada função de callback, as seguintes funções do OpenGL mapeiam dados do Android para o espaço de trabalho do OpenGL:

  • A função GLES20.glViewport define o tamanho do retângulo em que você renderiza o cenário.
  • A função Matrix.orthoM calcula a matriz ModelViewProjection.
  • A função Matrix.multiplyMM executa a multiplicação de matrizes para transformar os dados do Android em uma referência do OpenGL e fornece a configuração para a 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)

Com essa parte do código configurada para você, é possível se concentrar no código que faz a renderização real. A função de callback onDrawFrontBufferedLayer renderiza uma pequena área da tela. Ele fornece um valor param do tipo Segment para que você possa renderizar um único segmento rapidamente. A classe LineRenderer é um renderizador OpenGL para o pincel que aplica a cor e o tamanho da linha.

Para implementar a função de callback onDrawFrontBufferedLayer, siga estas etapas:

  1. No arquivo FastRenderer.kt, encontre a função de callback onDrawFrontBufferedLayer.
  2. No corpo da função de callback onDrawFrontBufferedLayer, chame a função obtainRenderer para receber a instância LineRenderer.
  3. Chame o método drawLine da função LineRenderer com os seguintes parâmetros:
  • A matriz projection calculada anteriormente.
  • Uma lista de objetos Segment, que é um único segmento nesse caso.
  • A color da linha.

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. Execute o app e observe que você pode desenhar na tela com latência mínima. No entanto, o app não vai manter a linha porque ainda é necessário implementar a função de callback onDrawDoubleBufferedLayer.

A função de callback onDrawDoubleBufferedLayer é chamada após a função commit para permitir a persistência da linha. O callback fornece valores params, que contêm uma coleção de objetos Segment. Todos os segmentos do buffer frontal são reproduzidos novamente no buffer duplo para persistência.

Para implementar a função de callback onDrawDoubleBufferedLayer, siga estas etapas:

  1. No arquivo StylusViewModel.kt, encontre a classe StylusViewModel e crie uma variável openGlLines que armazene uma lista mutável 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. No arquivo FastRenderer.kt, encontre a função de callback onDrawDoubleBufferedLayer da classe FastRenderer.
  2. No corpo da função de callback onDrawDoubleBufferedLayer, limpe a tela com os métodos GLES20.glClearColor e GLES20.glClear para que o cenário possa ser renderizado do zero e adicione as linhas ao objeto viewModel para persistir:

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. Crie uma repetição for que itera e renderiza cada linha do 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. Execute o app e observe que você pode desenhar na tela. A linha é preservada depois que a constante ACTION_UP é acionada.

7. Implementar a previsão de movimento

Você pode melhorar ainda mais a latência com a biblioteca androidx.input, que analisa o curso da stylus, prevê o local do próximo ponto e o insere na renderização.

Para configurar a previsão de movimento, siga estas etapas:

  1. No arquivo app/build.gradle, importe a biblioteca na seção de dependências:

app/build.gradle

...
dependencies {
    ...
    implementation"androidx.input:input-motionprediction:1.0.0-beta01"
  1. Clique em File > Sync Project with Gradle Files.
  2. Na classe FastRendering do arquivo FastRendering.kt, declare o objeto motionEventPredictor como um atributo:

FastRenderer.kt

import androidx.input.motionprediction.MotionEventPredictor

class FastRenderer( ... ) {
   ...
   private var frontBufferRenderer: GLFrontBufferedRenderer<Segment>? = null
   private var motionEventPredictor: MotionEventPredictor? = null
  1. Na função attachSurfaceView, inicialize a variável motionEventPredictor:

FastRenderer.kt

class FastRenderer( ... ) {
   ...
   fun attachSurfaceView(surfaceView: SurfaceView) {
      frontBufferRenderer = GLFrontBufferedRenderer(surfaceView, this)
      motionEventPredictor = MotionEventPredictor.newInstance(surfaceView)
   }
  1. Na variável onTouchListener, chame o método motionEventPredictor?.record para que o objeto motionEventPredictor receba dados de movimento:

FastRendering.kt

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

A próxima etapa é prever um objeto MotionEvent com a função predict. Recomendamos prever quando uma constante ACTION_MOVE é recebida e depois que o objeto MotionEvent for registrado. Em outras palavras, você vai prever quando um traço está prestes a acontecer.

  1. Preveja um objeto MotionEvent artificial com o método predict.
  2. Crie um objeto Segment que use as coordenadas x e y atuais e previstas.
  3. Solicite a renderização rápida do segmento previsto com o 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)
              }

          }
          ...
       }

Os eventos previstos são inseridos para a renderização, o que melhora a latência.

  1. Execute o app e observe a latência aprimorada.

Melhorar a latência dará aos usuários da stylus uma experiência mais natural.

8. Parabéns

Parabéns! Você sabe usar a stylus como um profissional.

Você aprendeu a processar objetos MotionEvent para extrair as informações sobre pressão, orientação e inclinação. Você também aprendeu a melhorar a latência implementando as bibliotecas androidx.graphics e androidx.input. Essas melhorias implementadas em conjunto oferecem uma experiência de stylus mais orgânica.

Saiba mais