Optimización de tu app para Android en Chrome OS

Gracias a la capacidad de ejecutar apps para Android en Chromebooks, los usuarios ahora disponen de un enorme ecosistema de apps y una gran funcionalidad nueva. Si bien las noticias son excelentes para los desarrolladores, se deben implementar algunas optimizaciones de la app a fin de cumplir con las expectativas de usabilidad y ofrecer una excelente experiencia del usuario. En este codelab, se revisarán las optimizaciones más comunes.

f60cd3eb5b298d5d.png

Qué compilarás

Compilarás una app para Android funcional que demuestre las prácticas recomendadas y optimizaciones para Chrome OS. Tu app hará lo siguiente:

Administrará las entradas del teclado, por ejemplo:

  • Tecla Intro
  • Teclas de flecha
  • Las combinaciones de teclas con Ctrl y con Ctrl + Mayúsculas
  • Los comentarios visuales de los elementos seleccionados

Administrará las entradas del mouse, entre ellas:

  • Hacer clic con el botón derecho
  • Los efectos que se generan cuando se coloca el cursor sobre un elemento
  • Información sobre las herramientas
  • Arrastrar y soltar

Usará los componentes de la arquitectura a fin de realizar lo siguiente:

  • Mantener el estado
  • Actualizar la IU automáticamente

52240dc3e68f7af8.png

Qué aprenderás

  • Las prácticas recomendadas para administrar las entradas del teclado y del mouse en Chrome OS
  • Las optimizaciones específicas para Chrome OS
  • La implementación básica de los componentes de la arquitectura ViewModel y LiveData

Requisitos

Clona el repositorio desde GitHub

git clone https://github.com/googlecodelabs/optimized-for-chromeos

… o descarga un archivo ZIP del repositorio y extráelo

Download Zip

Cómo importar el proyecto

  • Abre Android Studio.
  • Selecciona Import Project o File > New > Import Project.
  • Navega hasta donde clonaste o extrajiste el proyecto.
  • Importa el proyecto optimized-for-chromeos.
  • Observa que hay dos módulos: start y complete.

Cómo probar la App

  • Compila y ejecuta el módulo start.
  • Para empezar, usa solo el panel táctil.
  • Haz clic en los dinosaurios.
  • Envía algunos mensajes secretos.
  • Intenta arrastrar el texto "Drag Me" o suelta un archivo en la zona "Drop Things Here".
  • Usa el teclado para navegar y enviar mensajes.
  • Usa la app en el modo tablet.
  • Gira el dispositivo o cambia el tamaño de la ventana.

¿Qué opinas?

Aunque esta app es bastante básica y las partes que parecen tener problemas son fáciles de resolver, la experiencia del usuario resulta horrible. Vamos a solucionarlo.

a40270071a9b5ac3.png

Si escribiste algunos mensajes secretos con el teclado, habrás notado que la tecla Enter no hace nada. Esto es frustrante para el usuario.

Encontraremos la solución en el código de muestra que aparece a continuación y en la documentación llamada Cómo controlar las acciones del teclado.

MainActivity.kt (onCreate)

// Enter key listener
edit_message.setOnKeyListener(View.OnKeyListener { v, keyCode, keyEvent ->
    if (keyEvent.action == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_ENTER) {
        button_send.performClick()
        return@OnKeyListener true
    }
    false
})

¡Pruébalo! Poder enviar mensajes usando solamente el teclado es una experiencia del usuario mucho más agradable.

¿No sería genial navegar por esta app usando solamente el teclado? De la manera actual, la experiencia resulta deficiente: es frustrante para el usuario cuando este cuenta con un teclado y la aplicación no responde a él.

Una de las formas más fáciles de hacer que las vistas sean navegables mediante las teclas Tab y de flecha es hacerlas enfocables.

Revisa los archivos de diseño y mira las etiquetas Button y ImageView. Observa que el atributo focusable está configurado como falso. Cámbialo a verdadero en XML:

activity_main.xml

android:focusable="true"

O bien de forma programática:

MainActivity.kt

button_send.setFocusable(true)
image_dino_1.setFocusable(true)
image_dino_2.setFocusable(true)
image_dino_3.setFocusable(true)
image_dino_4.setFocusable(true)

Pruébalo. Deberías poder usar las teclas de flecha y la tecla Enter para seleccionar dinosaurios, aunque, según la versión del SO, la pantalla y la luz, tal vez no puedas ver cuál elemento se seleccionó. A fin de mejorar esto, establece el recurso del fondo de las imágenes en R.attr.selectableItemBackground.

MainActivity.kt (onCreate)

val highlightValue = TypedValue()
theme.resolveAttribute(R.attr.selectableItemBackground, highlightValue, true)

image_dino_1.setBackgroundResource(highlightValue.resourceId)
image_dino_2.setBackgroundResource(highlightValue.resourceId)
image_dino_3.setBackgroundResource(highlightValue.resourceId)
image_dino_4.setBackgroundResource(highlightValue.resourceId)

En general, con Android se puede definir bastante bien la View que se encuentra encima de la View enfocada, así como la que está debajo de ella o a su izquierda o su derecha. ¿Qué tan bien funciona eso en esta app? Asegúrate de probar tanto las teclas de flecha como la tecla Tab. Desplázate entre el campo de mensajes y el botón Enviar con las teclas de flecha. Ahora selecciona el triceratops y presiona Tab. ¿Cambia el enfoque a la vista que esperabas?

En este ejemplo, las cosas se muestran (intencionalmente) con algunos problemas. Como usuarios, estos pequeños problemas en las respuestas de las entradas nos pueden resultar muy frustrantes.

A fin de ajustar de forma manual el comportamiento general de las teclas Tab y de flecha, puedes usar lo siguiente:

Teclas de flecha

android:nextFocusLeft="@id/view_to_left"
android:nextFocusRight="@id/view_to_right"
android:nextFocusUp="@id/view_above"
android:nextFocusDown="@id/view_below"

Tecla Tab

android:nextFocusForward="@id/next_view"

O bien de forma programática:

Teclas de flecha

myView.nextFocusLeftId = R.id.view_to_left
myView.nextFocusRightId = R.id.view_to_right
myView.nextFocusTopId = R.id.view_above
myView.nextFocusBottomId = R.id.view_below

Tecla Tab

myView.nextFocusForwardId - R.id.next_view

En este ejemplo, se puede corregir el orden del foco mediante los siguientes elementos:

MainActivity.kt

edit_message.nextFocusForwardId = R.id.button_send
edit_message.nextFocusRightId = R.id.button_send
button_send.nextFocusForwardId = R.id.image_dino_1
button_send.nextFocusLeftId = R.id.edit_message
image_dino_2.nextFocusForwardId = R.id.image_dino_3
image_dino_3.nextFocusForwardId = R.id.image_dino_4

Ahora puedes seleccionar dinosaurios; pero, según la pantalla, las condiciones de iluminación, la vista y tu propia visión, tal vez no sea fácil ver que los elementos seleccionados aparecen destacados. Por ejemplo, en la imagen que se muestra a continuación, la configuración predeterminada es gris sobre gris.

c0ace19128e548fe.png

A fin de brindar comentarios visuales más prominentes a tus usuarios, agrega lo siguiente a res/values/styles.xml en AppTheme:

res/values/styles.xml

<item name="colorControlHighlight">@color/colorAccent</item>

23a53d405efe5602.png

Ese color rosa es muy lindo, pero el tipo de resaltado en la imagen de arriba tal vez sea demasiado llamativo para lo que quieres destacar y podría parecer desordenado si todas las imágenes no tienen exactamente las mismas dimensiones. Mediante un elemento de diseño de lista de estados, puedes crear un elemento de diseño de borde que solo aparezca cuando se seleccione un elemento.

res/drawable/box_border.xml

<?xml version="1.0" encoding="UTF-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
   <item android:state_focused="true">
       <shape android:padding="2dp">
           <solid android:color="#FFFFFF" />
           <stroke android:width="1dp" android:color="@color/colorAccent" />
           <padding android:left="2dp" android:top="2dp" android:right="2dp"
               android:bottom="2dp" />
       </shape>
   </item>
</selector>

Ahora reemplaza las líneas highlightValue/setBackgroundResource del paso anterior por el nuevo recurso en segundo plano box_border:

MainActivity.kt (onCreate)

image_dino_1.setBackgroundResource(R.drawable.box_border)
image_dino_2.setBackgroundResource(R.drawable.box_border)
image_dino_3.setBackgroundResource(R.drawable.box_border)
image_dino_4.setBackgroundResource(R.drawable.box_border)

77ac1e50cdfbea01.png

631df359631b28bb.png

Los usuarios que utilizan el teclado esperan que funcionen las combinaciones de teclas comunes basadas en la tecla Ctrl. Así que ahora agregarás las combinaciones de teclas Deshacer (Ctrl + Z) y Rehacer (Ctrl + Mayúsculas + Z) a la app.

Primero, crea una pila de historial de clics simple. Imagina que un usuario realizó 5 acciones y presiona Ctrl + Z dos veces: las acciones 4 y 5 estarán en la pila Rehacer, mientras que 1, 2 y 3 estarán en la pila Deshacer. Si el usuario presiona Ctrl + Z nuevamente, la acción 3 se moverá de la pila Deshacer a la pila Rehacer. Si luego presiona Ctrl + Mayúsculas + Z, la acción 3 se moverá de la pila Rehacer a la pila Deshacer.

9d952ca72a5640d7.png

En la parte superior de la clase principal, define las diferentes acciones de clic y crea las pilas mediante ArrayDeque.

MainActivity.kt

private var undoStack = ArrayDeque<Int>()
private var redoStack = ArrayDeque<Int>()

private val UNDO_MESSAGE_SENT = 1
private val UNDO_DINO_CLICKED = 2

Cuando se envíe un mensaje o se haga clic en un dinosaurio, agrega esa acción a la pila Deshacer. Cuando se realice una acción nueva, borra la pila Rehacer. Actualiza tus objetos de escucha de clics de la siguiente manera:

MainActivity.kt

//In button_send onClick listener
undoStack.push(UNDO_MESSAGE_SENT)
redoStack.clear()

...

//In ImageOnClickListener
undoStack.push(UNDO_DINO_CLICKED)
redoStack.clear()

Ahora, mapea las combinaciones de teclas. Se puede agregar compatibilidad con los comandos Ctrl + y, en Android O y versiones posteriores, con los comandos Alt + y Mayúsculas + mediante dispatchKeyShortcutEvent.

MainActivity.kt (dispatchKeyShortcutEvent)

override fun dispatchKeyShortcutEvent(event: KeyEvent): Boolean {
    if (event.getKeyCode() == KeyEvent.KEYCODE_Z) {
        // Undo action
        return true
    }
    return super.dispatchKeyShortcutEvent(event)
}

Seamos meticulosos en este caso. A fin de insistir en que solo Ctrl + Z active la devolución de llamada y no Alt + Z ni Mayúsculas + Z, usa hasModifiers. Las operaciones de la pila Deshacer aparecen completas a continuación.

MainActivity.kt (dispatchKeyShortcutEvent)

override fun dispatchKeyShortcutEvent(event: KeyEvent): Boolean {
    // Ctrl-z == Undo
    if (event.keyCode == KeyEvent.KEYCODE_Z && event.hasModifiers(KeyEvent.META_CTRL_ON)) {
        val lastAction = undoStack.poll()
        if (null != lastAction) {
            redoStack.push(lastAction)

            when (lastAction) {
                UNDO_MESSAGE_SENT -> {
                    messagesSent--
                    text_messages_sent.text = (Integer.toString(messagesSent))
                }

                UNDO_DINO_CLICKED -> {
                    dinosClicked--
                    text_dinos_clicked.text = Integer.toString(dinosClicked)
                }

                else -> Log.d("OptimizedChromeOS", "Error on Ctrl-z: Unknown Action")
            }

            return true
        }
    }
    return super.dispatchKeyShortcutEvent(event)
}

Pruébalo. ¿Funciona como lo esperabas? Ahora, agrega Ctrl + Mayúsculas + Z usando OR con las funciones experimentales de tecla modificadora.

MainActivity.kt (dispatchKeyShortcutEvent)

// Ctrl-Shift-z == Redo
if (event.keyCode == KeyEvent.KEYCODE_Z &&
    event.hasModifiers(KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_ON)) {
    val prevAction = redoStack.poll()
    if (null != prevAction) {
        undoStack.push(prevAction)

        when (prevAction) {
            UNDO_MESSAGE_SENT -> {
                messagesSent++
                text_messages_sent.text = (Integer.toString(messagesSent))
            }

            UNDO_DINO_CLICKED -> {
                dinosClicked++
                text_dinos_clicked.text = Integer.toString(dinosClicked)
            }

            else -> Log.d("OptimizedChromeOS", "Error on Ctrl-Shift-z: Unknown Action")
        }

        return true
    }
}

En muchas interfaces, los usuarios suponen que, si hacen clic con el botón derecho del mouse o presionan dos veces en un panel táctil, aparecerá un menú contextual. En esta aplicación, queremos ofrecer este menú contextual para que los usuarios puedan enviar estas imágenes geniales de dinosaurios a un amigo.

8b8c4a377f5e743b.png

La creación de un menú contextual incluye automáticamente la función de clic derecho. En muchos casos, esto es todo lo que necesitas. Esta configuración tiene 3 partes:

Informar a la IU que esta vista tiene un menú contextual

Usa registerForContextMenu en cada vista que quieras que cuente con un menú contextual. En este caso, las 4 imágenes.

MainActivity.kt

registerForContextMenu(image_dino_1)
registerForContextMenu(image_dino_2)
registerForContextMenu(image_dino_3)
registerForContextMenu(image_dino_4)

Definir la apariencia del menú contextual

Diseña un menú en XML que contenga todas las opciones contextuales que necesitas. Para ello, solo tienes que agregar "Compartir".

res/menu/context_menu.xml

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <item
        android:id="@+id/menu_item_share_dino"
        android:icon="@android:drawable/ic_menu_share"
        android:title="@string/menu_share" />
</menu>

Luego, en tu clase de actividad principal, anula onCreateContextMenu y pasa el archivo en formato XML.

MainActivity.kt

override fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenu.ContextMenuInfo?) {
    super.onCreateContextMenu(menu, v, menuInfo)
    val inflater = menuInflater
    inflater.inflate(R.menu.context_menu, menu)
}

Definir las acciones que se deben realizar cuando se selecciona un elemento específico

Por último, anula onContextItemSelected a fin de definir la acción que se deberá realizar. Aquí, solo muestra una Snackbar rápida que informa al usuario que la imagen se compartió correctamente.

MainActivity.kt

override fun onContextItemSelected(item: MenuItem): Boolean {
    if (R.id.menu_item_share_dino == item.itemId) {
        Snackbar.make(findViewById(android.R.id.content),
            getString(R.string.menu_shared_message), Snackbar.LENGTH_SHORT).show()
        return true
    } else {
        return super.onContextItemSelected(item)
    }
}

¡Pruébalo! El menú contextual debería aparecer cuando hagas clic con el botón derecho en una imagen.

MainActivity.kt

myView.setOnContextClickListener {
    // Display right-click options
    true
}

Agregar texto de información sobre la herramienta que aparezca cuando se coloque el cursor sobre un elemento es una manera fácil de ayudar a los usuarios a comprender el funcionamiento de tu IU o de brindar información adicional.

17639493329a9d1a.png

Agrega información sobre la herramienta para cada una de las fotos con el nombre del dinosaurio mediante el método setTootltipText().

MainActivity.kt

// Add dino tooltips
TooltipCompat.setTooltipText(image_dino_1, getString(R.string.name_dino_hadrosaur))
TooltipCompat.setTooltipText(image_dino_2, getString(R.string.name_dino_triceratops))
TooltipCompat.setTooltipText(image_dino_3, getString(R.string.name_dino_nodosaur))
TooltipCompat.setTooltipText(image_dino_4, getString(R.string.name_dino_afrovenator))

Puede resultar útil agregar un efecto de comentarios visuales en ciertas vistas cuando un dispositivo apuntador se coloca sobre ellas.

Agrega este tipo de comentarios por medio del siguiente código a fin de hacer que el botón Enviar se vuelva verde cuando el cursor del mouse se coloque sobre él.

MainActivity.kt (onCreate)

button_send.setOnHoverListener(View.OnHoverListener { v, event ->
    val action = event.actionMasked

    when (action) {
        ACTION_HOVER_ENTER -> {
            val buttonColorStateList = ColorStateList(
                arrayOf(intArrayOf()),
                intArrayOf(Color.argb(127, 0, 255, 0))
            )
            button_send.setBackgroundTintList(buttonColorStateList)
            return@OnHoverListener true
        }

        ACTION_HOVER_EXIT -> {
            button_send.setBackgroundTintList(null)
            return@OnHoverListener true
        }
    }

    false
})

Agrega un efecto más cuando se coloque el cursor sobre un elemento: Cambia la imagen de fondo asociada con el elemento TextView arrastrable, de modo que el usuario sepa que el texto puede arrastrarse.

MainActivity.kt (onCreate)

text_drag.setOnHoverListener(View.OnHoverListener { v, event ->
    val action = event.actionMasked

    when (action) {
        ACTION_HOVER_ENTER -> {
            text_drag.setBackgroundResource(R.drawable.hand)
            return@OnHoverListener true
        }

        ACTION_HOVER_EXIT -> {
            text_drag.setBackgroundResource(0)
            return@OnHoverListener true
        }
    }

    false
})

¡Pruébalo! Deberías ver un gráfico de mano grande cuando se coloque el cursor del mouse sobre el texto "Drag Me!". Incluso este comentario llamativo permite que la experiencia del usuario sea más táctil.

Para obtener más información, consulta la documentación de View.OnHoverListener y MotionEvent.

En un entorno de escritorio, es natural arrastrar y soltar elementos en una app, especialmente desde el administrador de archivos de Chrome OS. En este paso, configura un destino en el cual se podrán soltar archivos o elementos de texto sin formato. En la próxima sección del codelab, implementaremos un elemento arrastrable.

cfbc5c9d8d28e5c5.gif

Primero, crea un OnDragListener vacío. Observa su estructura antes de comenzar a programar:

MainActivity.kt

protected inner class DropTargetListener(private val activity: AppCompatActivity
) : View.OnDragListener {
    override fun onDrag(v: View, event: DragEvent): Boolean {
        val action = event.action

        when (action) {
            DragEvent.ACTION_DRAG_STARTED -> {
                    return true
            }

            DragEvent.ACTION_DRAG_ENTERED -> {
                return true
            }

            DragEvent.ACTION_DRAG_EXITED -> {
                return true
            }

            DragEvent.ACTION_DRAG_ENDED -> {
                return true
            }

            DragEvent.ACTION_DROP -> {
                return true
            }

            else -> {
                Log.d("OptimizedChromeOS", "Unknown action type received by DropTargetListener.")
                return false
            }
        }
    }
}

Se llamará al método onDrag() cada vez que ocurra cualquiera de los distintos eventos de arrastre: iniciar un arrastre, colocar el cursor sobre una zona en la que se soltará un elemento o bien soltar un elemento. A continuación, se muestra un resumen de los diferentes eventos de arrastre:

  • ACTION_DRAG_STARTED se activa cuando se arrastra cualquier elemento. La zona de destino deberá buscar elementos válidos que pueda recibir y deberá proporcionar una indicación visual de que ese destino está listo.
  • ACTION_DRAG_ENTERED y ACTION_DRAG_EXITED se activan cuando se arrastra un elemento y ese elemento entra a la zona en la que se soltará o sale de ella. Debes proporcionar comentarios visuales a fin de informarle al usuario que puede soltar el elemento.
  • ACTION_DROP se activa cuando se suelta el elemento. Procesa el elemento aquí.
  • ACTION_DRAG_ENDED se activa cuando la acción de soltar se completa correctamente o se cancela. Regresa la IU a su estado normal.

ACTION_DRAG_STARTED

Este evento se activa cuando se inicia cualquier acción de arrastre. Indica aquí si un destino puede recibir un elemento en particular (muestra un valor verdadero) o no (muestra un valor falso) y avísale visualmente al usuario. El evento de arrastre contendrá una ClipDescription con información sobre el elemento que se está arrastrando.

Para determinar si este objeto de escucha de arrastre puede recibir un elemento, revisa el tipo de MIME del elemento. En este ejemplo, indica que el objetivo es válido ajustando el tono verde claro del fondo.

MainActivity.kt

DragEvent.ACTION_DRAG_STARTED -> {
    // Limit the types of items that can be received
    if (event.clipDescription.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) ||
        event.clipDescription.hasMimeType("application/x-arc-uri-list")) {

        // Greenify background colour so user knows this is a target
        v.setBackgroundColor(Color.argb(55, 0, 255, 0))
        return true
    }

    // If the dragged item is of an unrecognized type, indicate this is not a valid target
    return false
}

ENTERED, EXITED y ENDED

ENTERED y EXITED son los lugares adonde irá la lógica de respuesta táctil o visual. En este ejemplo, oscurece el verde cuando se coloque el elemento sobre la zona de destino a fin de indicarle al usuario que puede soltarlo. En ENDED, restablece la IU a su estado normal en el que no se está arrastrando y soltando nada.

MainActivity.kt

DragEvent.ACTION_DRAG_ENTERED -> {
    // Increase green background colour when item is over top of target
    v.setBackgroundColor(Color.argb(150, 0, 255, 0))
    return true
}

DragEvent.ACTION_DRAG_EXITED -> {
    // Less intense green background colour when item not over target
    v.setBackgroundColor(Color.argb(55, 0, 255, 0))
    return true
}

DragEvent.ACTION_DRAG_ENDED -> {
    // Restore background colour to transparent
    v.setBackgroundColor(Color.argb(0, 255, 255, 255))
    return true
}

ACTION_DROP

Este es el evento que se produce cuando se suelta el elemento en la zona de destino. Aquí es donde se realiza el procesamiento.

Nota: Se debe acceder a los archivos de Chrome OS mediante ContentResolver.

En esta demostración, el destino podría recibir un objeto de texto sin formato o un archivo. En el caso del texto sin formato, muestra el texto en la TextView. Si se trata de un archivo, copia los primeros 200 caracteres y muéstralos.

MainActivity.kt

DragEvent.ACTION_DROP -> {
    requestDragAndDropPermissions(event) // Allow items from other applications
    val item = event.clipData.getItemAt(0)
    val textTarget = v as TextView

    if (event.clipDescription.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN)) {
        // If this is a text item, simply display it in a new TextView.
        textTarget.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f)
        textTarget.text = item.text
        // In STEP 10, replace line above with this
        // dinoModel.setDropText(item.text.toString())
    } else if (event.clipDescription.hasMimeType("application/x-arc-uri-list")) {
        // If a file, read the first 200 characters and output them in a new TextView.

        // Note the use of ContentResolver to resolve the ChromeOS content URI.
        val contentUri = item.uri
        val parcelFileDescriptor: ParcelFileDescriptor?
        try {
            parcelFileDescriptor = contentResolver.openFileDescriptor(contentUri, "r")
        } catch (e: FileNotFoundException) {
            e.printStackTrace()
            Log.e("OptimizedChromeOS", "Error receiving file: File not found.")
            return false
        }

        if (parcelFileDescriptor == null) {
            textTarget.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f)
            textTarget.text = "Error: could not load file: " + contentUri.toString()
            // In STEP 10, replace line above with this
            // dinoModel.setDropText("Error: could not load file: " + contentUri.toString())
            return false
        }

        val fileDescriptor = parcelFileDescriptor.fileDescriptor

        val MAX_LENGTH = 5000
        val bytes = ByteArray(MAX_LENGTH)

        try {
            val `in` = FileInputStream(fileDescriptor)
            try {
                `in`.read(bytes, 0, MAX_LENGTH)
            } finally {
                `in`.close()
            }
        } catch (ex: Exception) {
        }

        val contents = String(bytes)

        val CHARS_TO_READ = 200
        val content_length = if (contents.length > CHARS_TO_READ) CHARS_TO_READ else 0

        textTarget.setTextSize(TypedValue.COMPLEX_UNIT_SP, 10f)
        textTarget.text = contents.substring(0, content_length)
        // In STEP 10, replace line above with this
        // dinoModel.setDropText(contents.substring(0, content_length))
    } else {
        return false
    }
    return true
}

OnDragListener

Ahora que el DropTargetListener está configurado, adjúntalo a la vista que deseas que reciba los elementos soltados.

MainActivity.kt

text_drop.setOnDragListener(DropTargetListener(this))

¡Pruébalo! Recuerda que deberás arrastrar archivos del administrador de archivos de Chrome OS. Puedes crear un archivo de texto con el editor de texto de Chrome OS o descargar un archivo de imagen de Internet.

Ahora, configura un elemento arrastrable en tu app. Por lo general, un proceso de arrastre se activa cuando se mantiene presionada una vista. Para indicar que se puede arrastrar un elemento, crea un LongClickListener que proporcione al sistema tanto los datos que se transferirán como su tipo. Aquí también configurarás el aspecto que tendrá el elemento mientras se arrastre.

Configura un elemento de arrastre de texto sin formato que extraiga una string de una TextView. Establece el tipo de MIME del contenido en ClipDescription.MIMETYPE_TEXT_PLAIN.

Determina el aspecto visual durante el arrastre mediante el DragShadowBuilder integrado a fin de obtener una apariencia traslúcida estándar. Para ver un ejemplo más complejo, consulta Cómo comenzar un arrastre en la documentación.

Recuerda establecer la función experimental DRAG_FLAG_GLOBAL a efectos de indicar que este elemento se podrá arrastrar a otras apps.

MainActivity.kt

protected inner class TextViewLongClickListener : View.OnLongClickListener {
    override fun onLongClick(v: View): Boolean {
        val thisTextView = v as TextView
        val dragContent = "Dragged Text: " + thisTextView.text

        //Set the drag content and type
        val item = ClipData.Item(dragContent)
        val dragData = ClipData(dragContent, arrayOf(ClipDescription.MIMETYPE_TEXT_PLAIN), item)

        //Set the visual look of the dragged object
        //Can be extended and customized. We use the default here.
        val dragShadow = View.DragShadowBuilder(v)

        // Starts the drag, note: global flag allows for cross-application drag
        v.startDragAndDrop(dragData, dragShadow, null, View.DRAG_FLAG_GLOBAL)

        return false
    }
}

Ahora, agrega el LongClickListener a la TextView arrastrable.

MainActivity.kt (onCreate)

text_drag.setOnLongClickListener(TextViewLongClickListener())

Pruébalo. ¿Puedes arrastrar el texto desde la TextView?

Tu app ya debería verse muy bien: tendrá compatibilidad con mouse y teclados, ¡y dinosaurios! Sin embargo, en un entorno de escritorio, los usuarios cambiarán el tamaño de la app con frecuencia: maximizarán y minimizarán el tamaño, cambiarán al modo tablet y modificarán la orientación. ¿Qué sucede con los elementos arrastrados, el contador de mensajes enviados y el contador de clics?

Es importante comprender el ciclo de vida de la actividad en el momento de crear apps para Android. A medida que las apps se vuelven más complicadas, la gestión de los estados del ciclo de vida puede resultar difícil. Por suerte, los componentes de la arquitectura facilitan la administración de los problemas en el ciclo de vida de una manera sólida. En este codelab, nos enfocaremos en el uso de ViewModel y LiveData a fin de preservar el estado de la app.

ViewModel ayuda a mantener los datos relacionados con la IU a lo largo de los cambios en el ciclo de vida. LiveData funciona como observador a efectos de actualizar automáticamente los elementos de la IU.

Ten en cuenta los datos de los cuales queremos hacer un seguimiento en esta app:

  • Contador de mensajes enviados (ViewModel, LiveData)
  • Contador de imágenes en las que se hizo clic (ViewModel, LiveData)
  • Texto actual de destino de la acción de soltar (ViewModel, LiveData)
  • Pilas Deshacer/Rehacer (ViewModel)

Revisa el código de la clase ViewModel que configura esto. En esencia, contiene métodos get y set, y usa un patrón singleton.

DinoViewModel.kt

class DinoViewModel : ViewModel() {
    private val undoStack = ArrayDeque<Int>()
    private val redoStack = ArrayDeque<Int>()

    private val messagesSent = MutableLiveData<Int>().apply { value = 0 }
    private val dinosClicked = MutableLiveData<Int>().apply { value = 0 }
    private val dropText = MutableLiveData<String>().apply { value = "Drop Things Here!" }

    fun getUndoStack(): ArrayDeque<Int> {
        return undoStack
    }

    fun getRedoStack(): ArrayDeque<Int> {
        return redoStack
    }

    fun getDinosClicked(): LiveData<Int> {
        return dinosClicked
    }

    fun getDinosClickedInt(): Int {
        return dinosClicked.value ?: 0
    }

    fun setDinosClicked(newNumClicks: Int): LiveData<Int> {
        dinosClicked.value = newNumClicks
        return dinosClicked
    }

    fun getMessagesSent(): LiveData<Int> {
        return messagesSent
    }

    fun getMessagesSentInt(): Int {
        return messagesSent.value ?: 0
    }

    fun setMessagesSent(newMessagesSent: Int): LiveData<Int> {
        messagesSent.value = newMessagesSent
        return messagesSent
    }

    fun getDropText(): LiveData<String> {
        return dropText
    }

    fun setDropText(newDropText: String): LiveData<String> {
        dropText.value = newDropText
        return dropText
    }
}

En tu actividad principal, obtén la ViewModel por medio de ViewModelProvider. De esta manera, obtendrás toda la magia del ciclo de vida. Por ejemplo, las pilas Deshacer y Rehacer mantendrán automáticamente su estado durante los cambios de tamaño, orientación y diseño.

MainActivity.kt (onCreate)

// Get the persistent ViewModel
dinoModel = ViewModelProviders.of(this).get(DinoViewModel::class.java)

// Restore our stacks
undoStack = dinoModel.getUndoStack()
redoStack = dinoModel.getRedoStack()

Para las variables de LiveData, crea y adjunta objetos Observer e indícale a la IU cómo debe cambiar cuando lo hagan las variables.

MainActivity.kt (onCreate)

// Set up data observers
dinoModel.getMessagesSent().observe(this, androidx.lifecycle.Observer { newCount ->
    text_messages_sent.setText(Integer.toString(newCount))
})

dinoModel.getDinosClicked().observe(this, androidx.lifecycle.Observer { newCount ->
    text_dinos_clicked.setText(Integer.toString(newCount))
})

dinoModel.getDropText().observe(this, androidx.lifecycle.Observer { newString ->
    text_drop.text = newString
})

Una vez que estos observadores estén implementados, se podrá simplificar el código de todas las devoluciones de llamada de clics a fin de modificar solo los datos de la variable de ViewModel.

El código que aparece a continuación muestra que no necesitas manipular de forma directa los objetos de TextView: todos los elementos de la IU que tengan observadores de LiveData se actualizarán automáticamente.

MainActivity.kt

internal inner class SendButtonOnClickListener(private val sentCounter: TextView) : View.OnClickListener {
    override fun onClick(v: View?) {
        undoStack.push(UNDO_MESSAGE_SENT)
        redoStack.clear()
        edit_message.getText().clear()

        dinoModel.setMessagesSent(dinoModel.getMessagesSentInt() + 1)
    }
}

internal inner class ImageOnClickListener(private val clickCounter: TextView) : View.OnClickListener {
    override fun onClick(v: View) {
        undoStack.push(UNDO_DINO_CLICKED)
        redoStack.clear()

        dinoModel.setDinosClicked(dinoModel.getDinosClickedInt() + 1)
    }
}

Por último, actualiza los comandos Deshacer/Rehacer para usar ViewModel y LiveData en lugar de realizar una manipulación directa de la IU.

MainActivity.kt

when (lastAction) {
    UNDO_MESSAGE_SENT -> {
        dinoModel.setMessagesSent(dinoModel.getMessagesSentInt() - 1)
    }

    UNDO_DINO_CLICKED -> {
        dinoModel.setDinosClicked(dinoModel.getDinosClickedInt() - 1)
    }

    else -> Log.d("OptimizedChromeOS", "Error on Ctrl-z: Unknown Action")
}

...

when (prevAction) {
    UNDO_MESSAGE_SENT -> {
        dinoModel.setMessagesSent(dinoModel.getMessagesSentInt() + 1)
    }

    UNDO_DINO_CLICKED -> {
        dinoModel.setDinosClicked(dinoModel.getDinosClickedInt() + 1)
    }

    else -> Log.d("OptimizedChromeOS", "Error on Ctrl-Shift-z: Unknown Action")
}

Pruébalo. ¿Cómo cambia el tamaño ahora? ¿Amas los componentes de la arquitectura?

Consulta el Codelab de ciclos de vida de Android a fin de obtener información más detallada acerca de los componentes de la arquitectura. Esta entrada de blog es un recurso excelente para comprender cómo funcionan e interactúan ViewModel y onSavedInstanceState.

¡Excelente! ¡Muy bien! Recorriste un largo camino a fin de conocer los problemas más comunes que encuentran los desarrolladores a la hora de optimizar las apps para Android en Chrome OS.

52240dc3e68f7af8.png

Código fuente de muestra

Clona el repositorio desde GitHub

git clone https://github.com/googlecodelabs/optimized-for-chromeos

… o descarga el repositorio como un archivo ZIP

Download Zip