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 Chrome OS 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.

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

What you'll need

Clone the repository from GitHub

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

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

Download Zip

Import Project

Try 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:

When you first load the app, you will notice that the app takes 5 seconds to fetch the reviews. This is hardcoded into our DataProvider 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))
    }
}

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.

Create the ViewModel class - a ViewModel contains getters/setters using a singleton pattern. The DataProvider class is the simulated network data fetcher.

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

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

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

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 to use the ViewModel:

MainActivity.kt

expandDescriptionButton.setOnClickListener { viewModel.toggleDescriptionExpanded() }

Delete the handleAppDataUpdate(), toggleExpandButton(), and updateDescription()method as we will replace its 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

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.

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

State data in this app

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

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)

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. This involves updating your class definition line and updating toggleDescriptionExpanded().

MainViewModel.kt

class MainViewModel(private val state: SavedStateHandle) : ViewModel() {
    private val appData = DataProvider.fetchData(getIdState())
    val suggestions = DataProvider.fetchSuggestions(getIdState())

    private val isDescriptionExpanded: LiveData<Boolean> = state.getLiveData(KEY_EXPANDED)

...

    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.

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?

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:

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:

Need some ideas? Here is an implementation of the wide-screen landscape orientation:

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.

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.

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 Chrome OS 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 add a PopupWindow to the Purchase button that is 50% of the screen width and height and opens in the middle of the parent window.

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.

But what happens if the application is not fullscreen?

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

Much better.

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

Sample Source Code

Clone the repository from GitHub

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

...or download the repository as a Zip file

Download Zip