Android App Resizing 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. While this is great news for developers, certain app optimizations are required to meet usability expectations and to make for an excellent user experience.

Android apps on ChromeOS should expect to be rotated and resized multiple times throughout their lifecycle. This code lab will guide you through implementing some best practices to make your app resize robustly and easily.

f60cd3eb5b298d5d.png

What you will build

You will build an Android app that demonstrates best practices for resizing. Your app will:

Manage state

Adapt to different window size

  • Use multiple ConstraintLayouts
  • [Advanced] Use the correct window coordinates for positioning Views

5cb0159e04504365.gif

What you'll need

If you run into any issues (code bugs, grammatical errors, unclear wording, etc.) as you work through this codelab, please report the issue via the Report a mistake link in the lower left corner of the codelab.

2. Getting Started - Sample App

Clone the repository from GitHub

git clone https://github.com/googlecodelabs/resizing-chromeos

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

Import Project

  • Open Android Studio
  • Chose Import Project or File->New->Import Project
  • Navigate to where you cloned or extracted the project
  • Work from the start module

Try the App

  • Build and run the app on a Chromebook
  • Try resizing the app
  • Try flipping to tablet mode
  • Try rotating the app

What do you think?

The technical implementation of the app could be working perfectly, but the user experience is not ideal.

What is happening with:

  • The data?
  • The layout?

75b9d95bf7264e70.png

3. State (ViewModel)

When you first load the app, you will notice that the app takes 5 seconds to fetch the reviews. This delay is hardcoded into DataProvider.kt and simulates a network delay.

DataProvider.kt

fun fetchData(dataId: Int): LiveData<AppData> {
    return MutableLiveData<AppData>().also {
        // Introduce an artificial delay to simulate network traffic
        mainHandler.postDelayed({ it.value = appData }, TimeUnit.SECONDS.toMillis(5))
    }
}

3b47384349a98805.gif

What happens when you resize or rotate the app?

Unnecessary network fetches are hard on your server and provide a poor user experience. This is particularly true for users with slow or limited data access.

Once the reviews have been fetched the first time, store them in a ViewModel so they survive configuration changes. Use LiveData for elements that have a UI component so that they can be automatically updated when the ViewModel changes.

Look at the MainViewModel class - a ViewModel contains getters/setters that use a singleton pattern. The DataProvider class here is the simulated network data fetcher.

Uncomment the code in the "STEP 1 - State (ViewModel)" comment block. It should look something like this:

MainViewModel.kt

class MainViewModel : ViewModel() {
    private val appData: LiveData<AppData> = DataProvider.fetchData(1)
    val suggestions = DataProvider.fetchSuggestions(1)

    val showControls: LiveData<Boolean> =
        Transformations.map(appData) { it != null }
    val productName: LiveData<String> =
        Transformations.map(appData) { it?.title }
    val productCompany: LiveData<String> =
        Transformations.map(appData) { it?.developer }
    val reviews: LiveData<List<Review>> = Transformations.map(appData) { it?.reviews }

    private val isDescriptionExpanded =
        MutableLiveData<Boolean>().apply { value = false }
    private val _descriptionText = MediatorLiveData<String>().apply {
        addSource(appData) { value = determineDescriptionText() }
        addSource(isDescriptionExpanded) { value = determineDescriptionText() }
    }

    val descriptionText: LiveData<String>
        get() = _descriptionText

    val expandButtonTextResId: LiveData<Int> =
        Transformations.map(isDescriptionExpanded) {
        if (it == true) {
            R.string.button_collapse
        } else {
            R.string.button_expand
        }
    }

    private fun determineDescriptionText(): String? {
        return appData.value?.let { appData ->
            if (isDescriptionExpanded.value == true) {
                appData.description
            } else {
                appData.shortDescription
            }
        }
    }

    /**
     * Handle toggle button presses
     */
    fun toggleDescriptionExpanded() {
        isDescriptionExpanded.value = !(isDescriptionExpanded.value ?: false)
    }
}

You will notice that the fetchData logic has moved into the ViewModel so you can delete the fetchData call in onCreate.

Now delete your member variables in MainActivity isDescriptionExpanded, and appData. Instead of holding a local copy, we will use the ViewModel to get and set this data.

In your onCreate, create an instance of the ViewModel:

MainActivity.kt (onCreate)

val viewModel = ViewModelProviders.of(this).get(MainViewModel::class.java)

Now update your data calls in onCreate to use the ViewModel. Hint: use "Find" and look for "STEP 1".

First, replace the expandDescriptionButton.setOnClickListener call to use the ViewModel:

MainActivity.kt (onCreate)

expandDescriptionButton.setOnClickListener { viewModel.toggleDescriptionExpanded() }

You can also delete the handleAppDataUpdate(), toggleExpandButton(), and updateDescription()methods found at the bottom of the class as we will replace their functionality in the next section.

Observers

Using ViewModel as above would allow the application to store the data and prevent extraneous network fetches. Attaching observers, however, makes this even more powerful and will allow for the UI to be updated automatically if the data changes. Add the following to onCreate:

MainActivity.kt (onCreate)

viewModel.reviews.observe(this, 
    NullFilteringObserver(reviewAdapter::onReviewsLoaded))
viewModel.suggestions.observe(this, 
    NullFilteringObserver(suggestionAdapter::updateSuggestions))
viewModel.showControls.observe(this, 
    NullFilteringObserver(::updateControlVisibility))
viewModel.expandButtonTextResId.observe(this, 
    NullFilteringObserver<Int>(expandDescriptionButton::setText))
viewModel.productName.observe(this, 
    NullFilteringObserver(productNameTextView::setText))
viewModel.productCompany.observe(this, 
    NullFilteringObserver(productCompanyTextView::setText))
viewModel.descriptionText.observe(this, 
    NullFilteringObserver(productDescriptionTextView::setText))

Try it out! Now what happens on resize and rotation?

Want to learn more? Check out the Android Lifecycles Codelab for a more detailed exploration of Architecture Components.

4. State (onSaveInstanceState)

If you are new to ViewModel and Architecture Components, you are likely wondering about onSaveInstanceState. Doesn't that handle UI state across configuration changes? Does ViewModel replace it? Do I have to use both? What about saving user data?

Summary

  • ViewModel is an excellent tool that can handle complex data across configuration changes
  • onSaveInstanceState retains small amounts of application and UI data that should persist when the application process is stopped and restarted.
  • They should be used in conjunction with each other.
  • User data should be persisted as usual and is not related to ViewModel or onSaveInstanceState.

State data in this app

  • Product Id (Int)
  • Expanded/collapsed state of product description (Boolean)
  • AppData and Suggestions (Complex data fetched from network)

AppData and Suggestions are complex and can be fetched again, so should be stored in the ViewModel. Product Id and Expanded/collapsed state cannot be retrieved again as they came from the user, they are simple and so should be stored in instance state.

Now if the application process is destroyed, AppData and Suggestions will need to be fetched again using the saved product Id, and the saved UI state of expanded/collapsed description will match the saved instance state when restored.

onSaveInstanceState uses Bundles to save and restore information. Create constant keys for the data you want to store to facilitate storage and retrieval:

MainViewModel.kt

Add the following constants above class definition of MainViewModel.

internal const val KEY_ID = "KEY_ID"
private const val KEY_EXPANDED = "KEY_EXPANDED"

The ViewModel can now help facilitate saving and restoring instance state with the Saved State Module for ViewModels.

To use it pass a custom ViewModel Factory when fetching the ViewModel. Notice that the Product Id is passed in as a default value.

MainActivity.kt (onCreate)

Remove the previous code for initializing viewModel and add the following.

val viewModel = ViewModelProviders.of(this, SavedStateVMFactory(this, Bundle().apply { putInt(KEY_ID, dataId) }))
    .get(MainViewModel::class.java)

Now expose the state from the ViewModel class. Update your class definition line:

MainViewModel.kt

class MainViewModel(private val state: SavedStateHandle) : ViewModel() {

and then update toggleDescriptionExpanded(), add getIdState(), and add getExpandedState() :

MainViewModel.kt

    fun toggleDescriptionExpanded() {
        state.set(KEY_EXPANDED, !getExpandedState())
    }

    private fun getIdState(): Int {
        return state.get(KEY_ID) ?:
            throw IllegalStateException("MainViewModel must be called with an Id to fetch data")
    }

    private fun getExpandedState(): Boolean {
        return state.get(KEY_EXPANDED) ?: false
    }

Want to know more about onSaveInstanceState and ViewModel? This blog post is a great resource.

If you'd like to test this out on a phone (sorry, this won't work on a Chromebook) expand the description in the app, then press the home button to put it into the background.

Then run the following command in adb:

adb shell am kill com.google.example.resizecodelab

This will simulate the system caching the app in a low memory scenario. When you navigate back to the app through recents you'll see that the description is still expanded after the large data reloads correctly.

5. Multiple Layouts

The app is maintaining state nicely now. This is excellent for speed and data usage. Let's take a look at the layout. On a phone in portrait mode, it looks fine, but what happens in landscape or when it expands to fill a Chromebook screen?

75b9d95bf7264e70.png

This is unacceptable in a production application, let's do better and aim to support different windows sizes. Create four different layouts that correspond to:

  • Phone in portrait (layout/activity_main.xml)
  • Phone in landscape (layout-land/activity_main.xml)
  • Tablet in portrait (layout-w600dp/activity_main.xml)
  • Tablet in landscape (layout-w600dp-land/activity_main.xml)

Copy the code from activity_main.xml into the three new layouts to provide you with a good base to work from.

If this is new to you, have a look at supporting multiple screen sizes documentation. And keep the following in mind:

  • Use ConstraintLayout to allow Views/layouts to resize fluidly
  • Consider changing the layout managers for the recycler views. What could you do with a GridLayoutManager with different span counts?
  • If Views like icons are scaling too large, consider using max-width attributes
  • Avoid large areas of empty space

Need some ideas? Here is an implementation of the wide-screen landscape orientation provided in layout-w600dp-land/activity_main_for_multiple_layouts.xml as a reference:

layout-w600dp-land/activity_main.xml

<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main_nested_scroll"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:id="@+id/main_constraint_layout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:descendantFocusability="blocksDescendants"
        android:paddingStart="16dp"
        android:paddingTop="16dp"
        android:paddingEnd="16dp">

        <!--20% of screen width-->
        <androidx.constraintlayout.widget.Guideline
            android:id="@+id/left_guideline"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:orientation="vertical"
            app:layout_constraintGuide_percent="0.2" />

        <!--Start at 45% screen width-->
        <androidx.constraintlayout.widget.Guideline
            android:id="@+id/right_guideline"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:orientation="vertical"
            app:layout_constraintGuide_percent="0.45" />

        <ImageView
            android:id="@+id/product_image_view"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:adjustViewBounds="true"
            android:contentDescription="@string/access_product_image"
            android:scaleType="fitCenter"
            app:layout_constraintEnd_toEndOf="@id/left_guideline"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:srcCompat="@drawable/whizzbuckling" />

        <ProgressBar
            android:id="@+id/loading_progress"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintEnd_toEndOf="@id/right_guideline"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/product_name_text_view"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="8dp"
            android:textAppearance="@style/TextAppearance.AppCompat.Title"
            app:layout_constraintEnd_toEndOf="@id/right_guideline"
            app:layout_constraintStart_toEndOf="@id/product_image_view"
            app:layout_constraintTop_toTopOf="parent"
            tools:text="@string/label_product_name" />

        <TextView
            android:id="@+id/product_company_text_view"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="8dp"
            android:layout_marginTop="8dp"
            android:textAppearance="@style/TextAppearance.AppCompat.Subhead"
            app:layout_constraintEnd_toEndOf="@id/right_guideline"
            app:layout_constraintStart_toEndOf="@id/product_image_view"
            app:layout_constraintTop_toBottomOf="@id/product_name_text_view"
            tools:text="@string/label_product_company" />

        <!--Directly below Company-->
        <Button
            android:id="@+id/purchase_button"
            style="@style/Widget.AppCompat.Button.Colored"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="4dp"
            android:layout_marginTop="8dp"
            android:text="@string/button_purchase"
            app:layout_constraintEnd_toEndOf="@id/right_guideline"
            app:layout_constraintStart_toStartOf="@id/left_guideline"
            app:layout_constraintTop_toBottomOf="@id/product_company_text_view" />

        <!--Expand button and Image-->
        <androidx.constraintlayout.widget.Barrier
            android:id="@+id/horizontal_barrier"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:barrierDirection="bottom"
            app:constraint_referenced_ids="expand_description_button, product_image_view" />

        <!--Below the Purchase Button-->
        <TextView
            android:id="@+id/product_description_text_view"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_margin="8dp"
            app:layout_constraintEnd_toEndOf="@id/right_guideline"
            app:layout_constraintStart_toStartOf="@id/left_guideline"
            app:layout_constraintTop_toBottomOf="@id/purchase_button"
            tools:text="@tools:sample/lorem/random" />

        <Button
            android:id="@+id/expand_description_button"
            style="@style/Widget.AppCompat.ActionButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_margin="16dp"
            android:text="@string/button_expand"
            android:textAllCaps="true"
            android:textColor="@color/colorAccent"
            app:layout_constraintEnd_toEndOf="@id/right_guideline"
            app:layout_constraintTop_toBottomOf="@id/product_description_text_view" />

        <!--Starts at top, guideline right-->
        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/reviews_recycler_view"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="@id/right_guideline"
            app:layout_constraintTop_toTopOf="parent" />

        <!--Below the Barrier, end at guideline right-->
        <TextView
            android:id="@+id/suggested_text_view"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_margin="16dp"
            android:text="@string/label_suggested"
            android:textAppearance="@style/TextAppearance.AppCompat.Title"
            app:layout_constraintEnd_toEndOf="@id/right_guideline"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/horizontal_barrier" />

        <!--End at guideline right-->
        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/suggested_recycler_view"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_margin="8dp"
            app:layout_constraintEnd_toEndOf="@id/right_guideline"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/suggested_text_view"
            tools:listitem="@layout/list_item_suggested" />

    </androidx.constraintlayout.widget.ConstraintLayout>

</androidx.core.widget.NestedScrollView>

LayoutManagers

You may wish to use different LayoutManagers for the two RecyclerViews depending on the screen configuration. Add the following code to the onCreate function in MainActivity.

MainActivity.kt (onCreate)

val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
val isSmall = resources.configuration.screenWidthDp < 600

reviewsRecyclerView.layoutManager =
    if (isLandscape) {
        GridLayoutManager(this, 2)
    } else {
        LinearLayoutManager(this)
    }

suggestedRecyclerView.layoutManager =
    when {
        isSmall -> LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
        isLandscape -> GridLayoutManager(this, 3)
        else -> GridLayoutManager(this, 2)
    }

Test your layouts by free-form resizing windows to be large, small, portrait, and landscape. Flip the Chromebook to tablet mode and try both portrait and landscape orientations.

6. Advanced: Window Coordinates

As much as possible, apps should implement ConstraintLayout and aim for fluid layouts. Developers should almost never be retrieving window coordinates and making layout calculations based on them.

In the ChromeOS environment with multiple applications open and the potential for multiple monitors, if you have a valid use case for manually calculating layout coordinates it is crucial that window coordinates are used and not screen coordinates.

To illustrate this, let's show a PopupWindow that is 50% of the screen width and height and opens in the middle of the parent window. Create and show this PopupWindow by adding an onClickListener to the Purchase button.

MainActivity.kt

purchaseButton.setOnClickListener { showPurchaseDialog() }

private fun showPurchaseDialog() {
    val popupView = layoutInflater.inflate(R.layout.dialog_purchase, mainNestedScrollView, false)

    //Get window size
    val displayMetrics = Resources.getSystem().displayMetrics // This line has a mistake that will be remedied in the next part.
    val screenWidthPx = displayMetrics.widthPixels
    val screenHeightPx = displayMetrics.heightPixels

    //Popup should be 50% of window size
    val popupWidthPx = screenWidthPx / 2
    val popupHeightPx = screenHeightPx / 2

    //Place it in the middle of the window
    val popupX = (screenWidthPx / 2) - (popupWidthPx / 2)
    val popupY = (screenHeightPx / 2) - (popupHeightPx / 2)

    //Show the window
    val popupWindow = PopupWindow(popupView, popupWidthPx, popupHeightPx, true)
    popupWindow.elevation = 10f
    popupWindow.showAtLocation(mainNestedScrollView, Gravity.NO_GRAVITY, popupX, popupY)
}

When the app is full-screen, which many mobile-minded developers assume will always be the case, things look ok.

28b7a8d03aaddf0b.png

But what happens if the application is not fullscreen?

d39cae6c59fa97e5.png

Not so good. If your UI depends heavily on screen coordinates, it quickly becomes unusable in a multi-window environment. If the user has multiple monitors, hard-coding coordinates gets even more complicated.

In this case, the line causing the issue is where we get the screen metrics:

val displayMetrics = Resources.getSystem().displayMetrics

The correct way to do this is to get the window metrics:

val displayMetrics = resources.displayMetrics

9ae8fcc2e64a0f75.png

Much better.

7. CONGRATULATIONS!

You did it! Great work! You have now implemented some best practices for allowing Android apps to resize well on ChromeOS and other multi-window, multi-screen environments.

1a1b9369991cdca8.png

Sample Source Code

Clone the repository from GitHub

git clone https://github.com/googlecodelabs/resizing-chromeos

...or download the repository as a Zip file