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
- La versión más reciente de Android Studio
- Tener experiencia con la sintaxis de Kotlin, incluidas las funciones lambdas
- Tener experiencia básica con Compose (si no estás familiarizado con Compose, completa el codelab Aspectos básicos de Jetpack Compose)
- Un dispositivo que admita plumas stylus
- Una pluma stylus activa
- Git
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:
- Clona este repositorio de GitHub:
git clone https://github.com/android/large-screen-codelabs
- Abre la carpeta
advanced-stylus
. La carpetastart
incluye el código de partida, y la carpetaend
, 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 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:
- En Android Studio, abre el repositorio clonado.
- Haz clic en
app
>java
>com.example.stylus
y haz doble clic enMainActivity
. Se abrirá el archivoMainActivity.kt
. - En la clase
MainActivity
, observa las funcionesComposable
deStylusVisualization
yDrawArea
. En esta sección, te enfocas en la funciónComposable
deDrawArea
.
Crea una clase StylusState
.
- En el mismo directorio
ui
, haz clic en File > New > Kotlin/Class file. - En el cuadro de texto, reemplaza el marcador de posición Name por
StylusState.kt
y, luego, presionaEnter
(oreturn
en macOS). - En el archivo
StylusState.kt
, crea la clase de datosStylusState
y, luego, agrega las variables de la siguiente tabla:
Variable | Tipo | Valor predeterminado | Descripción |
|
| Es un valor que varía de 0 a 1.0. | |
|
| Es un valor de radianes que varía de -pi a pi. | |
|
| Es un valor de radianes que varía de 0 a pi/2. | |
|
| Almacena líneas renderizadas por la función |
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(),
)
- En el archivo
MainActivity.kt
, busca la claseMainActivity
y, luego, agrega el estado de la pluma stylus con la funciónmutableStateOf()
:
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 |
|
| Coordenada |
|
| Coordenada |
|
| Tipo de punto |
Existen dos tipos de objetos DrawPoint
, que describe el enum DrawPointType
:
Tipo | Descripción |
| Mueve el inicio de una línea a una posición. |
| 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 objetosDrawPoint
:
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:
- En la clase
StylusViewModel
del archivoStylusViewModel.kt
, agrega una funcióncreatePath
. - Crea una variable
path
de tipoPath
con el constructorPath()
. - Crea un bucle
for
en el que iteres a través de cada dato en la variablecurrentPath
. - Si los datos son del tipo
START
, llama al métodomoveTo
para iniciar una línea en las coordenadasx
yy
especificadas. - De lo contrario, llama al método
lineTo
con las coordenadasx
yy
de los datos para vincularlos al punto anterior. - 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 |
| El puntero toca la pantalla. Es el inicio de una línea en la posición que informa el objeto |
| El puntero se mueve en la pantalla. Es la línea que se dibuja. |
| El puntero deja de tocar la pantalla. Es el final de la línea. |
| 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 claseStylusViewModel
, 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:
- En la clase
StylusViewModel
, crea una variable_stylusState
de un tipoMutableStateFlow
de la claseStylusState
y una variablestylusState
de un tipoStateFlow
de la claseStylusState
. La variable_stylusState
se modifica cada vez que se cambia el estado de la pluma stylus en la claseStylusViewModel
y la IU consume la variablestylusState
en la claseMainActivity
.
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
- Crea una función
requestRendering
que acepte un parámetro del objetoStylusState
:
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
}
}
- Al final de la función
processMotionEvent
, agrega una llamada a la funciónrequestRendering
con un parámetroStylusState
. - En el parámetro
StylusState
, recupera los valores de inclinación, presión y orientación de la variablemotionEvent
y, luego, crea la ruta con una funcióncreatePath()
. 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()
)
)
Vincula la IU con la clase StylusViewModel
- En la clase
MainActivity
, busca la funciónsuper.onCreate
de la funciónonCreate
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.
- En el cuerpo de la función
Composable
deDrawArea
, agrega el modificadorpointerInteropFilter
a la funciónComposable
deCanvas
para proporcionar objetosMotionEvent
.
- Envía el objeto
MotionEvent
a la funciónprocessMotionEvent
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)
}
) {
}
}
- Llama a la función
drawPath
con el atributopath
destylusState
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
)
}
}
}
- 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
- En el archivo
MainActivity.kt
, busca la funciónComposable
deStylusVisualization
y, luego, usa la información del objeto de flujoStylusState
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)
}
}
}
- 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.
- Con una pluma stylus, haz garabatos en la pantalla y observa cómo reacciona cada visualización con las entradas.
- 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 claseStylusViewModel
y, luego, crea una funcióncancelLastStroke
que encuentre el índice del último datoSTART
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
.
- En el archivo
StylusViewModel.kt
, busca la funciónprocessMotionEvent
. - En la constante
ACTION_UP
, crea una variablecanceled
que verifica si la versión actual del SDK es Android 13 o una posterior, y si la constanteFLAG_CANCELED
está activada. - En la siguiente línea, crea un condicional que verifique si la variable
canceled
es verdadera. Si es así, llama a la funcióncancelLastStroke
para quitar el último conjunto de objetosMotionEvent
. De lo contrario, llama al métodocurrentPath.add
para agregar el último conjunto de objetosMotionEvent
.
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))
}
}
- En la constante
ACTION_CANCEL
, observa la funcióncancelLastStroke
:
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:
- En Android Studio, abre la carpeta
low-latency
para obtener todos los archivos necesarios: - Observa los siguientes archivos nuevos en el proyecto:
- En el archivo
build.gradle
, se importó la bibliotecaandroidx.graphics
con la declaraciónimplementation "androidx.graphics:graphics-core:1.0.0-alpha03"
. - La clase
LowLatencySurfaceView
extiende la claseSurfaceView
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 interfazGLFrontBufferedRenderer.Callback
. También intercepta objetosMotionEvent
. - La clase
StylusViewModel
contiene los datos con una interfazLineManager
. - La clase
Segment
define un segmento de la siguiente manera: x1
,y1
: coordenadas del primer puntox2
,y2
: coordenadas del segundo punto
En las siguientes imágenes, se muestra cómo se mueven los datos entre cada clase:
Crea una superficie y un diseño de baja latencia
- En el archivo
MainActivity.kt
, busca la funciónonCreate
de la claseMainActivity
. - En el cuerpo de la función
onCreate
, crea un objetoFastRenderer
y, luego, pasa un objetoviewModel
:
MainActivity.kt
class MainActivity : ComponentActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
fastRendering = FastRenderer(viewModel)
lifecycleScope.launch {
...
- En el mismo archivo, crea una función
Composable
deDrawAreaLowLatency
. - En el cuerpo de la función, usa la API de
AndroidView
para unir la vistaLowLatencySurfaceView
y, luego, proporcionar el objetofastRendering
:
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)
}
- En la función
onCreate
después de la funciónComposable
deDivider
, agrega la funciónComposable
deDrawAreaLowLatency
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()
}
}
- En el directorio
gl
, abre el archivoLowLatencySurfaceView.kt
y, luego, observa lo siguiente en la claseLowLatencySurfaceView
:
- La clase
LowLatencySurfaceView
extiende la claseSurfaceView
. Usa el métodoonTouchListener
del objetofastRenderer
. - La interfaz
GLFrontBufferedRenderer.Callback
a través de la clasefastRenderer
debe adjuntarse al objetoSurfaceView
cuando se llama a la funciónonAttachedToWindow
para que las devoluciones de llamada se puedan renderizar en la vistaSurfaceView
. - La interfaz
GLFrontBufferedRenderer.Callback
a través de la clasefastRenderer
debe liberarse cuando se llama a la funciónonDetachedFromWindow
.
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:
- En el directorio
gl
, abre el archivoFastRenderer.kt
. - En el cuerpo de la constante
ACTION_DOWN
, crea una variablecurrentX
que almacene la coordenadax
del objetoMotionEvent
y una variablecurrentY
que almacene su coordenaday
. - Crea una variable
Segment
que almacene un objetoSegment
que acepte dos instancias del parámetrocurrentX
y dos instancias del parámetrocurrentY
porque es el inicio de la línea. - Llama al método
renderFrontBufferedLayer
con un parámetrosegment
para activar una devolución de llamada en la funciónonDrawFrontBufferedLayer
.
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:
- En el cuerpo de la constante
ACTION_MOVE
, crea una variablepreviousX
que almacene la variablecurrentX
y una variablepreviousY
que almacene la variablecurrentY
. - Crea una variable
currentX
que guarda la coordenadax
actual del objetoMotionEvent
y una variablecurrentY
que guarda la coordenaday
actual. - Crea una variable
Segment
que almacene un objetoSegment
que acepte los parámetrospreviousX
,previousY
,currentX
ycurrentY
. - Llama al método
renderFrontBufferedLayer
con un parámetrosegment
para activar una devolución de llamada en la funciónonDrawFrontBufferedLayer
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 constanteACTION_UP
, llama al métodocommit
para activar una llamada en la funciónonDrawDoubleBufferedLayer
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 matrizModelViewProjection
. - 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 matrizprojection
.
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:
- En el archivo
FastRenderer.kt
, busca la función de devolución de llamadaonDrawFrontBufferedLayer
. - En el cuerpo de la función de devolución de llamada
onDrawFrontBufferedLayer
, llama a la funciónobtainRenderer
para obtener la instanciaLineRenderer
. - Llama al método
drawLine
de la funciónLineRenderer
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())
}
- 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:
- En el archivo
StylusViewModel.kt
, busca la claseStylusViewModel
y, luego, crea una variableopenGlLines
que almacene una lista mutable de objetosSegment
:
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) {
- En el archivo
FastRenderer.kt
, busca la función de devolución de llamadaonDrawDoubleBufferedLayer
de la claseFastRenderer
. - En el cuerpo de la función de devolución de llamada
onDrawDoubleBufferedLayer
, borra la pantalla con los métodosGLES20.glClearColor
yGLES20.glClear
para que se pueda renderizar la escena desde cero y agrega las líneas al objetoviewModel
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())
- Crea un bucle
for
que itere y renderice cada línea desde el objetoviewModel
:
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())
}
}
- 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:
- 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"
- Haz clic en File > Sync Project with Gradle Files.
- En la clase
FastRendering
del archivoFastRendering.kt
, declara el objetomotionEventPredictor
como un atributo:
FastRenderer.kt
import androidx.input.motionprediction.MotionEventPredictor
class FastRenderer( ... ) {
...
private var frontBufferRenderer: GLFrontBufferedRenderer<Segment>? = null
private var motionEventPredictor: MotionEventPredictor? = null
- En la función
attachSurfaceView
, inicializa la variablemotionEventPredictor
:
FastRenderer.kt
class FastRenderer( ... ) {
...
fun attachSurfaceView(surfaceView: SurfaceView) {
frontBufferRenderer = GLFrontBufferedRenderer(surfaceView, this)
motionEventPredictor = MotionEventPredictor.newInstance(surfaceView)
}
- En la variable
onTouchListener
, llama al métodomotionEventPredictor?.record
para que el objetomotionEventPredictor
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.
- Predice un objeto
MotionEvent
artificial con el métodopredict
- Crea un objeto
Segment
que use las coordenadas x e y previstas y actuales. - 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.
- 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.