How to create one Android app/library for multiple design systems (visually different UIs)
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 Views
in 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:
src/materialdesign/java/your/package/UserView.kt
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.
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.