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.
What you will build
You will build an Android app that demonstrates best practices for resizing. Your app will:
Manage state
- Use ViewModel for data caching and UI state
- Use onSaveInstanceState for UI state
Adapt to different window size
- Use multiple ConstraintLayouts
- [Advanced] Use the correct window coordinates for positioning Views
What you'll need
- Knowledge of creating basic Android applications
- Chromebook with Android Studio or a Chromebook with ADB set up and a separate development computer with Android Studio installed
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?
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))
}
}
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?
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.
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.
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.
Sample Source Code
Clone the repository from GitHub
git clone https://github.com/googlecodelabs/resizing-chromeos
...or download the repository as a Zip file