Optimizing your Android App for ChromeOS

1. Introduction

With the ability to run Android apps on Chromebooks, a huge ecosystem of apps and vast new functionality is now available to users. Although great news for developers, certain app optimizations are required to meet usability expectations and to make for an excellent user experience. This code lab walks you through the most common of these optimizations.

f60cd3eb5b298d5d.png

What you will build

You will build a functional Android app that demonstrates best practices and optimizations for ChromeOS. Your app will:

Handle keyboard input including

  • Enter key
  • Arrow keys
  • Ctrl- and Ctrl-Shift- shortcuts
  • Visual feedback for currently selected items

Handle mouse input including

  • Right-click
  • Hover effects
  • Tooltips
  • Drag and drop

Use Architecture Components to

  • Maintain state
  • Automatically update UI

52240dc3e68f7af8.png

What you'll learn

  • Best practices for handling keyboard and mouse input in ChromeOS
  • ChromeOS specific optimizations
  • Basic implementation of ViewModel and LiveData Architecture Components

What you'll need

2. Getting Started - The Broken App

Clone the repository from GitHub

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

...or download a zip file of the repository and extract it

Import the Project

  • Open Android Studio
  • Choose Import Project or File > New > Import Project
  • Navigate to where you cloned or extracted the project
  • Import the project optimized-for-chromeos
  • Notice there are two modules, start and complete

Try the App

  • Build and run the start module
  • Start out using only the trackpad
  • Click the dinosaurs
  • Send some secret messages
  • Try to drag the "Drag Me" text or drop a file onto the "Drop Things Here" area
  • Try using the keyboard to navigate and send messages
  • Try using the app in tablet mode
  • Try rotating the device or resizing the window

What do you think?

Even though this app is quite basic, and the parts which feel broken are easy to address, the user experience is awful. Let's fix it!

a40270071a9b5ac3.png

3. Enter Key

If you typed a few secret messages using the keyboard, you will have noticed that the Enter key does not do anything. This is frustrating to the user.

The sample code below and the Handling Keyboard Actions documentation should do the trick.

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

Test it out! Being able to send messages using only the keyboard is a much nicer user experience.

4. Arrow Key Navigation

Wouldn't it be nice to navigate this app using only the keyboard? As is, the experience is poor - when a user has a keyboard in front of them, it is frustrating when an application doesn't respond to it.

One of the easiest ways to make views navigable via the arrow and tab keys is to make them focusable.

Examine the layout files and look at the Button and ImageView tags. Notice that the focusable attribute is set to false. Change this to true in XML:

activity_main.xml

android:focusable="true"

Or programmatically:

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)

Try it out. You should be able to use the arrow keys and the enter key to select dinosaurs, but depending on your OS version, your screen, and the lighting, you may not be able to see which item is currently selected. To help with this, set the background resource for the images to 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)

Usually, Android does a pretty good job in deciding which View is above, below, to the left, or to the right of the View currently focused. How well does it work in this app? Make sure to test both the arrow keys and the tab key. Try navigating between the message field and the send button with the arrow keys. Now select the triceratops and you press tab. Does the focus change to the view you expect?

Things are (intentionally) just a little bit off in this example. As a user, these small glitches in input feedback can feel really frustrating.

To manually adjust the arrow/tab key behavior in general, you can use the following:

Arrow keys

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

Tab key

android:nextFocusForward="@id/next_view"

Or programmatically:

Arrow keys

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

Tab key

myView.nextFocusForwardId - R.id.next_view

For this example, focus order can be fixed with:

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

5. Selected Item Highlight Color

You are now able to select dinosaurs but depending on your screen, the lighting conditions, the view, and your vision, it may be difficult to see the highlighting for selected items. For example, in the image below the default is grey on grey.

c0ace19128e548fe.png

To provide more prominent visual feedback for your users. Add the following to res/values/styles.xml under AppTheme:

res/values/styles.xml

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

23a53d405efe5602.png

Gotta love that pink, but the kind of highlighting in the picture above may be too aggressive for what you want and looks messy if all of your images are not exactly the same dimension. By using a state list drawable, you can create a border drawable that only appears when an item is selected.

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>

Now replace the highlightValue/setBackgroundResource lines from the previous step with this new box_border background resource:

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

6. Ctrl- Based Shortcuts

Keyboard users expect common Ctrl-based shortcuts to work. So now you'll add Undo (Ctrl-Z) and Redo (Ctrl-Shift-Z) shortcuts to the app.

First create a simple click-history stack. Imagine a user has done 5 actions and presses Ctrl-Z twice so that action 4 and 5 are on the redo stack and 1, 2, and 3 on the undo stack. If the user hits Ctrl-Z again, action 3 moves from the undo stack to the redo stack. If they then hit Ctrl-Shift-Z, action 3 moves from the redo stack to the undo stack.

9d952ca72a5640d7.png

At the top of your main class, define the different click actions and create the stacks using 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

Whenever a message is sent or a dinosaur is clicked, add that action to the undo stack. When a new action is taken, clear the redo stack. Update your click listeners as follows:

MainActivity.kt

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

...

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

Now actually map the shortcut keys. Support for Ctrl- commands and, on Android O and later, for Alt- and Shift- commands can be added using dispatchKeyShortcutEvent.

MainActivity.kt (dispatchKeyShortcutEvent)

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

Let's be picky in this case. To insist that only Ctrl-Z triggers the callback and not Alt-Z or Shift-Z, use hasModifiers. The undo stack operations are filled in below.

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

Test it out. Are things working as expected? Now add Ctrl-Shift-Z by using OR with the modifier flags.

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

7. Right-click

For many interfaces, users assume that a right-click with a mouse or a double-tap on a trackpad will bring up a context menu. In this app, we want to provide this context menu so users can send these cool dinosaur pictures to a best friend.

8b8c4a377f5e743b.png

Creating a context menu includes right-click functionality automatically. In many cases this is all you need. There are 3 parts to this setup:

Let the UI know this view has a context menu

Use registerForContextMenu on each view you want a context menu for, in this case the 4 images.

MainActivity.kt

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

Define how the context menu looks

Design a menu in XML that contains all the context options you need. For this, just add "Share".

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>

Then, in your main activity class, override onCreateContextMenu and pass in the XML file.

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

Define the actions to take when a specific item is chosen

Lastly, define the action to take by overriding onContextItemSelected. Here, just show a quick Snackbar letting the user know the image was shared successfully.

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

Test it out! If you right-click on an image, the context menu should appear.

MainActivity.kt

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

8. Tooltips

Adding tooltip text that appears upon hover is an easy way to help users understand how your UI works or to provide additional information.

17639493329a9d1a.png

Add tooltips for each of the photos with the dinosaur's name using the setTootltipText() method.

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

9. Mouse Hover Effects

It can be useful to add a visual feedback effect to certain views when a pointing device is hovering over them.

To add this type of feedback, use the code below to make the Send button turn green when the mouse hovers over it.

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

Add one more hover effect: Change the background image associated with the draggable TextView, so that the user knows that that text is draggable.

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

Test it out! You should see a big hand graphic appear when your mouse hovers on the "Drag Me!" text. Even this garish feedback makes the user experience more tactile.

For more information, see the View.OnHoverListener and MotionEvent documentation.

10. Drag and Drop (add drop support)

In a desktop environment, it is natural to drag and drop items into an app, particularly from ChromeOS's file manager. In this step, set up a drop target that can receive files or plain text items. In the next section of the codelab, we will implement a draggable item.

cfbc5c9d8d28e5c5.gif

First, create an empty OnDragListener. Have a look at its structure before starting to code:

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

The onDrag() method is called whenever any of several different drag events occur: starting a drag, hovering over a drop zone, or when an item is actually dropped. Here is a summary of the different drag events:

  • ACTION_DRAG_STARTED is triggered when any item is dragged. Your target should look for valid items it can receive and provide a visual indication that it is a ready target.
  • ACTION_DRAG_ENTERED and ACTION_DRAG_EXITED are triggered when an item is being dragged and the item enters/exits the drop zone. You should provide visual feedback to let the user know they can drop the item.
  • ACTION_DROP is triggered when the item is actually dropped. Process the item here.
  • ACTION_DRAG_ENDED is triggered when the drop is either dropped successfully or cancelled. Return the UI to its normal state.

ACTION_DRAG_STARTED

This event is triggered whenever any drag is started. Indicate here whether a target can receive a particular item (return true) or not (return false) and visually let the user know. The drag event will contain a ClipDescription that contains information about the item being dragged.

To determine if this drag listener can receive an item, examine the MIME type of the item. In this example, indicate that the target is a valid one by tinting the background light green.

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, and ENDED

ENTERED and EXITED is where the visual/haptic feedback logic goes. In this example, deepen the green when the item is hovering over the target zone to indicate the user can drop it. In ENDED, reset the UI to its normal non-drag and drop state.

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

This is the event that occurs when the item is actually dropped on the target. Here is where the processing gets done.

Note: ChromeOS files need to be accessed using ContentResolver.

In this demo, the target may receive a plain text object or a file. For plain text, show the text in the TextView. If it is a file, copy the first 200 characters and show that.

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

Now that the DropTargetListener is set up, attach it to the view you wish to receive the dropped items.

MainActivity.kt

text_drop.setOnDragListener(DropTargetListener(this))

Test it out! Remember you will need to drag in files from the ChromeOS file manager. You can create a text file using the ChromeOS text editor or download an image file from the internet.

11. Drag and Drop (add drag support)

Now set up a draggable item in your app. A drag process is usually triggered by a long-press on a view. To indicate an item can be dragged, create a LongClickListener that provides the system with the data being transferred and indicates what type of data it is. This is also where you configure the appearance of the item while it is being dragged.

Set up a plain text drag item that pulls a string out of a TextView. Set the content MIME type to ClipDescription.MIMETYPE_TEXT_PLAIN.

For visual appearance during the drag, use the built-in DragShadowBuilder for a standard translucent drag look. Check out Starting a Drag in the documentation for a more complex example.

Remember to set the DRAG_FLAG_GLOBAL flag to signify that this item can be dragged into other 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
    }
}

And now add the LongClickListener to the draggable TextView.

MainActivity.kt (onCreate)

text_drag.setOnLongClickListener(TextViewLongClickListener())

Try it out! Are you able to drag in the text from the TextView?

12. Preserve state with Architecture Components

Your app should be looking pretty good - keyboard support, mouse support, and dinosaurs! However, in a desktop environment, users will be frequently resizing the app, maximizing, un-maximizing, flipping to tablet mode, and changing orientation. What happens to your dropped items, the sent message counter, and the click counter?

The Activity Lifecycle is important to understand when creating Android apps. As apps get more complicated, managing lifecycle states can be difficult. Fortunately, Architecture Components makes it easier to handle lifecycle concerns in a robust way. In this codelab, we are going to focus on using ViewModel and LiveData to preserve the app state.

ViewModel helps maintain UI-related data across lifecycle changes. LiveData works as an observer to automatically update UI elements.

Consider the data we want to keep track of in this app:

  • Sent message counter (ViewModel, LiveData)
  • Images clicked counter (ViewModel, LiveData)
  • Current drop target text (ViewModel, LiveData)
  • Undo/redo stacks (ViewModel)

Examine the code for the ViewModel class that sets this up. Essentially, it contains getter and setters using a singleton pattern.

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

In your main activity, get the ViewModel using ViewModelProvider. This will do all the lifecycle magic. For example, the undo and redo stacks will automatically maintain their state during resizing, orientation, and layout changes.

MainActivity.kt (onCreate)

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

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

For the LiveData variables, create and attach Observer objects and tell the UI how to change when the variables change.

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

Once these observers are in place, the code in all of the click callbacks can be simplified to just modify the ViewModel variable data.

The code below shows how you do not need to directly manipulate the TextView objects—all of the UI elements with LiveData observers update automatically.

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

Lastly, update the undo/redo commands to use the ViewModel and LiveData instead of directly manipulating the UI.

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

Try it out! How does it resize now? Are you in love with Architecture Components?

Check out the Android Lifecycles Codelab for a more detailed exploration of Architecture Components. This blog post is a great resource for understanding how ViewModel and onSavedInstanceState work and interact.

13. CONGRATULATIONS!

You did it! Great work! You have gone a long way to familiarizing yourself with the most common issues developers face when optimizing Android apps for ChromeOS.

52240dc3e68f7af8.png

Sample Source Code

Clone the repository from GitHub

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

...or download the repository as a Zip file