Designing scalable Compose APIs
Summary
TLDRSimona Milanovic's session on designing scalable Compose APIs emphasizes the importance of creating high-quality, use-case-based APIs in the Jetpack Compose framework. She discusses best practices for planning, structuring, and naming components like FilterChip, leveraging Kotlin conventions, and maintaining API consistency. The talk also covers accessibility, testing, and documentation to ensure long-term API stability and usability, guiding developers towards better design practices.
Takeaways
- π The core of Jetpack Compose is the composable function, which is essential for building UI and establishing a scalable API.
- π When creating Compose APIs, it's important to have guidelines that ensure code scalability, consistency, and intuitiveness, as well as to guide best practices.
- π‘ Start by identifying a single problem to solve with a new API, adhering to the principle of 'one component, one problem' for clarity and ease of use.
- π For variations of a component with similar UI but different purposes, consider building layered, higher-level APIs that are more constrained and offer fewer customization options.
- π Naming conventions in Compose are crucial; use PascalCase for composable functions returning Unit, and standard Kotlin conventions for functions returning values.
- π« Avoid implicit or obscure inputs in APIs; instead, use explicit parameters to enhance readability, adjustability, and ease of use.
- π¨ Modifiers are key in Compose for defining the appearance and behavior of composables; ensure every UI-emitting component has a modifier parameter for customization.
- π Prefer stateless composables that accept state as input and are controlled by the caller, which improves reusability and testability.
- π§ Parameters should be ordered logically, starting with required parameters indicating the main purpose, followed by modifiers and optional parameters with defaults.
- π Use default parameter values and nullability to convey meaningful API descriptions, distinguishing between default, empty, and absent values.
- 𧩠Slot parameters or slot content allow for flexibility within components, enabling users to insert custom content while the component handles its main responsibilities.
- π Ensure API documentation is thorough, including capabilities, parameters, expectations, and usage examples, and maintain backward compatibility for long-term stability.
Q & A
What is the basic building block in Jetpack Compose?
-The basic building block in Jetpack Compose is the composable function, which is used to write UI components that call other functions.
Why is it important to establish guidelines for writing Compose code?
-Establishing guidelines for writing Compose code is important for creating high-quality, scalable code that is easier to evolve with minimum friction, consistent across the Compose ecosystem, and follows known patterns to guide others towards better practices.
What are the three parts of the best practices and guidelines for developing idiomatic Compose APIs?
-The three parts are: planning for your components, leveraging Kotlin and naming conventions to define a solid structure of your API, and verifying and maintaining these APIs.
What should be the approach when considering creating a new API, like a FilterChip?
-When considering creating a new API, start by determining if the API is meant to solve a single problem and ensure it is use-case based, making it easy and clear to use.
What is the concept of layered higher-level APIs in Compose?
-Layered higher-level APIs in Compose are more constrained and provide specific behavior and defaults with fewer customization options. They are usually built on top of extracted lower-level components that define a common surface and expectations for all layers.
How should naming be approached for composable functions in Compose?
-Composable functions should be named using PascalCase and a noun, with or without prefixes, to establish a persistent identity across recompositions and reinforce the declarative model of describing UI with Compose.
Why is it recommended to use explicit parameters for defining an API's purpose and requirements?
-Explicit parameters make it easy to present, adjust, preview, test, and use the API. They also ensure the API is readable from the start and easier for users to understand at a glance.
What is the significance of modifiers in Compose and how should they be used in API parameters?
-Modifiers define the composable behavior and appearance. They should be at the top of the mind when defining API parameters, as Compose users already have expectations around their use. Every component that emits UI should have a modifier parameter that is the first optional parameter, has a default no-op value, and is the only parameter of its type.
How should the order of parameters be determined when defining an API?
-The order should prioritize readability and ease of use, starting with required parameters indicating the main purpose, followed by the modifier for customization, optional parameters with default values, and a trailing composable lambda for nested content if needed.
Why is it preferable to use stateless composables in API design?
-Stateless composables that hold no state of their own but instead accept it as input are more reusable and testable, as the caller dictates the changes and the component follows, a practice known as state hoisting.
How can an API be made more testable and what are some considerations for testability?
-An API can be made more testable by avoiding the use of platform-specific resources, providing explicit parameters instead of implicit ones, exposing all possible states as parameters, and using interactionSource for complex states. It's also important to ensure that the API can be easily verified in isolation.
What are some key considerations for API documentation and backward compatibility?
-Documentation should clearly communicate the API's capabilities, purpose, parameters, expectations, and provide usage examples following KTDoc guidelines. For external usage, ensuring backward compatibility is crucial for long-term stability and maintenance.
How does the concept of 'one component, one problem' benefit API design in Compose?
-'One component, one problem' ensures that the API is focused and use-case based, making it easier to understand and use, and reducing the complexity of the component.
Outlines
π Building Scalable Compose APIs
Simona Milanovic introduces the concept of designing scalable APIs in Jetpack Compose, emphasizing the importance of creating high-quality, intuitive, and maintainable code. The session covers the basics of composable functions and the role of API developers in establishing guidelines for scalable UI development. It outlines the structure of the talk, which includes planning components, leveraging Kotlin and naming conventions, and maintaining APIs. The guidelines are presented as strict rules for framework contributions and as recommendations for app development, tailored to team and app requirements.
π― Design Philosophy and Naming Conventions
This paragraph delves into the design philosophy behind creating new Compose components, such as FilterChip, and the decision-making process involved. It discusses the principle of 'one component, one problem' and the considerations for creating variations of a component. The paragraph also covers naming conventions for composable functions, the use of prefixes for different types of components, and the importance of parameters in defining an API's purpose. Modifiers are highlighted as a key concept in Compose for defining the behavior and appearance of components.
π API Structure and Parameter Ordering
The focus shifts to the structure of APIs, detailing the order and types of parameters that should be used. It explains the significance of explicit and descriptive parameters for API readability and user understanding. The paragraph discusses the role of modifiers in API parameters, the importance of state management within components, and theζ¨ε΄ of stateless composables for better reusability and testability. It also touches on event handling through lambda parameters and the avoidance of passing state as direct API parameters.
π Accessibility, Testing, and Documentation
The final paragraph addresses the importance of accessibility and testing in API design. It discusses how to handle semantic properties for screen readers and the use of modifier semantics for accessibility. The paragraph also covers the testability of APIs, suggesting ways to maximize it by avoiding platform-specific resources and using explicit parameters. The importance of documentation and maintaining backward compatibility for long-term API stability is highlighted, concluding with the goal of creating maintainable, scalable components that guide users towards better practices.
Mindmap
Keywords
π‘Jetpack Compose
π‘composable function
π‘API developer
π‘scalability
π‘consistency
π‘FilterChip
π‘layered API
π‘state
π‘accessibility
π‘testability
π‘documentation
Highlights
Simona Milanovic introduces the session on designing scalable Compose APIs.
The fundamental building block in Jetpack Compose is the composable function, emphasizing the role of API developers in UI creation.
Establishing guidelines for high-quality, scalable Compose code is crucial for evolving apps with minimal friction.
Guidelines should be consistent across the Compose ecosystem to enhance intuitiveness and encourage good design practices.
The session covers best practices for idiomatic Compose API development based on experience with Compose libraries.
Guidelines are strict rules for Compose framework contributions and recommended for library development.
For app development, guidelines are considered recommendations that should be tailored to specific app and team needs.
The example of a FilterChip component is used to illustrate the process of creating a new API in Compose.
APIs should be designed to solve a single problem, ensuring clarity and ease of use.
When variations of a component are needed, it may be a signal to create separate components or layered APIs.
Layered higher-level APIs are more constrained and provide specific behavior and defaults with fewer customization options.
Naming conventions in Compose are discussed, emphasizing the use of PascalCase for composable functions returning Unit.
Parameters are essential in defining API purpose and requirements, with explicit and descriptive inputs being preferred.
Modifiers are key in defining composables and should be leveraged in API parameters for customization.
The order of parameters is important for API readability and ease of use, with required parameters listed first.
Default parameter values and nullability are used to signal the absence of value, impacting API usability.
Styling and customization options should have default values provided for independent API use.
Slot parameters or slot content are used to specify open spaces within a component for child content.
State management within components should prefer stateless composables, enhancing reusability and testability.
Accessibility and testing requirements are crucial for a reliable and maintainable API, ensuring long-term stability.
Documentation and backward compatibility are important for external API development to support different user needs.
The session concludes with a well-structured chip API example that embodies the discussed principles and practices.
Transcripts
[MUSIC PLAYING]
SIMONA MILANOVIC: Hi, I'm Simona.
Welcome to this session on designing scalable Compose APIs.
The basic building block in Jetpack Compose
is the composable function.
So when building UI, you write many functions, and functions
that call other functions.
And this makes you an API developer.
Whether you're developing on your own
in bigger teams and apps or for external libraries,
it's important to establish guidelines
for writing high-quality Compose code that is more scalable,
making it easier to evolve with minimum friction,
more consistent across Compose ecosystem,
making it more intuitive and following
known patterns, and that guides others towards better practices
by setting the right expectations
and encouraging good designs.
We will cover best practices and guidelines
for developing idiomatic Compose APIs
based on our own experience of creating the Compose libraries.
This comes in three parts--
how to think about and plan for your components, how to
leverage Kotlin and naming conventions,
and define a solid structure of your API,
and, finally, how to verify and maintain these APIs.
The guidelines covered in this talk
should be considered as strict rules for Compose framework
contributions.
For library development, it is recommended
to follow these guidelines as much as
possible to meet the expectations of your API users.
If you're building an app, consider these guidelines
as recommendations.
But also consider what's best suited for your app and team
requirements.
Let's go through how we would decide
to create a new API using the example of a FilterChip
component.
If you get an idea for a new component,
start by asking if this API is meant to solve a single problem.
We needed a component that would represent
a concise option for the user from a limited selection.
So we came up with a FilterChip component
to solve this problem--
one component, one problem, solved in one place.
This ensures an API is use case-based, making
it easy and clear to use.
If you need the FilterChip to do more,
like representing suggestions or quick actions,
this is a signal that these additional problems
should be solved in a different place, which leads
to the next design question.
We quickly found the need for more variations of FilterChip
that have a similar UI, but also different purposes, representing
generated suggestions, smart, quick actions, and concise user
input.
How do we approach this?
Do we create more customization options on FilterChip
so users can transform it completely?
Or do we create variations of it as completely separate
components?
If your app designs have a consistent requirement
for more opinionated but somewhat similar
variations of a component, it's a good signal
to build new components as layers on top.
Layered, higher-level APIs like these are more constrained
and provide specific behavior and defaults and fewer
customization options.
Layers are usually built on top of extracted lower-level
components, like a generic chip, which
define a common surface and expectations for all layers.
Simpler, lower-level components like this
should be more open for customizations.
This means that when going from lower-level
to higher-level APIs, there is an increase
in opinionated behavior and reduction
of open customizations.
And that's how we opted for new layers of suggestion, assist,
and input chips.
Layering decisions are closely coupled
with design requirements.
So make an informed choice where to draw the line.
While creating a filterable API like FilterChip
might be justified, what about with ChipGroup, a layout that
would rearrange chips in a selected orientation
and apply some extra styling?
Let's break this down.
Arranging chips vertically or horizontally is easy,
and you can already achieve that with existing column and row
composables.
The styling can be handled via applied modifiers
and decorations.
So there are no custom behaviors coming from the ChipGroup API.
We can achieve everything using existing building blocks.
This is a good signal we don't need a new API for this.
A new API should justify its existence.
It takes work to create, maintain, and learn how
to use one.
Choose between creating a new component
or delegate to existing.
Now that the FilterChip has been solidified
as a concept, let's look at how best to structure it.
Naming is a good place to start.
Let's explain the different naming types in Compose.
A composable function returning unit
should be named using pascalcase and a noun,
with or without prefixes.
This is why FilterChip comes with a capital F,
as it omits UI that can be added to the composition and returns
unit by default. So it follows the naming rules for classes.
This helps the element establish persistent identity
across recompositions and reinforces the declarative model
of describing UI with Compose.
To differentiate between the unit returning types,
for composables that return values,
you should use the standard Kotlin conventions
for function naming, starting with lowercase.
Composable functions should either emit content
into the composition or return a value, but not both.
Any composable function that internally uses remember
and returns a mutable object should be prefixed with
remember to clearly signal its nature and potential
of persisting through recompositions.
Components without a prefix can represent
basic components that are ready to use and somewhat decorated.
Consider using a basic prefix for APIs that are
bare bones with no decoration.
This is a signal that can be wrapped
with more decoration, as the API is not
expected to be used as is.
One level or layer above are the opinionated APIs,
such as the FilterChip and InputChip,
signaling that there are more stylistic variations.
Prefer prefixes and names that are mostly
derived from the component's use case or purpose
instead of using generic company name, module, or feature
prefixes.
Composition locals provide global-like values
scoped to a specific subtree of composition.
To signal this, they should hold a descriptive name
and use local as prefix.
Your API is only as functional as the inputs it can access.
Parameters are crucial to defining the API
purpose and requirements.
When thinking about your API structure,
be explicit and descriptive.
Explicit parameters make it easy to present the API,
adjust it, preview, test, and use.
Descriptive inputs ensure your API is readable from the start
and easier for users to understand at a glance.
Avoid implicit obscure inputs as much as possible via composition
locals or similar mechanisms as these
may get hard to track where the customization comes from.
Instead, open the API up and use explicit parameters,
which also make it easy for users
to add more layers of customization.
An essential Compose concept are modifiers
and how they define composables they're paired with.
Components are responsible for their own internal behavior
and appearance, while modifiers are in charge of well modifying
the external ones.
Modifiers should be at the top of your mind
when defining API parameters.
Compose users already have expectations
around where and how to use modifiers.
So make sure you leverage that in your APIs.
This is why every component that emits UI should have a modifier
parameter that is-- of modifier type,
so that any modifier can be passed;
is the first optional parameter, so it can be set without a name
parameter and has consistent positioning in the contract; has
a default no-op value, so no functionality is lost when users
provide their own modifiers appended to the existing chain;
is the only parameter of type modifier,
as one should be enough for a single component.
The need for more could signal you
should rethink other parameters or break your API up,
which is also why this modifier should only
be applied once to the root outermost
layout in the component.
If modifiers describe the composable behavior
and appearance, you might ask, why do we
need explicit parameters?
How do we tell the difference?
Every API has crucial core parts that
represent its behavior and UI.
These should be explicit parameters as an indication
of the API main purpose.
Parameters should also add behavior or decoration that
cannot otherwise be added via modifier.
If, however, there's a noncore modifier-supported
customization, then you can use a modifier.
When defining the order of parameters,
think about the API readability and ease of use.
Its signature is an implicit user instruction for its purpose
and customization options.
Consider listing the API parameters
in the following order--
required parameter first, indicating the main purpose
of your API, set without default values
so they can be used without named parameters.
Then comes the modifier, making the API customizable
and aligning it with Compose expectations,
then the optional parameters, with default
values that can be overridden by the user,
or not, requiring the usage of named parameters.
Additionally, you should group parameters
that are semantically similar, like putting textual inputs
together, click events, styling, et cetera.
A trailing composable lambda named content,
representing the nested slot content of the API
if it requires one--
this is a common concept we'll cover later.
Using default parameter values and nullability
to signal absence of value can be a meaningful API description.
So choose with care.
There's a difference between a default value, an empty value,
and an absent one.
Nullable parameters hint that this API feature is available
but might not be required at all for your use case.
The choice is yours.
But a null value is not the same as an empty one.
Empty defaults signal.
This feature is required and can be used with an empty value,
or it can be overridden with a more concrete one, based
on the use case.
All other default values should be nonnull, meaningful,
and clear to the user.
They need to be publicly available
so the component gives the same consistent result in any place.
A subset of your parameters will most likely
target styling and customization options.
A good practice is to provide default values
for these parameters so the API is independent and can
be used as is.
If the default styling values are short and predictable,
it might be enough to keep them as simple inline constants.
However, some APIs, like the chip,
can have a long list of customizations,
different shapes, colors and borders
between enable, disabled, selected, et cetera.
If you find that your API requires
a lot of default values of similar type,
you could use component defaults to map them externally
in a single place.
Ensure these objects are publicly
available so the component can be used anywhere, like adding
them to your theming.
Styling can be conditionally set based on different states,
like how the chip sets a different background color based
on the selected state.
Handling conditions like this can also
be delegated to the defaults object.
Another Compose concept which you've likely used a lot
are slot parameters, or slot content.
These are composable lambda parameters
that specify an open space inside the parent component
to fill with child ones.
For chips, we want the user to be able to insert a text, image,
icon, or any other content.
For a single slot, position it as the last content parameter
of a composable so it ensures consistency across Compose
and that it could be used as a trailing lambda.
If your API requires multiple slots,
like the chip icon and label, you
can provide multiple parameters of such type.
By using slots, the chip doesn't care
what's being passed as its nested content.
It focuses only on handling its main responsibilities,
like the choice selection, click handling, et cetera,
giving more flexibility to the user.
This also simplifies the main API contract
by taking only the information needed for it
and delegating everything else to the slots.
The chip API now has a relevant name, carefully
defined inputs and styling, and nested content.
But we also need a mechanism to manage
the changes happening inside and outside the component.
This is where state comes in.
We need the chip composable to have
an enabled and disabled state, a property that could change
frequently and impact the UI.
Changes in composables can be handled in two ways--
statefully, with the composable owning and handling
its own state, via APIs like remember or mutableStateOf,
or statelessly, where you rely on the composable being
called and recomposed with different inputs
and rendered in different state.
Wherever possible, prefer the stateless composables that
hold no state of their own but instead accept it as input
and are controlled by the caller.
This makes your API more reusable and testable,
as the caller is dictating the changes,
and your component just follows.
Extracting the state control like this
is called state hoisting, a common Compose practice.
The enabled state is now hoisted and outside of the API control,
but the chip still needs a way of handling clicks and signaling
it to the caller.
Events such as onClick should be accepted as lambda parameters
and passed accordingly.
Extracting events like this ensures
your API is more reusable, testable, and previewable.
But avoid passing state mutable or immutable
as a direct parameter of an API.
Passing state like this means there
could be multiple owners of it, both the caller
and the composable, which complicates control,
creates multiple sources of truth.
Instead, prefer explicit inputs.
Structuring your API is a big part of the design process.
But we still have to ensure it satisfies
other important requirements, like accessibility and testing,
that it has the right documentation, and backward
compatibility.
We want the API to be set up to be solid and reliable long-term.
To make your API supportive of different users
with different needs, ensure it is able to reliably
and logically transform what's shown on the screen
to a more fitting format.
Compose uses semantic properties to pass information
to accessibility services.
Lower-level APIs like clickable modifier,
or composables like image and text,
have a predefined way of handling this.
They either provide good defaults
or explicitly ask for information.
When creating higher-level APIs, you
might need to specify and request
more information to understand how to describe UI to the user.
If your API is required to have an explicit semantic set,
you should request a nonoptional accessibility parameter.
A good example is the image composable
that has a required parameter, contentDescription,
to signal what is necessary.
To apply this to the chip, you could
specify a contentDescription parameter
and apply it via the modifier semantics to the root component.
When designing APIs, it's important to make an informed
decision on what to build into the implementation versus what
to ask from the user.
Keep in mind that users can also provide their own modifier
semantics and try to override the default setup.
Aside from the explicit parameters,
you could also rely on slotted content semantics
as more logical choice.
APIs with slots don't necessarily
need to set the text for accessibility services to read.
Where it is contextually appropriate,
the slotted content semantics, like icons, content description,
or text from a text composable, can be merged
into the parent semantics.
This is called semantics merging for accessibility
and is often used in Compose.
To have the chip merge all of its children semantics into one,
you can set a modifier semantics with mergeDescendants as true.
This will force children descendants to collect and pass
the data to your component to be treated as a single entity.
A rule of thumb here is that simple components typically
require one to three semantics, whereas more complex components
require a richer set of semantics
to function correctly with screen readers.
When developing a new component, you
can compare your API to an existing composable.
Find the one that is most alike, and copy its semantics
implementation to start with.
And then continue fine-tuning it according to your own use case.
When creating a new API, you should
think about how it will be used for building UI
as well as for testing UI.
Checking how testable it is in isolation
is a good indicator of a well-crafted API, which
also makes it more testable as part of a larger
composite component.
Let's look at ways to maximize API testability.
Does the component require platform-specific resources
to work?
For example, does it need a specific activity or context?
If so, this could limit its testability because in tests,
and also in previews, you might not
have access to these resources.
Here we require access to context
to launch an activity as a click event.
A better alternative would be to provide a click
lambda as a parameter and delegate this responsibility
to the caller.
Implicit resources like context or composition locals
can have impact on testability.
So you should always aim to use alternatives
through explicit parameters.
However, if you need composition locals for styling purposes,
make sure to put good defaults in place.
When testing your API, you should
be able to easily verify all possible states.
For example, we should test all chip variations
for selected and enabled states.
Exposing these as explicit state parameters
gives us access to change and verify in tests as we like.
While selected and enabled states are simple to invoke,
there are some more complex system-specific ones,
like the pressed state.
To make testing easier for these states,
you can hoist and use interactionSource.
This lets you emit and verify interactions
at tests, such as the PressInteraction.
Having the modifier parameter allows users to pass
the testTag, which also helps identify the element
in tests when other matchers are not specific enough to find it.
For long-term stability and maintenance of your API,
consider writing appropriate documentation.
And if you're developing for external usage,
ensure backward compatibility is supported.
Documentation should follow [? KTDoc ?] guidelines,
should clearly communicate the APIs capabilities and purpose,
describe its parameters and expectations, as well as
usage examples.
For in-depth backward compatibility guidelines,
refer to the official documentation.
This brings us to one good-looking and well-structured
chip API that is planned around solving a single problem,
has different variations as opinionated components,
chose its name carefully and descriptively,
has the right explicit, mandatory,
and optional parameters, well ordered with the right defaults,
accepts slot as subcontent, is adept at handling state,
supports accessibility and testing requirements,
and is user-friendly with good documentation.
Since in Compose, everyone is an API developer,
we hope this equips you to create
components that are more maintainable, scalable,
consistent across Compose ecosystem,
and that guide users towards better practices.
Thanks so much for watching.
[MUSIC PLAYING]
5.0 / 5 (0 votes)