by Louis Pullen-Freilich (software engineer), Matvei Malkov (software engineer) and Preethi Srinivas (UX researcher) of the Jetpack Compose team.
Recently Jetpack Compose released 1.0 version , bringing a series of stable APIs for building UI. Earlier this year, we released the API guide , which introduced the best practices and API design patterns for writing Jetpack Compose API. The guidelines formed after many iterations of the public API interface (API surface) did not actually show the formation process of these design patterns and the story behind our decision-making during the iteration.
This article will take you through the "evolutionary journey" of a "simple" Button, to understand how we iteratively design the API to make it simple and easy to use without losing flexibility. This process requires multiple adaptations and improvements to the usability of the API based on developer feedback.
Draw a clickable rectangle
There is a joke in Google's Android Toolkit team: All we do is draw a colored rectangle on the screen and make it clickable. It turns out that this is one of the most difficult things to achieve in the UI toolkit.
Some people might think that a button is a simple component: just a colored rectangle with a click listener. There are many reasons for the complexity of the Button API design: discoverability, order and naming of parameters, and so on. Another constraint is flexibility: Button provides many parameters for developers to customize each element at will. Some of these parameters use the theme's configuration by default, and some parameters can be based on the values of other parameters. This combination makes the design of Button API an interesting challenge.
Our first iteration of the Button API started public commit The API at the time was like this:
@Composable
fun Button(
text: String,
onClick: (() -> Unit)? = null,
enabled: Boolean = true,
shape: ShapeBorder? = null,
color: Color? = null,
elevation: Dp = 0.dp
) {
// 下面是具体实现
}
△ The original Button API
Apart from the name, the original Button API is far from the final version of the code. It has gone through many iterations, and we will show you this process:
@Composable
fun Button(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
elevation: ButtonElevation? = ButtonDefaults.elevation(),
shape: Shape = MaterialTheme.shapes.small,
border: BorderStroke? = null,
colors: ButtonColors = ButtonDefaults.buttonColors(),
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
content: @Composable RowScope.() -> Unit
) {
// 下面是具体实现
}
△ 1.0 version of Button API
Get developer feedback
In the early stages of Compose's research and experimentation, our Button component can receive a ButtonStyle type parameter. ButtonStyle defines visual-related configuration for Button, such as color and shape. This allows us to display three different Material Button types : Contained, Outlined and Text; we directly expose the top-level construction function, which will return a ButtonStyle Instance, this instance corresponds to the corresponding button type in the Material specification. Developers can copy these built-in button styles and fine-tune them, or create a new ButtonStyle
from scratch to completely redesign the custom Button. We are quite satisfied with the original Button API, which is reusable and contains easy-to-use styles.
In order to verify our assumptions and design methods, we invite developers to participate in programming activities and use the Button
API to complete simple programming exercises. The programming exercises include the interface that implements the following figure:
Rally Material Study that developers need to develop
Observations on the development of these codes used Cognitive Dimensions Framework (Cognitive Dimensions Framework) to review the usability of .
Soon, we observed an interesting phenomenon: some developers started using Button API like this:
Button(text = "Refresh"){
}
△ Use Button API
There are also developers who try to create a Text component, and then use a rounded rectangle to surround the text:
// 这里我们有 Padding 可组合函数,但是没有修饰符
Padding(padding = 12.dp) {
Column {
Text(text = "Refresh", style = +themeTextStyle { body1 })
}
}
△ Add Padding to Text to simulate a Button
When using style APIs, such as themeShape
or themeTextStyle
, you need to add the + operator prefix. This is due to the specific limitations of the Compose Runtime at the time. Developer surveys show that: Developers find it difficult to understand how this operator works. The enlightenment we get from this phenomenon is that API styles that are not directly controlled by the designer will affect the developer's perception of the API. For example, we learned that a developer's comment on the operator here is:
As far as I understand it, it is reusing an existing style or extending it based on that style.
Most developers believe that there is an inconsistency between the Compose API-for example, the way to style the Button is different from the way to style the Text component*.
*Most developers want to add a "plus" before the style, using +themeButtonStyle or +buttonStyle, similar to the way they use +themeTextStyle for the Text component.
In addition, we found that most developers have experienced a painful process when implementing rounded edges Button
Usually, they need to browse multiple levels of implementation code to understand the structure of the API.
I feel that I just stacked some things randomly here, and I don't have the confidence to make it work.
Button{
text = "Refresh",
textStyle = +themeStyle {caption},
color = rallyGreen,
shape = RoundedRectangleBorder(borderRadius = BorderRadius.circular(5.dp.value))
}
△ Correctly customize the text style, color and shape of the Button
This affects the way developers style Button
For example, when adding a Button to an Android application, ContainedButtonStyle
cannot correspond to the style known to the developer. Click here view early insight videos from developer research.
Through these programming activities, we realized the need to simplify the Button
API to enable simple custom operations and support complex application scenarios. We began to work on discoverability and personalization, and these two points brought us the next series of challenges: style and naming .
Maintain API consistency
In our programming activities, styles pose a lot of problems for developers. To understand the reason, let's go back to why the concept of style exists in the Android framework and other toolkits.
"Style" is essentially a collection of UI-related attributes that can be applied to components (such as Button
). The style contains two main advantages:
1. Separate the UI configuration from the business logic
In imperative toolkits, independently defining styles helps separate concerns and makes code easier to read: UI can be defined in one place, such as an XML file; callbacks and business logic can be defined and associated in another place.
In declarative toolkits like Compose, the coupling between business logic and UI will be reduced through design. Components like Button are mostly stateless, it just displays the data you pass. When the data is updated, you do not need to update its internal state. Since components are also functions, they can be customized by passing parameters to the Button function, just like other functions. But this will increase the difficulty of separating the UI configuration from the functional configuration. For example, set the Button's enabled = false
, not only control Button
function, but also to control Button
is displayed.
This leads to a question: enabled
be a top-level parameter, or should it be passed as an attribute in the style? Button
about other styles that can be used for 0615bc6c4db339, such as elevation, or when Button
is clicked, its color changes? A core principle in designing usable APIs is to maintain consistency. We found that it is very important to ensure API consistency among different UI components.
2. Customize multiple instances of a component
In a typical Android View system, the style is very advantageous because the cost of creating a new component is high: you need to create a subclass, implement the constructor, and enable custom attributes. The style allows a series of shared attributes to be expressed in a more concise way. For example, create a LoginButtonStyle
to define the appearance of all login buttons in the application. In Compose, the implementation is as follows:
val LoginButtonStyle = ButtonStyle(
backgroundColor = Color.Blue,
contentColor = Color.White,
elevation = 5.dp,
shape = RectangleShape
)
Button(style = LoginButtonStyle) {
Text(text = "LOGIN")
}
△ Define the style for the login button
Now in a variety of UI Button
used on LoginButtonStyle
, without the need of each Button
explicitly set these parameters. However, if you also want to extract the text so that all login buttons display the same text: "LOGIN" , what should you do?
In Compose, each component is a function, so the conventional solution is to define a function, which calls Button
and provides the correct text Button
@Composable
fun LoginButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Button(onClick = onClick, modifier = modifier, style = LoginButtonStyle) {
Text(text = "LOGIN")
}
}
△ Create a LoginButton function that semantically expresses its meaning
Due to the inherent stateless nature of components, the cost of refining functions in this way is very low: parameters can be passed directly from the encapsulated function to the internal button. Since you are not inheriting a class, only the required parameters are exposed; the rest can be left in LoginButton
, so as to avoid the color and text being overwritten. This method is suitable for many custom scenarios and exceeds the scope of the style.
In addition, compared to setting Button
LoginButtonStyle
, creating a LoginButton
can have more semantic meaning. We also found in the research process: Compared to styles, independent functions are more discoverable.
Without styles, LoginButton
can now be refactored to directly Button
without using style objects, so that it can be consistent with other custom operations:
@Composable
fun LoginButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Button(
onClick = onClick,
modifier = modifier,
shape = RectangleShape,
elevation = ButtonDefaults.elevation(defaultElevation = 5.dp),
colors = ButtonDefaults.buttonColors(backgroundColor = Color.Blue, contentColor = Color.White)
) {
Text(text = "LOGIN")
}
}
△ The final LoginButton implementation
Eventually we rid style , and flatten into the assembly parameters - both to the consistency of the overall design of Compose, on the other hand is to encourage developers to create more semantic features of the "package" function:
@Composable
inline fun OutlinedButton(
modifier: Modifier = Modifier.None,
noinline onClick: (() -> Unit)? = null,
backgroundColor: Color = MaterialTheme.colors().surface,
contentColor: Color = MaterialTheme.colors().primary,
shape: Shape = MaterialTheme.shapes().button,
border: Border? =
Border(1.dp, MaterialTheme.colors().onSurface.copy(alpha = OutlinedStrokeOpacity)),
elevation: Dp = 0.dp,
paddings: EdgeInsets = ButtonPaddings,
noinline children: @Composable() () -> Unit
) = Button(
modifier = modifier,
onClick = onClick,
backgroundColor = backgroundColor,
contentColor = contentColor,
shape = shape,
border = border,
elevation = elevation,
paddings = paddings,
children = children
)
△ OutlinedButton in version 1.0
Improve the discoverability or visibility of
We also found in our research that there is a major flaw in how to set the button shape. To customize the shape of the Button, developers can use the shape parameter, which accepts a Shape object. When a developer needs to create a new button with chamfered corners, it can usually be achieved as follows:
- Create a simple
Button
- From the
MaterialTheme.kt
source file, refer to the content related to the theme setting of the shape - Look back at the
MaterialButtonShapeTheme
function - Find
RoundedCornerShape
, and use a similar method to create a shape with chamfered corners
Most developers will feel confused here, and are often at a loss when browsing a large number of APIs and source codes. We found that it is not easy for developers to find CutCornerShape
because it is exposed from a package different from other shape APIs.
Visibility is a measure of how easy it is for developers to locate functions or parameters when they reach their goals. It is directly related to the effort spent on the cognitive process required to write code; the deeper the path used to explore, discover and use a method, the worse the visibility of the API. Ultimately, this leads to lower efficiency and a poor developer experience. Based on this knowledge, we will migrate CutCornerShape to the same other shape API package to support easy discoverability.
mapping developer's work framework
Next comes more feedback-in a series of further programming activities, we re-evaluated the usability of Button
In these activities, we use the definition of the button in Material Design to name: Button
becomes ContainedButton
to conform to its characteristics in Material Design. Then, we tested the new naming and the entire Button API that existed at the time, and evaluated two main developer goals:
- Create
Button
and handle the click event - Use the predefined Material theme to add styles
Button
△ Material Button in material.io
We got a key revelation from the developer activity-most developers are not familiar with the naming convention in Material Button. For example, many developers cannot distinguish ContainedButton
from OutlinedButton
:
What does ContainedButton mean?
We found that when entering Button
and seeing the three Button components suggested by auto-completion, developers spent considerable effort to guess which one is what they need. Most developers want the default button to be ContainedButton
, because this is the most commonly used one and the one that looks most like a "button". So it is clear that we need a default setting so that developers can use it directly without reading the Material Design guide. In addition, the view-based MDC-Android Button
defaults to the fill button, which is also a precedent for using it as the default button.
Describe the role more clearly
The study found that another confusing point is the two existing Button
: a Button
accept a String type parameter as text, and a Button
accept a modifiable lambda parameter, representing general content. The original intention of this design is to provide APIs from two different levels:
Button
with text is simpler and easier to implement- The more advanced
Button
, its content is more open
We found that developers have certain difficulties when choosing between the two: but when String
overload to lambda overload, the existence of "cliff" Button
challenge. We often hear developers request to add TextStyle
parameter Button
String
It allows to customize the internal TextStyle without using the lambda overloaded version.
We provide String
with the intention of simplifying the implementation of the simplest use cases, but this prevents developers from using the overload with composable lambdas, and instead requires the String
overload to add additional functions. The existence of these two separate APIs not only caused confusion for developers, but also showed that there are some fundamental problems with overloading with primitive types: They accepted primitive types, such as String
, rather than composable lambda types.
single-step code
The primitive type Button
overload directly takes text as a parameter, which reduces the code that developers need to write when creating a text-based Button. We initially used the simple String
type as the text parameter, but later found that the String type was difficult to add style to some of the text.
For such needs, Compose provides AnnotatedString API to add custom styles to different parts of the text. However, it adds a certain cost to simple application scenarios, because developers first need to convert String to AnnotatedString. This also makes us consider whether we should provide a new Button overload, which can accept String as a parameter or AnnotatedString as a parameter to support simple and more advanced requirements.
Our API design discussion is more complicated in terms of pictures and icons, such as when FloatingActionButton needs pictures or icons. Should the type of the icon parameter be Vector or Bitmap? How to support animated icons? Even if we tried our best, we finally found that we can only support the types available in Compose-any third-party image types require developers to implement their own overloads to provide support.
Side effects of tight coupling
One of Compose's biggest advantages is composability. Create composable functions to separate concerns at a small cost, and build reusable and relatively independent components. Through the composable lambda overload, you can intuitively see the idea: Button is a container for clickable content, but it does not need to care about what the content is.
But for the overloading of primitive types, the situation becomes more complicated: Button that directly accepts text parameters now needs to be used as a clickable container, and the Text component needs to be passed inside. This means that it now needs to manage the public API interface of the two, which also raises another important question: What kind of text-related parameters should the Button expose to the outside world? This also binds the public API interfaces of Button and Text: If new parameters and functions are added to Text in the future, does that mean Button also needs to add support for these new content? Tight coupling is one of the problems that Compose tries to avoid, and it is difficult to answer this question on all components in a unified way, which also leads to inconsistencies in the public API interface.
supports working framework
The overloading of primitive types allows developers to avoid the use of composable lambda overloads, at the expense of less customization space. But what about when developers need to implement customizations that cannot be achieved by overloading primitive types? The only option is to use composable lambda overloads, and then copy the internal implementation code from the original type overloads and modify them accordingly. In our research, we found that the "cliff" custom operations prevents developers from using more flexible and composable APIs, because operations between levels seem more challenging than before.
Use "slot API" to solve the problem
After enumerating the above problems, we decided to remove the original type overload of Button, leaving only the API that contains combinable lambda parameters for each type of Button. We began to call this general API form "slot API" , and it has been widely used in various components.
Button(backgroundColor = Color.Purple) {
// 任何可组合内容都可以写在这里
}
△ Button with blank "slot"
Button(backgroundColor = Color.Purple) {
Row {
MyImage()
Spacer(4.dp)
Text("Button")
}
}
△ Button with pictures and text arranged horizontally
A "slot" represents a combinable lambda parameter, which represents any content in the component, such as Text or Icon. Slot API increases composability, makes components simpler, reduces the number of independent concepts between components, and enables developers to quickly create a new component or switch between different components.
△ Remove the overloaded CL of the original type
Looking to the future
The number of changes we have made to the Button API, the amount of time spent in meetings discussing Button, and the amount of energy invested in collecting feedback from developers is amazing. Having said that, we are very satisfied with the overall effect of the API. In hindsight, we see that Button has become more discoverable and customizable in Compose, and most importantly, it promotes combinatorial thinking.
@Composable
fun Button(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
elevation: ButtonElevation? = ButtonDefaults.elevation(),
shape: Shape = MaterialTheme.shapes.small,
border: BorderStroke? = null,
colors: ButtonColors = ButtonDefaults.buttonColors(),
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
content: @Composable RowScope.() -> Unit
) {
// 实现体代码
}
It is important to realize that our design decisions are based on the following slogan:
Make simple development simple, and make difficult development possible. *
*This is from a well-known technical book: English version: "Learning Perl: Making Easy Things Easy and Hard Things Possible" (by Randal L. Schwartz, Brian D Foy and Tom Phoenix), Chinese version: "Perl Language Introduction" ( Translated by Sheng Chun)
We try to make development easier by reducing overloading and flattening the "style". At the same time, we improved the auto-completion function of Android Studio to help developers improve efficiency.
Here we hope to put forward two key points in the entire API design process:
- API is an iterative process. is almost impossible for 1615bc6c4dbda7 to reach perfection in the first iteration of the API. There are some requirements that are easily overlooked. As the author of an API, you need to make some assumptions. This includes the different backgrounds of developers and the different ways of thinking ¹ , which ultimately affects the way developers explore and use APIs. Adaptation adjustments are unavoidable. This is a good thing. Continuous iteration can get a more usable and more intuitive API.
- When iterating an API design, one of your most valuable tools is the feedback loop that developers experience using the API. For our team, the most important thing is to understand what the developers mean by "this API is too complicated". When an API is called incorrectly, it usually reduces the developer's success rate and efficiency. The insights gained from it will help us understand the meaning of "complex API" more deeply. The key driver of our continuous iteration is to design easy-to-use and great APIs. To this end, to create a developer feedback loop, we used a variety of research paths-on-site programming activities ² , and a remote path that requires developers to provide experience diary ³ We can already understand how developers deal with APIs and the path they take to find the right way for the functions they intend to implement. Pillars in frameworks such as Programmer Thinking Styles and Cognitive Dimensions help our cross-functional teams maintain consistency in language thinking, not only in reviewing and communicating developer feedback, but also Involved in API design discussions. In particular, when evaluating the relationship between user experience and functionality, this framework helps us shape the discussion of choices and trade-offs.
- from Android Developer UX team Meital Tagor Sbero by role models and ways of thinking (Personas & Thinking Styles) design and cognitive dimension framework (Cognitive Dimensions Framework) inspired, engineers developed a way of thinking Framework (Programmer Thinking Styles Framework). The framework uses the developer's motivation and attitude of the "type of solution" required within a limited time to help developers determine the design ideas for API usability. It takes into account the working methods of ordinary engineers, and optimizes usability for high-intensity development tasks.
- We usually use this method to evaluate the availability of specific aspects of the API. For example, each event will invite a group of developers to use the Button API to complete a series of development tasks. These tasks will deliberately expose some of the features of the API, and these features are the goals we hope to collect feedback on. We use thinking aloud to get more information about what the developer is after and what the developer envisions. These activities also include some follow-up questions for researchers to further understand the needs of developers. We will review these activities to determine the behavioral patterns of developers that contribute to success or failure in programming tasks.
- We usually use this method to evaluate the usability and ease of learning of the API over a period of time. In this way, you can capture the moments of difficulty and the moments of inspiration by listening to the feedback of the developers in their regular work. In this process, we will have a group of developers to develop a specific project of their choice, while also ensuring that they will use the API we wish to evaluate. We will combine the developer's self-submitted diary, the example ), and interviews to help us determine the availability of the API.
We admit that although we Button
API, we also know that it is not perfect. There are many ways of thinking of developers, coupled with different application scenarios, and endless needs, requiring us to continue to meet new challenges. This is not a problem! Button
is of great significance to us and the developer community. All of these are designed and shaped for Compose a usable Button
API-a simple rectangle that can be clicked on the screen.
I hope this article can help you understand how your feedback helps us improve the Button API in Compose. If you encounter any problems when using Compose, or have any suggestions and ideas improving the experience of the new API, please let us know. Developers are welcome to participate in our next user research activity , and we look forward to your registration.
Welcome to click here to submit feedback to us, or share your favorite content, found problems. Your feedback is very important to us, thank you for your support!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。