Creating responsive layouts with Jetpack Compose

Johann Blake
17 min readOct 8, 2021

--

Most Android developers who have developed apps that use alternate xml-based resources to change the layout due to changes in a device’s configuration will discover that Jetpack Compose does not currently provide any similar solution.

This article describes how to create responsive layouts without the need to write code in your composables to react to device configurations or configuration changes. A device configuration change can include things like an orientation change, running on devices with different screen sizes and densities or changing the system language.

The official documentation on Jetpack Compose does describe briefly how to check for these configuration settings and modify the layout but this is limited to doing this inside a composable, which is not the ideal way to react to configuration settings and changes.

This article is divided into two sections:

  • A general overview of how alternate xml-based resources under the older view-based system is used to create a responsive layout and how a similar design pattern can be used to create “composable resources” that are automatically selected based on device configurations.
  • A step-by-step guide on building a minimal responsive app using composable resources.

Source code for the demo app built in this article can be downloaded at:

Source code for the Jetmagic framework, used to handle responsive layouts, can be downloaded at:

Note: the demo app in this article uses the Jetmagic library hosted on Maven.

XML-based responsive apps compared to Compose responsive apps

The demo app provided by Jetmagic shows a grid list of cats on the startup screen, if the app is run on a phone or tablet in portrait mode. If you tap on a list item, you navigate to the next screen where details about the selected cat are shown. If you run the app on a tablet in landscape mode (or switch it from portrait to landscape mode), the grid list and the details list appear side-by-side because there is enough room on a tablet in landscape mode to accompany both of these. Here are some screenshots:

Portrait Mode (any device) — Pets grid list screen and details screen

In the older xml based resource layout system, the layout files might consist of the following:

  • pets_list: A layout that shows a list of pets.
  • pet_details: A layout that shows details about a pet.
  • pets_list_screen: The default screen for pets list on any device in portrait mode. It contains a pets_list layout.
  • pet_details_screen: The default screen for displaying details about a pet. It contains a pet_details layout.
  • /layout-xlarge-land/pets_list_screen: Displays the pets list and the pet details side by side on a tablet in landscape mode. It contains both a pets_list and pet_details layout. These could be placed in a LinearLayout whose orientation is set to horizontal.

The pets_list and pet_details are layouts that can be reused on any screen. The pets_list_screen and pet_details_screen act as parent containers that can be used to embed the reusable layouts pets_list and pet_details, respectively. Whenever the pets_list_screen is inflated into a view, Android will select the correct one based on the device’s current orientation.

In a single activity app using xml resources, you would probably use a navigation component, a navigation host and an xml-based navigation graph. The navigation host is essentially a container that hosts the layouts. The navigation controller will swap out the layouts as the user navigates from one screen to another. Android however is responsible for selecting the correct layout that matches the current device configuration.

Jetpack Compose also has a navigation framework known as “Navigation Compose”. It consists of a navigation host (NavHost) which supports xml-based navigation graphs but instead of loading xml resources, it renders composables when navigating. The NavHost also supports passing parameters from one screen to another, but although not stated explicitly in the official documentation, it does not support the passing of objects — not even parcelables, even though a link that is provided to the data types it supports indicates that parcelables are supported.

Jetmagic has what it calls the “Composable Resource Manager” — or CRM.

The CRM is responsible for determining the current device configuration and selecting the correct composable that will be rendered.

Using Jetmagic, the composables for the pets app shown above might look like this:

The folder structure and naming conventions are entirely your own choice. However, by closely matching the folder structure to the way it would be done using xml-based layouts, you’ll find it easier to read and maintain.

You’ll notice that the folders xlarge and land represent the configuration qualifiers that you would normally use as part of the folder name in xml-based layouts. The full list of configuration qualifiers are documented at:

https://developer.android.com/guide/topics/resources/providing-resources#AlternativeResources

Step-by-Step Guide to Creating a Responsive App

Now that we have seen how a responsive app’s project is structured for composables, we’ll go through a step-by-step guide on how to implement a basic responsive app. It will demonstrate the following features:

  • The home screen will show a list of countries.
  • If the device is in portrait mode, clicking on a list item will take you to another screen showing more detailed information about the country. If the device is in landscape mode, the list and details will be shown side-by-side and clicking on a list item will update the details pane.
  • If you change the system’s default language to German, the layout for the details screen will contain some extra text shown in blue appended to the country’s description. To keep things simple, text itself will not be changed to German — just the layout.

Note: This step-by-step guide is not the same demo app used by Jetmagic repository which provides a much more in depth demo covering more topics. The purpose of the demo shown here is just to keep things simple and focus only on how an app can update its layout when device configuration changes occur.

Step 1: Create an empty Compose app.

Step 2: Add the following dependencies to your app’s build.gradle file:

Step 3: In your project’s build.gradle file, add the following:

These compiler options help you to avoid having to add the experimental annotations throughout your code (At this point much of Compose is still considered “experimental”). This helps keep your code uncluttered from unnecessary annotations.

Step 4: Add a class called App that inherits from Application and initialize Jetmagic in onCreate:

Step 5: In the AndroidManifest, set the application name to be the App class and set the activity launchMode to be singleInstance. The activity doesn’t have to be a single instance, but for this demo please do this. The explanation for this is provided in the Jetmagic Github documentation:

Step 6: Create a data source that will provide the list of country names and a description of each country. Create a file called Repository.kt and paste the following code into it:

Step 7: Define IDs to identify the screens that make up your app and the composables used on the screens. Create a file called ComposableResourceIDs.kt and paste the following code into it:

Generally speaking, you will have screens that host composables — referred to as “children composables”. Both the screen and its children require unique IDs. This will become more apparent in the following steps.

Step 8: Create a viewmodel for the screen used to show the list of countries. Under the ui package (folder), create the package countrylist and create a file called CountryListViewModel.kt. Paste the following code into the file:

In a later step, it will be shown how this viewmodel gets used.

Step 9: Create a composable that will display the list of countries. This actually consists of two composables. The CountryListHandler is a composable that is used to hoist the state of the CountryList composable that renders the list. Under the countrylist package, create the file CountryListUI.kt and paste the following code:

CountryListHandler must be provided with a parameter that is a ComposableInstance. This is an object that Jetmagic uses to provide state management for the composable and can optionally include a viewmodel. The CRM is responsible for calling CountryListHandler and will provide this parameter with state data. It’ll be shown in a further step how the CRM calls CountryListHandler.

When you tap on a list item, the onItemClick handler will call the CRM with the selected item. The CRM updateOrGoto function will either update the details pane (when in landscape mode) or navigate to the details screen when in portrait mode. The parentComposableInstance parameter refers to the composable instance that was passed into the composable. The childComposableResourceId parameter is used by the CRM to identify the type of composable to look for on the current screen. If one is present, it will be updated. If no composable can be found on the current screen to update, the fullscreenComposableResourceId is used to navigate to a new screen that will be used to display the details. If a navigation occurs, the CRM will select the correct composable to render based on the device’s current configuration. It will use the value provided by fullscreenComposableResourceId to determine which composable resource to use for the new screen.

Step 10: Create a screen to host the CountryList. Under the countrylist package, create a file call CountryListScreenHandler.kt and paste the following code into it:

Like the CountryListHandler, the CRM is responsible for calling CountryListScreenHandler. The CountryListScreenHandler is effectively the “screen” or often referred to as the “root” or “parent” composable that acts as container to host other composables, which are usually referred to as “children composables”. When CountryListScreenHandler is called, it will in turn call on the CRM to render any children composables that need to be hosted. Here, we want it to render a CountryList. We don’t explicitly tell it to render CountryListHandler but rather provide the CRM with the ID of the composable resource that it should use to select the correct CountryListHandler. Remember, you can have multiple composables all named CountryListHandler located in different packages with each of them specific to some device configuration. It is up to the CRM to check the device’s current configuration and then select the correct handler by using the ID provided by the composableResId. A later step will show how the ID is used to match up with the correct handler.

Every child composable that you add to the screen handler with RenderChildComposable needs to have a unique ID within its parent. This is set with the childComposableId parameter. This need not be globally unique throughout the app but needs to be unique for the screen on which it appears. If you do use the same child composable on other screens and if you decide to let Jetmagic manage the viewmodel for it, it is recommended that you use the same ID. This will allow your viewmodel to be reused even when the device’s configuration changes.

You use RenderChildComposable whenever you have a composable whose layout is dependent on the device’s configuration, or when a composable on its own can function as the entire screen.

The “p” parameter is a way of passing any additional data you want to the child composable. This is the equivalent of passing a Bundle using the older mechanism of passing data between activities. In this demo, nothing is actually being passed, so CountryListHandler will end up receiving null for the parameters.

CountryListScreenHandler can in fact include just normal composables if you have a need for doing so. As we’ll see in the next step, the screen handler, CountryListWithDetailsScreenHandler, contains some basic composables for displaying both the list and details side-by-side.

Step 11: We need a different composable to display both the country list and the details side-by-side. Under the countrylist package, create a package called land. land is the equivalent to the land qualifier that represents landscape mode. Create a file under land called CountryListWithDetailsScreenHandler.kt. Paste the following code into it:

Step 12: Creating the composables for the details screen is essentially a repeat of the steps that were used to create the screen for the list, so we’ll just list the steps briefly without any explanation, except where it is necessary.

Create a package called countrydetails (it doesn’t matter where you create this). Create a file called CountryDetailsUI.kt and paste in the following code:

There are a few differences here from CountryListHandler. The onUpdate is a LiveData observable used to trigger an update for the composable. This will happen when the device is in landscape mode and the user taps on a list item. The call to updateOrGoto in CountryListHandler will trigger this update. The update recomposes the composable and makes the new data available in composableInstance.parameters. The onUpdate observable itself doesn’t return anything meaningful. It actually returns just a random number which is of no real value to the composable. LiveData is only being used to trigger an update.

The other thing to notice is that if composableIntance.parameters is null, it retrieves the description from the first item in the repository list. composableInstance.parameters will be null when you initially switch from portrait to landscape mode. This is because in CountryListHandler, there is nothing that sends an initial value to the details pane if the app starts up in landscape mode. In a production app, CountryListHandler would maintain the state of the currently selected item (or set one as selected if one does not exist) and pass that selection when the device rotates to landscape mode. The demo app in the Jetmagic framework does implement state management for this particular case. But for this demo, we’ll leave it out to keep things simple.

The other issue is that in a production app, you normally wouldn’t call the repository directly from a composable. It should be called from a viewmodel. But since this article is already long enough, we’ll keep it simple and just grab the first item from the repository whenever composableInstance.parameters is null.

Step 13: Create the handler for the details screen. Create a file called CountryDetailsScreenHandler.kt and paste the following code into it:

Step 14: The final composable we need is one to handle a change in the system language. We want to have the layout change when German (de) is used. Under the countrydetails package, create a package called de. As noted previously, the package name doesn’t have to be called this. You can place your composables wherever you want. Create a file called CountryDetailsUI.kt and paste the following code into it:

It should be noted that the code in CountryDetailsHandler is the same as the one used in portrait mode. In a production app, you will want to avoid duplicating code. However, you can’t just use the same composable for both portrait and landscape because both of these need to call their own CountryDetails composable. You definitely don’t want to add code that tries and determines whether you’re in portrait or landscape mode. That defeats the purpose of delegating that task to the CRM. What you could do however is to move that part of CountryDetailsHandler that is common to both portrait and landscape mode to a common function or class that they both share.

In CountryDetails, the extra Text composable at the end is added and colored blue. This is what we’re using to demo the change in the system language.

Step 15: At this point we have created composables that will act as resources and we have created IDs that we will use to identify those resources. We now need to tie the two together. This is done using the CRM and is where the magic happens that makes your app responsive. In the App.kt file you previously created, paste the following code after the line that initializes Jetmagic:

A composable resource is created by using the ComposableResource class. This class provides properties that are used by the CRM to help it select the correct composable based on configuration settings. It includes a list of properties that represent the same configuration qualifiers that you would use with folder names for xml-based resources.

A list of these composable resources are added to a list and the list is then provided to the CRM by means of the addComposableResources function. The order in which you add these is arbitrary but it is recommended to add all your screen composable resources first followed by the children resources as it makes it easier to visualize.

Whenever you add a composable resource that includes configuration qualifiers, you must also include a composable resource that contains no qualifiers. A composable resource without qualifiers is considered the default resource. This is the equivalent of adding resources to the res/layout folder for xml-based resources. However, when using xml-based resources, you could get away with adding resources to a folder that contains a qualifier but has no default resource. But that runs the risk of your app crashing under certain conditions. The Android docs state the following:

Caution: If all your resources use a size qualifier that is larger than the current screen, the system will not use them and your app will crash at runtime (for example, if all layout resources are tagged with the xlarge qualifier, but the device is a normal-size screen).

Jetmagic avoids this by forcing you to include a default resource that has no qualifiers. If you fail to include one, Jetmagic will throw an exception at runtime when addComposableResources is called, indicating that you forgot to include one.

Every composable resource must have its resourceId parameter set. This does not have to be unique. Looking at the code above, you’ll notice that the ComposableResourceIDs.CountryListScreen is used for two resources. The first one is used for the default (portrait mode) resource while the second one is used for landscape mode. This is the equivalent of using the same file name for an xml resource file but stored under a different folder. But instead of filenames, we’re using IDs to map to the composables.

The last parameter in the ComposableResource is the onRender parameter which is a lambda. When the CRM selects a resource, it will call onRender. Your lambda expression must call the correct composable function that corresponds to that resource.

Another parameter you may want to use is the viewmodelClass. The CRM will create a viewmodel and associate it with the composable when it gets rendered. In the code above, the CountryList resource provides a reference to CountryListViewModel. Alternatively, if you need more control over how the viewmodel gets created, you can set the onCreateViewmodel parameter. This is a lambda callback. When called, you can instantiate your viewmodel and return it.

Step 16: The final step is to add some minimal boilerplate code to your activity that will cause your app to navigate to the start screen, handle Back navigation and inform the CRM when device configuration changes occur. In your activity, paste the following code:

navman is a global variable part of Jetmagic used for navigation. It is an instance of Jetmagic’s own Navigation Manager which is not covered in this article. When your activity is created, you can check to see how many screens are currently on the navigation stack and decide whether to go to the start (home) screen or start on the screen the user was last on when the activity was last destroyed.

The ScreenFactoryHandler is part of Jetmagic. It essentially acts as a container for the screens that get displayed. Whenever the CRM needs to render one of your composable resources, it will be rendered by the ScreenFactoryHandler.

In onBackPressed, a call to navman.goBack is called. This is how Jetmagic handles navigating back to the previous screen. If goBack returns true, it means that there are no more screens to navigate back to — you are already on the first screen. As a result, the activity will terminate.

In onDestroy, crm.onConfigurationChanged is called. While onDestroy will be called when the activity is destroyed under normal conditions, it will also be called when device configuration changes occur. Regardless of what causes the activity to be destroyed, we’ll inform the CRM that device configuration changes have occurred and that the screens need to be recomposed with composables that match the new configuration.

The CRM will go through the composable resources you setup and find ones that apply to the new configuration. If a screen doesn’t need to use a new composable resource, it just recomposes with the composable instance it already has. If a screen does change to a new composable resource but uses the same viewmodel for that resource (if you provided one), then the viewmodel from the previous composable resource will be reused making the transition from one layout to another seemless without losing any state data from your viewmodel.

This concludes the steps to build this app. When you run the app, the list of countries will appear. If you are in portrait mode, tapping on a list item will take you to the details screen. If you are in landscape mode, both the list and details will appear side-by-side and tapping on a country list item will update the details pane. If you change the system default language to German and then switch back to the app, some additional text in blue is shown on the details screen.

Conclusion

If there is one important thing that you can take away from this article is that there is little or no need to include code in your composables that use device configuration settings to decide how the layout should look.

You should treat your composables as “resources” no differently than you did with xml resources and delegate the selection of those resources to a component whose primary task is to detect device settings and changes and cause your layouts to rerender (recompose). Using a CRM to handle this task will give your app a more natural adaptation to environmental changes. Jetmagic is certainly one of the first frameworks designed to handle this task. But if it doesn’t meet your needs, you can at least take what you learned in this article as a guide in developing a framework that provides a similar process.

Perhaps Google was aware of this missing component in Compose but decided to leave it out and see how developers approach it and if eventually one came out on top, to adopt it as part of the standard Jetpack Compose framework.

While Google could potentially add a CRM of their own, at present, their navigation framework leaves much to be desired and would probably be incompatible with changes needed to support a CRM. In Jetmagic, the CRM and navigation are very much dependent on each other.

I highly recommend downloading the Jetmagic framework and trying out the demo app it includes. It goes much deeper into the capabilities that this article could not cover.

UPDATE October 28, 2021

Google held their Android Summit and presented their new solution to how to handle creating responsive layouts for Compose. As usual, Google was doing the Indiana Jones take on development — “Make it up as we go along”

Their solution is pathetic to say the least. It only takes into account screen sizes and has nothing to do with device configuration changes. But the list of critical points I have with their solution is too long to get into here. The point of this article isn’t to rant against Google. If they want to emply newbies to develop their frameworks, that’s their choice. Your choice is to decide whether you want to be a sheep and follow the herd or take a route that will make developing Android apps easier, cleaner and more exciting.

On a final note, if you mention the word “Jetmagic” in the comments section of any Android videos posted on Android’s official YouTube channel, your comment will get deleted. Google’s censorship to criticism runs deep and they’ve been doing it for years. What was once a great company has turned into just another bloated corporate entity along the likes of Facebook with no regard to freedom of speech.

--

--