Jetpack Compose Tips and Conventions for the @Composables to Make Them Better
Learn more about creating more developer friendly composables for everyone
Everyone builds the composables as they find them useful, convenient and readable. You may have noticed that basic Jetpack Compose components have quite similar parameter structures, naming conventions and the behaviour.
It is because there is the guide for it, how to create your composables, so they are easily readable, reasonable and explicit for other programmers in your team or community.
I decided to give you a shorter glimpse and my interpretation, what are the most useful ones with some context/practicality tips, because not all of them are intended to be used for application programming, but rather a library creation.
Simplicity over complexity
First of all, it is always better to have more single-purpose composables than massive behemonths with multiple functionalities. This is not about that you should not merge multiple composables into one screen.
The best way is to create your own modified versions of the buttons, checkboxes, textfields etc. and those components compose together to create specific blocs of the screen.
If you try to create complex composables, which tackle a lot of problems at the same time, you will spend more time fixing issues, which could have been isolated to certain components and easily reachable.
Firstly create low-level blocks, which are reusable across whole project and move on to higher level blocks and screens afterwards. The higher you go, the more limited modifications should be as you would open ways to tarnish the UI’s consistency Example:
// LOW-LEVEL BLOCS
// custom body text
@Composable
fun BodyText(...) {
}
// icon with custom modifier for whole app
@Composable
fun AppIcon(...) {
}
/// HIGHER LEVEL BLOCS
@Composable
fun IconTextButton(@StringRes val title: Int, @DrawableRes val drawableId: Int, modifier: Modifier) =
Surface(
modifier = modifier,
) {
Row() {
AppIcon(
painter = painterResource(id = item.drawableId),
contentDescription = null
)
Spacer()
BodyText(
text = stringResource(id = title),
)
}
}
/// SCREEN LEVEL
@Composable
fun NavigationScreen() {
Column {
IconTextButton(...)
IconTextButton(...)
IconTextButton(...)
IconTextButton(...)
}
}
You will get:
- better readability
- less ambiguity and more confidence
- more flexibility
- easily extensible codebase
Consistency over customisations
This is highly personal, but I prefer more consistency over customisations. Because, if the app has consistent implementation, the maintenance is much simpler, faster and cheaper.
Of course, customers, managers, and UX people will bring to the table more ideas on how to make every single screen better for that special use case.
But I always recommend cracking down on it a tiny bit and getting everything as consistent as possible (here comes the importance of planning and discussing ahead).
Unfortunately, we do not live in perfect world and these things will happen.
Here is what you can do from a code perspective.
Do not attempt to create additional styling objects beyond MaterialTheme.
// DON'T
@Composable
fun Button(style: ButtonStyle, ...) {
...
}
You can create multiple themes, if it is required and scope them for specific screens.
Do create consistent MaterialTheme and create separated Composables to serve the specific purpose like PrimaryButton
, BackButton
, ToggleButton
or TitleText
, SubtitleText
, BodyText
.
You can still wrap these buttons and make additional changes, but it should be more intentional than just out of spite or a special request.
These low level composables will create you after some time small library, which can be housed independently and used at other projects! If it is the same company, both apps will look consistent. I usually have one module or folder, which is dedicated for all such composables.
If you are about to create a modified version, prefer explicit inputs to implicit behaviour of the Composable. The easiest way is to put the default option into the definition of the function.
Avoid using edge case null parameters or inaccessible classes - rather define default behaviour with default Jetpack compose components.
// create your own versions of the text as independent composables
@Composable
fun TitleText(@StringRes id: int, ...) {
Text(
text = stringResource(id = id),
style = MaterialTheme.typography.titleLarge
)
}
@Composable
fun BodyText(@StringRes id: int, ...) {
Text(
text = stringResource(id = id),
style = MaterialTheme.typography.bodyMedium
)
}
// in need of modification, modify already existing one
// without adding more styles - add more explicit inputs with defaults
@Composable
fun OtherTitleText(@StringRes id: int, textAlign = TextAlign.Center, ... ) {
TitleText(id = id, textAlign = textAlign)
}
Input parameters
Modifier
Every composable should contain only one Modifier
as an optional parameter in input, which modifies the surrounding visuals of your @Composable
.
Donts and Dos:
- do put the modifier behind the required parameters
- don’t pass other styles - do define material design or specific composable
- don’t put multiple modifiers as input - do separate it into another composable
- don’t modify the inner parts of the composable - do modify the surroundings of the composable with the modifier
Order
- Required parameters
- modifier (only one modifier)
- optional parameter
- composable trailing
It feels much more intuitive as most of the default composables have the same structure and it should become your habit to write such composables.
Here is an example:
@Composable
fun AppButton(
// required might not be even needed,
// because modifier has most of the required parameters
// such as clickable {}
// modifier
modifier: Modifier = Modifier,
// optional
enabled: Boolean = true,
// composable trailing
text: @Composable () -> Unit,
) {}
// usage
AppButton(
modifier = Modifier.padding(bottom = 30.dp).clickable {},
) {
Text(...)
}
State
Do not post channels or mutable states into the composables. The state should be handled within the ViewModel
. UI should not be aware of any logic handling, only reacting to the current state and passing UI events between the ViewModel and the actual user.
The Composable input should only contain an immutable state, which the ViewModel can rebuild.
There are 2 ways of passing values, which I prefer.
- The first one is passing pure kotlin data class as a result of the composable.
- Second one is the passing function intended for reading the value - this delays the reading of the value to the points, which are needed.
data class SomeData(val text: String)
// pass the data directly to the composable
@Composable
fun TitleText(text: SomeData) {
Text(text, ...)
}
// delay getter by passing function
@Composable
fun MainScreen(text: () -> String){
}
// invocation with function
MainScreen({ someData.text })
Slot inputs
Slot input is another set of composables, which are used in the hierarchy of the composable. For example, column or row use your composables as slot input.
Creating simple composables as it was described on the top is one way of dealing with a lot of variants of the text, button, switch, etc.
But me and others are always tempted to write something like this:
@Composable
fun TextButton(
text: String,
){
...
}
If you go with this solution, this is not a sign you are a bad programmer, but there is another way how to make this composable a bit more flexible.
You can pass in Text
composable directly and take advantage of pattern for simple composables and start to compose them together.
Here is example:
@Composable
fun BoldBodyText(
text: String
) {
...
}
@Composable
fun PrimaryButton(
content: @Composable () -> Unit
) {
...
content()
...
}
// usage / composable which could be extracted as individual component
PrimaryButton {
BoldBodyText("My text")
}
Semantics
Semantics are an inseparable part of the Jetpack Compose as they are used for testing and for accessibility purposes for people with special needs.
If you create higher-level composables, you can benefit from Modifier.semantics(mergeDescendants = true)
, where all the semantics are merged as one node and can be perceived as one item from a testing and accessibility perspective. clickable
and toggleable
are doing it by default.
To learn more about testing with semantics, you can read my article about end-to-end testing. If you want to know more about accessibility, you can read more about in article how to remove testTags and replace them with accessibility semantics.
Conclusion
All of these tips and conventions act as a guide on how to make your codebase better, but they are only silver bullet for some of the issues.
You will find situations, where it is just better to break the convention or amend it in a way, that is beneficial for your project and colleagues. There may be a better way for your purpose.
But, if you build a general-purpose library for multiple projects or open-source solutions, then it is essential to keep them intact.
Thanks for reading and do not forget to follow for more!