Pushing the right buttons in Jetpack Compose | by Louis Pullen-Freilich | Android Developers | Aug, 2021


Louis Pullen-Freilich

The following post was written by Louis Pullen-Freilich (Software Engineer), Matvei Malkov (Software Engineer), and Preethi Srinivas (UX researcher) on the Jetpack Compose team.

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:

Initial Button API

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.

1.0 Button API

Early on during the research and experimentation stage of Compose, our Button component accepted a ButtonStyle parameter. 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:

The screen of the Rally Material Study that developers were tasked to build

Observations in these coding sessions were reviewed using the Cognitive Dimensions Framework for evaluating the usability of the Button API.

We immediately observed an interesting pattern in these sessions — a few developers started by using the Button API:

Using the Button API

Others attempted to create a Text component and surround that with a rounded rectangle:

Adding Padding around some Text to try and emulate a Button

Back then, using style APIs such as themeShape or 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 Text component¹.

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”

Correctly customizing a Button’s text style, color, and shape

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.

Early insights from developer research

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 Button appears.

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:

Defining a style for a login button

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 Button:

Creating a semantically meaningful LoginButton function

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.

Without 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:

The final LoginButton implementation

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:

OutlinedButton in 1.0

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:

  1. Create a simple Button using default values
  2. Look for some clues from the MaterialTheme.kt source file related to shape theming
  3. Review theMaterialButtonShapeTheme function
  4. Identify 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: Button became 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 Button and handling click events
  • Styling a Button using a predefined Material theme
Material Buttons from material.io

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 ContainedButton and OutlinedButton:

“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: a 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 Button with text, which is easier to implement
  • A more advanced Button that 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 String overload:

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

A primitive 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 simpleString, 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 String and 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 Button to 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 Button with an empty ‘slot’
A Button with an image and text placed in a row

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.

CL removing the primitive overloads

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.

1.0 Button API

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:

  1. 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.
  2. 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.





Source link

Latest articles

Related articles

Leave a reply

Please enter your comment!
Please enter your name here