Jetpack Compose recently reached 1.0, bringing with it a set of stable APIs to build UIs. Earlier this year, we published our API guidelines, outlining the best practices and API design patterns for writing Jetpack Compose APIs. These guidelines are the result of many iterations over our API surface, but do not show how these patterns emerged or the reasoning behind decisions we made.
Today we will walk you through the evolutionary journey of a relatively ‘simple’ component,
Button, to give you an inside look at how we iteratively designed the APIs to be easy to use, yet flexible. This required several adjustments and improvements to the API’s usability based on developer feedback.
There’s an inside joke in the Android Toolkit team at Google that everything we do is just painting a colored rectangle on the screen and making it click. As it turns out, it’s one of the hardest things to get right in a UI toolkit.
One might assume that a button is a simple component — a colored rectangle with a click listener. There are many different things that make designing the
Button API complicated: discoverability, the order and naming of parameters, and more. An additional constraint is flexibility:
Button provides many parameters so that developers can customize individual elements as they like. Some of the parameters use values from the theme by default, while some can depend on the values of others. These combine to make designing the
Button API an interesting challenge.
We started with a public commit that looks like this for our first iteration of the
Button API two years ago:
This initial shape of the
Button API has very little in common with the final version we have settled on apart from the name. It has evolved over many iterations which we will walk you through.
Early on during the research and experimentation stage of Compose, our
Button component accepted a
ButtonStyle modeled visual configuration for the
Button, such as its color and shape. This allowed us to represent the three distinct Material Button types (Contained, Outlined & Text); we simply exposed top level builder functions that return an instance of
ButtonStyle corresponding to a button type from the Material specification. Developers could either
copy one of these built-in styles to make small adjustments, or fully customize a
Button by creating a new
ButtonStyle from scratch. We felt comfortable with the initial version of the
Button API — an API that is reusable and includes easy-to-use styles.
To validate our assumptions and design approach, we invited developers to join coding sessions to complete simple programming exercises using the
Button API. The programming exercises involved building this screen:
We immediately observed an interesting pattern in these sessions — a few developers started by using the
Others attempted to create a
Text component and surround that with a rounded rectangle:
Back then, using style APIs such as
themeTextStyle required the preceding
+ operator. This existed due to certain limitations that the Compose Runtime had at that time. Developer research surfaced that developers found it difficult to know what the operator did. A key takeaway from this observation was that aspects of an API that are not under an API designer’s direct control can influence how an API is perceived. For instance, we heard a developer make the following comment about the operator:
“As far as I understand this is to reuse an existing style or maybe extend on top of it”
Most developers called out inconsistencies between Compose APIs — for instance, the technique used to style a
Button was not similar to how one would style a
In addition, we observed that most developers experienced significant difficulties applying a rounded border to a
Button, a coding task that one would expect to be very simple. Often, they traveled multiple levels deep in the implementation to understand the API structure.
“I am just putting random stuff in here, definitely don’t have the confidence that this would work”
This influenced how developers applied styling to a
Button. For example,
ContainedButtonStyle did not map to what developers already knew when implementing buttons for Android apps.
From the coding sessions, we understood that we needed to simplify the
Button API to make it easier to achieve simple customizations, while still supporting complex use cases as well. We started with discoverability and customizability, which brings us to our next set of challenges: styling and naming.
Styles caused a lot of problems for developers in our coding sessions. To understand some of them, let’s take a step back and evaluate why styles as a concept exists in the Android framework and other toolkits.
A ‘style’ is essentially a collection of UI-related attributes that can be applied to a component, such as a
Button. Styles have two main benefits:
1. Separating UI configuration from business logic
In imperative toolkits, being able to define styles independently helps separate concerns and makes code easier to read: the UI can be defined in one place, such as an XML file, with callbacks and business logic being defined and attached separately.
In a declarative toolkit such as Compose, business logic and UI are less coupled by design. Components such as a
Button are mostly stateless, and simply display the data you pass to them, without you needing to update internal state when a new value arrives. And because components are just functions, customization can be done by passing a parameter to the
Button function, just as for any other function. But this can make separating the UI configuration from behavioural configuration difficult. For instance, setting
enabled = false on a
Button not only controls how the
Button behaves, but it also controls how the
This led to a question: should
enabled be a top level parameter or should it be passed as a property within a style? What about other styling that can be applied to a
Button, such as elevation, or a color change when a
Button is pressed? A core principle to designing usable APIs is maintaining consistency; we recognized that it is important to ensure API consistency across different UI components.
2. Customizing multiple instances of a component
In the classic Android
View system, styles are beneficial because the cost of creating a new component is very high: you need to create a subclass, implement constructors, and apply custom attributes. Styles allow expressing a shared set of attributes in a much more concise manner. For example, consider creating a
LoginButtonStyle to define the appearance for all login buttons in an application. In Compose, this could look as follows:
LoginButtonStyle can now be reused across multiple
Buttons in your UI, without needing to explicitly set all these parameters on each
Button. However, what if you want to extract the text as well, so each login button has the same text: “LOGIN”?
In Compose, every component is a function, so the natural solution here is to define a function that calls
Button internally and provides the correct text to the
The cost of extracting a function in this way is very low, due to the stateless nature of components: parameters can be directly passed from the wrapping function to the internal button. And since you are not extending a class, you only need to expose the parameters you want; everything else can be kept internal to the implementation of the
LoginButton, preventing the color and text from being overridden. This approach allows for a much wider range of customization than is possible with just a style.
In addition, it is semantically more meaningful to create a
LoginButton function than to pass a
LoginButtonStyle to a
Button. We also observed from research sessions that standalone functions are much more discoverable than styles.
LoginButton can now be refactored to directly pass parameters to the underlying
Button instead of needing a style object, consistent with any other customization:
As a result we removed styles and flattened the parameters directly into the component — both for consistency with the overall Compose philosophy, and to also encourage developers to create semantically meaningful ‘wrapper’ functions:
We also observed in research a significant flaw with how shapes could be applied to buttons. To customize the shape of a
Button, developers could use the shape parameter, which accepts a
Shape object. Developers tasked with creating a button with cut corners, commonly adopted this approach:
- Create a simple
Buttonusing default values
- Look for some clues from the
MaterialTheme.ktsource file related to shape theming
- Review the
RoundedCornerShape, and attempt to use a similar approach for creating a shape with cut corners
Most developers were lost at this point, often feeling overwhelmed with the depth they had travelled when reviewing APIs and source code. We observed that developers experienced significant difficulties discovering
CutCornerShape since it was exposed in a separate package from the other shape APIs.
Visibility is a measure of how easily developers can locate the functions or parameters necessary to accomplish their goals. It is directly related to the cognitive effort that is required while coding; the longer the search trail to finding and using a method, the less visible is the API. Consequently, this would lead to a less productive and satisfactory developer experience. Owing to this insight, we moved CutCornerShape to be in the same package as the other shape APIs to support easy discoverability.
It was now time for more feedback — we went back to evaluating the usability of
Button API in a series of further coding sessions. For these sessions, we named buttons precisely as they are specified in the Material Design specification:
ContainedButton to comply with the specification. We then tested the new naming along with the overall API for the
Buttons we had at that time. Two primary developer goals were evaluated:
- Creating a
Buttonand handling click events
- Styling a
Buttonusing a predefined Material theme
A key insight we came away with from these sessions was that most developers were not familiar with the naming convention used for Material Buttons. For example, many were unable to differentiate between
“What does ContainedButton mean?”
We observed developers spending considerable effort guessing when typing
Button and seeing autocomplete suggest three
Button components. Most developers expected the default to be
ContainedButton, as it is the most commonly used one and the one that most resembles a ‘button’. It was clear that we needed a default that developers could use without needing to read the Material design guidance. Additionally, the view-based MDC-Android
Button defaults to a contained button, so there was precedent for using it as a default here too.
Another point of confusion from research was the existence of two versions of
Button that accepts a
String parameter for text, and a
Button that accepts a composable lambda parameter representing generic content. The intention here was to provide APIs in two distinct layers:
- A simpler
Buttonwith text, which is easier to implement
- A more advanced
Buttonthat is less opinionated about the content placed inside it
We observed developers experiencing difficulty when choosing one over the other: the
String overload was simple to start with, but the existence of a customization ‘cliff’ when moving from the
String overload to the lambda overload made it challenging to incrementally customize a
Button. A common request we heard from developers was to add a
TextStyle parameter to the
Button with the
It will allow for customizing the internal TextStyle without having to drop down to using the lambda overload.
Our intention in providing the
String overload was to make the simplest use cases simple, but this discouraged developers from using the overload with a composable lambda, leading to requests for extra functionality on the
String overload. Not only was the existence of these two separate API shapes confusing to developers, but it was clear that there were some fundamental problems with the ‘primitive’ overloads: those accepting raw types such as
String instead of composable lambdas.
Stepping through code
Button overload accepts text directly as a parameter, reducing the amount of code a developer needs to write to create a
Button with text inside. We started by making the type of this text parameter a simple
String, but recognized that a
String does not offer separate styling for different parts of the text.
For this use case, Compose provides the
AnnotatedString API, which allows developers to apply custom styling to different parts of some text. However, this adds some overhead for simple use cases, as developers first need to convert their simple
Strings to an
AnnotatedString. This made us question whether we should provide
Button overloads with both
AnnotatedString parameters to support both the simple and more advanced cases.
Our API design discussions were further complicated for images and icons, such as when used in a
FloatingActionButton. Should the type of the
icon parameter be a
Vector or a
Bitmap? How will animated icons be supported? Even with our best efforts, we recognized that we would only be able to support the types available inside Compose — any third party image types would require developers to make their own overloads that supported these.
Side-effects of tight coupling
One of Compose’s biggest strengths is composability. The small cost of creating a composable function makes it easier to separate concerns, and build reusable and isolated components. With composable lambda overloads, it is easy to see this separation of concerns: a
Button is a clickable container for content, but it does not need to know what that content is.
But with primitive overloads, it is a bit more complicated: a
Button that accepts text directly is now responsible for both being the clickable container, and emitting the
Text component inside. This means that it now needs to manage the API surface for both, which raises another important question: what text-related parameters should
Button expose? This also ties the API surface of
Text: if there are new parameters and functionality added to
Text in the future, does this mean that
Button also needs to add support for them? This tight coupling is one of the problems that Compose tries to avoid, and it is hard to answer these questions in a consistent way across all components, which leads to inconsistency in our API surface.
Supporting working framework
Primitive overloads by design allow developers to avoid using the composable lambda overload, in exchange for less possible customization. But what happens when a developer wants to customize something that isn’t possible in the primitive overload? The only option is to use the composable lambda overload, and then copy-paste the internal implementation from the primitive overload, with the desired changes. We found in research that this customization ‘cliff’ discouraged developers from using the more flexible, composable APIs, as the work required to move between layers seemed a lot more challenging than it was.
Slots to the rescue
Given the above-mentioned problems, we decided to remove the primitive overloads for
Button, leaving behind one API for each
Button that contained a composable lambda parameter for its content. We started to refer to this general API shape as a “slot API”, a shape that is now widely used across components.
A ‘slot’ refers to a composable lambda parameter, which represents arbitrary content inside a component, such as
Text or an
Icon. Slot APIs increase composability, making components simpler, and reduce the number of unique concepts across components, making it easier for developers to start using a new component, or move between components.
The number of changes we’ve made in the
Button APIs, the amount of time we have spent in meetings talking about
Button, and the effort we have spent in capturing developer feedback is astonishing. That being said, we are pretty happy with where we landed with this API. In hindsight, we can see how the
Button in Compose is much more discoverable, customizable, and most importantly, promotes a composable mindset.
It is important to acknowledge that most of our design decisions were based on the following mantra:
“Make the development of simple things simple, and the hard things possible” ²
We attempted to make things simpler by removing overloads and flattening ‘styles’, while making improvements to Android Studio autocomplete to help developer productivity.
There are two major takeaways from the overall API design process that we would like to mention explicitly:
- API design is an iterative process. It is almost impossible to come up with something perfect in the very first iteration of an API. There are requirements that are easy to miss. There are assumptions that you have to make as an author of an API. These include the different contexts of developers’ backgrounds, leading to different thinking styles³ that influence the ways one discovers and uses an API. Adjustments will be inevitable, which is a good thing, since iterations lead to a more usable and intuitive API.
- The feedback loop of developers’ experience using an API is one of the most valuable tools in your arsenal when iterating on an API design. It has been absolutely critical for our team to understand what it means when a developer says “this API is too complex”. This has often been informed by our need to understand and learn from incorrect usage of APIs, often resulting in decreased developer success and productivity. A key driver motivating this need is our intent to design easy-to-use and delightful APIs. To this end, we have used a mix of research approaches to create a developer feedback loop — ranging from live coding sessions⁴ to remote approaches that require developers to keep a diary of their experiences⁵. We have been able to understand how developers approach an API, and the paths they take to find the right handle for a functionality they intend to implement. The pillars of frameworks such as Programmer Thinking Styles and Cognitive Dimensions have been particularly helpful for our cross-functional team to align on a language not only while reviewing and communicating developer feedback, but also while having API design discussions. In particular, this framework has helped shape our conversations on the choices and tradeoffs we have been making when evaluating user experience against functionality.
We do acknowledge that although we like the current version of the
Button API, we know it is not perfect. There are multiple developer thinking styles, coupled with different working contexts, and emerging requirements that are going to require us to address new challenges. And that’s fine! The whole evolutionary process of
Button is worth so much for us and for the developer community. All of this is to say that our process has helped design and shape a usable
Button API for Compose — a simple clickable rectangle on the screen.
We hope this article has shed some light on the behind-the-scenes of how your feedback influenced improvements to the
Button API for Compose. As always, if you encounter any issues while implementing in Compose, or have an idea for a new API(s) that can improve your experience, please file a bug here. We are also looking for developers to participate in future user research sessions — sign up here to participate in a research study.