Tomáš Repčík - 25. 2. 2024

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:

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:

Order

  1. Required parameters
  2. modifier (only one modifier)
  3. optional parameter
  4. 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.

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!

Subscribe for more
LinkedIn GitHub Medium Threads X Bluesky