How to create one Android app/library for multiple design systems (visually different UIs)

Tom Seifert
5 min readOct 14, 2022

--

On Android, Google is pushing developers extensively to use and follow their Material Design to create user interfaces. While most apps do (more or less) look like Google wants you to, there are some apps that use their own design systems — just think about the Spotify app. They created their own design system, so all of their apps, no matter on which platform, look and feel similar.

Let’s say you’re developing an Android library that also provides UI. To pleasure most users, you implement your UI in Material Design. But now Spotify comes around, tells you that your library is awesome, but they would need to use it with their design system. You start to scratch your head; how should you enable them to? And how do you avoid duplicating so much code? Can your business logic be reused? How would the architecture look like?

Starting point

My status quo was pretty common: a ViewModel with some business logic and aFragment as its view.

The ViewModel exposed a Flow<Event> to which a Fragment could subscribe and on receiving an event like DisplayUserData the Fragment, which uses view binding, sets the user data contained in this event to the relevant views. Imagine something like this:

The following described approach also works totally fine if you use different concepts, like having a Flow<ScreenState> or use LiveData.

The general idea

We will reuse all logic from our ViewModels and also reuse our Fragments but we will no longer directly use any Android Viewsin it. Instead, we set up build variants to provide different implementations of an abstract view with, where each of these implementations uses a different design system. We will also be able to reuse view logic where it’s appropriate.

At the end we want to have something similar to the following snippet, but we get there step by step:

First step: product flavors

Each product flavor will contain a layout implemented with the specific design system. So, we will have one layout, to continue the initial example, with Material Design, using the default Android classes you know, and one with Spotify’s design system, using custom Views. Each flavor will furthermore contain any view logic that is specific to that design system. E.g., setting a click listener might require different code. Our default source set will provide a common base class where we can share logic for each subtype.

Head to your build.gradle(.kts) and add the product flavors:

android {
productFlavors {
create("materialdesign") { ... }

create("spotify") { ... }
}
}

Hit Gradle sync and first step is done. You should end up with different build types: materialdesignDebug, spotifyDebug etc.

Second step: refactor view inflation

The following steps will, unfortunately, be pretty annoying.

At the end, your Fragment should not contain any Android views anymore. It will still be used for any Android relevant code, e.g. starting a new activity.

I assume the layout of your Fragment is already implemented in Material Design and you want to move it to the new materialdesign product flavor. First copy the layout file from the base source set to the both new source sets. This is just to avoid upcoming compilation errors.

We then start by replacing the view inflation in your fragment. Let’s assume your fragment is named UserFragment. Create a new class in your two newly created source sets:

  1. src/materialdesign/java/your/package/UserView.kt
  2. src/spotify/java/your/package/UserView.kt

with this content:

In your UserFragment you can now define this new class and use the static method:

And with that, you already achieved that, based on your selected build variant, you inflate different layouts! Well, theoretically, there is still work to do.

Third step: all these ViewModel calls…

You probably have a lot of code in your Fragment that, based on some event emission from the ViewModel, accessed an Android view in any way. It’s now time that we, if not already happened, delete the layout file, the view binding and all eventual view definitions from your fragment and redefine them in your specific UserView classes. I recommend first recreating your old view and don’t touch the other source set until you’re finished with that.

After you deleted the views, a lot of code will be red in your Fragment and you will have to move all code that is red to your new UserView classes. Not only that, but also for every method or code snippet think if you potentially can reuse this code in both flavors.

We assume you will want to reuse code. In the base source set create an abstract class BaseUserView and make your both UserViews extend this class. Now you have a way to share code!

In my code it looks like this: my UserFragment subscribes to theViewModel events and delegates each event to BaseUserView, which has a method like

fun handle(event: UserEvent) = when(event){
x -> doThis()
y -> doThat()
}

If I can share the implementation, doThis() is implemented in AbstractUserView, otherwise it is defined as abstract fun doThat() and hence gets implemented in your subclasses.

Now you should be able to move most of your UI code out of your fragment.

Your source sets and their content

Fourth step: Resolving calls that happened from Fragment to ViewModel

The user clicks a button and in the click listener you called viewModel.onButtonClicked(). That was simple, but with the new architecture the views that set the click listener don’t know your ViewModel anymore.

I solved this by not only consuming an event stream from the ViewModel to the Fragment for the UI state, but also create one for the other way around. My BaseUserView has a property Flow<Intent> which the fragment can collect and pass the event down to the ViewModel.

So, setting a click listener looks like this in my UserView:

myButton.setOnClickListener {
intentFlow.tryEmit(UserIntent.MyButtonPressed)
}

and my fragments delegates this:

userView.intentFlow.onEach { intent ->
when(intent) {
MyButtonPressed -> viewModel.onButtonClicked()
}
}

Fifth step: implement the other layout

At this point your old layout, which used Material Design, should work again. You can switch to the other build variant now, redefine your layout file and implement all the view logic in that flavor’s UserView.

And with that, you have two different layouts for the same screen while reusing as much logic as possible! You can even write Espresso tests for each variant what works perfectly fine.

--

--