Implementing design system components for Android applications
Achieving a consistent user interface layer of your mobile app is now more important than ever. With the introduction of Material Design 2.0 and Material Theming, the focus has changed from following the system UI guidelines strictly to better reflecting the brand in the application’s design. The idea is good, but some of the questions developers ask themselves also became essential. How to convert UX/UI Designers’ ideas to the code? How to keep the design system implementation consistent, clean and up-to-date? How to spend a reasonable amount of time on implementing the UI layer and achieve more than good results? It’s a great time to answer most of those.
That said, I won’t be discussing how to prepare a design system itself. Instead, I will share some thoughts on implementing a complex design system in the Android application’s code.
Consistent styling is important…
If you think about what makes your app distinguishable from others, it’s mostly the styling. It’s crucial to keep colors, typography, dimensions, icons, styles and other primary resources well-defined and ready to be used. And most of the projects are successful here: in the Android world is relatively easy to keep those separated and up-to-date.
…but we need a bigger picture
If your app is more complicated, and you’re not the only one working on its code, some issues may appear. For example, you may notice that there are some of the screens that are similar to each other and probably should look and behave in the same way, but they don’t; the details don’t match. Also, XML files can take many lines of code, and two similar layouts or styles can differ in many areas. So it seems that it’s not enough to have basic styling well defined. When it comes to creating a new screen, it’s important to merge the views and the styling into one layout that is consistent with the design system. And since this can be quite challenging in more complicated projects and app designs, and most of the UI-related bugs and mistakes happen here, let’s try to simplify the process.
Introducing components
It’s a good idea to start with some basic requirements and assumptions. The goal is to simplify the layout creation process to make sure that our screens are consistent and always follow the design system. Breaking our goal down, if we want to make layouts simpler, they probably should be built of something more concise than nested ViewGroups and Views with applied styles. We could wrap Views that create a logical “brick” into something handier. If we want to make sure that the screen follows the design system, those entities should be tightly connected to the styling, and they shouldn’t require any advanced code to make them styled correctly. Let’s call those beings components.
For example, horizontal bar that displays the price of a product on the left and the button for adding the product to the cart on the right, with a background, can be considered as a component.
Apart from satisfying the mentioned requirements, components should be:
- simple: ready to be used in any screen, exposing simple API,
- reusable: one component can appear on many different screens, and it shouldn’t be tied in any way to any particular screen/usage,
- customizable: apart from setting properties and listeners on the component, it would be good to have an ability to apply different styles to the component.
So how to implement the idea of a component in the Android ecosystem?
Custom views, reinvented
Let’s simplify the idea of the component by mapping it to something we already know. If we create a class that derives from a ViewGroup (like ConstraintLayout), inflate its content and expose the API, we achieved most of our requirements. However, we have some significant problems to be solved:
- Sane support for custom attributes requires a significant amount of boilerplate code. Adding custom code for parsing attributes, maintaining both initial state set in XML layout and changes introduced in the runtime can easily lead to class illegibility and bugs (even if we come up with some Kotlin extensions).
- We need a robust solution for styling components. Specifying component style in a layout works, but it is applied only to the outer ViewGroup, and if a component needs to support more than one variant, we probably need a way of styling subviews too. Also, it would be good to have a way of defining supported styles, and change the component style in the runtime.
When I was investigating the topic, I found a solution that probably could help to solve both issues. Let’s have a look at Airbnb’s Paris.
The main goal of Paris is to give an ability to style views programmatically, but the solution also brings some additional features which we will use. To solve the issue with boilerplate code when parsing custom attributes, what we only need to do is specify @Attr
annotation on the method which will apply selected attribute:
@Attr(R.styleable.MyComponent_mcTitle)fun setTitle(title: CharSequence?) { subTextView.text = title}
and make Paris read and apply attributes when the component is inflated:
@Styleable("MyComponent")class MyComponent @JvmOverloads constructor(...
...
init { // TODO inflate layout Paris.style(this).apply(attrs) }
After this operation what we have is both support for custom attributes set in the layout and sane API that invokes the same code when called in the runtime. Pretty neat.
Styling subviews can be achieved in a very similar way. Suppose we have defined mcSubTextViewStyle
custom attribute that refers to subview style, all we need to do is to annotate subview field with @StyleableChild
:
@StyleableChild(R.styleable.MyComponent_mcSubTextViewStyle)val subTextView: TextView
init { // TODO inflate layout subTextView = findViewById(R.id.sub_text_view) Paris.style(this).apply(attrs)}
Where Paris unveils its power is linking styles to views and applying styles in runtime. Suppose our component has two variants: “red” with red text and grey background and “green” with green text and white background. Paris allows us to define that styles directly in the class and style both outer view attribute and subview. Also, it generates methods to use that styles in runtime:
companion object {
@Style val RED_STYLE = myComponentStyle { backgroundColorRes(R.color.grey) mcSubTextViewStyle(R.style.RedText) }
@Style val GREEN_STYLE = myComponentStyle { backgroundColorRes(R.color.white) mcSubTextViewStyle(R.style.GreenText) }
}
...
// After inflating the componentParis.style(myComponent).applyRed()Paris.style(myComponent).applyGreen()
This was only a sneak peak of what Paris allows us to do with custom views and their styling. Apart from this, you can take advantage of combining styles or style builders. You can use XML resources or get rid of them and specify all the attribute values directly in the Java/Kotlin code. The only limitation is that Paris has to have explicit support for the attribute you want to use. The list of supported attributes is long, but not necessarily complete. Good news is that if you’re using views from some 3rd party libraries, you can prepare a ViewProxy that extends them with Paris styling ability.
Let’s go back to the topic of design system components. Paris-powered custom views are the complete solution to our problem:
- Components can be concisely defined and are ready to be used in both XML layouts and runtime,
- They can be strictly tied to the design system by defining styles in their code,
- They are simple, reusable and customizable.
What about layouts?
If we have a library of components, we can use them in the layout:
<LinearLayout ... />
<myapp.ui.MyComponent1 ... />
<myapp.ui.MyComponent2 ... />
<myapp.ui.MyComponent3 ... />
</LinearLayout>
and style them in XML or at the runtime. We achieved our goal of having layouts more concise.
But, going further, most of the screens in Android applications are just scrollable containers of views. In such a case, we often use RecyclerView
in our apps. Some of you probably use another Airbnb’s library, Epoxy, to build such screens easily even if defining their content is complicated. Good news is that Paris plays well with Epoxy.
To build the Epoxy model out of our styleable component, annotate the class with @ModelView
and methods with @TextProp
, @ModelProp
or @CallbackProp
, as you usually would do for creating Epoxy model out of the custom view. No additional code/annotations are required to generate styling-related code from Paris annotations. As a result, when building Epoxy models, you can specify styling that should be applied to created views:
override fun buildModels(data: List<ListItem>) { data.forEach { myComponent1 { id(it.id) text(it.text) withRedStyle() // Method generated from Paris style } }}
Separating, demoing and testing the design system
When you start preparing the library of components to be used in your application, soon you’ll probably notice that the list of them is quite extensive. How to keep control of it? The solution is simple:
- Prepare the separate package for your components.
- Move that package to separate module. If you already have your application modularized, this will allow you to use components in all of your feature modules.
Also, preparing another application-type module that presents components in a separated environment can also be a good idea.
If you have a component that can exist in two states, but one of those cannot be easily presented in your app even in debug mode, the demo app can be used to show it. Another gain is that you can find issues with your component before actually using it in your app.
You may also consider “unit-testing” your components. The idea is to prepare an Espresso-powered test that launches the Activity that contains the component you want to test. Then you can set some example data to it and perform assertions on views.
What have we achieved?
Let’s go back to our bigger picture: a mobile application that follows the design system/branding. How our workflow has changed after introducing components?
- The process of designing new screens for the application by UI Designers is now simplified to building screens out of components.
- Developers don’t have to dive into complex layouts built out of ViewGroups, Views and semi-random attribute values. Instead, we define our screens using reusable components.
- Components in our implementation are well-defined, concise, separated from the application and strictly tied to the design system. No more random mistakes and bugs when preparing screen layout.
We can also observe the decreased amount of time needed to prepare a new screen in the application. Now you can use this saved time to make your app even better.