[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]