Blog Infos
Author
Published
Topics
, , ,
Published

In any kind of development, it often happens that designers come up with their crazy ideas and ask to add yet another variation of a component that you have just finished writing. And it’s not certain that this new component won’t break all the previous ones. Today, we will talk about the principles of creating flexible components using Compose and will try to do it beautifully, thinking ahead.

Input data

Let’s start from the very beginning. Imagine that your project has three different components, but they all represent one entity — a certain type of contact person. In our example, we will have three variations:

photo, first name, last name

photo, first name, last name, data update time, actions

photo, first name, last name, data update time, position, manager’s photo

ContactFullNameView

First, we need to understand the structure of the given layout.

Row {
  Image
  Text
}

Since this is the simplest variant and there is nothing special about it, just a Row with components inside.

After polishing the code, it turned out something like this:

@Composable
fun ContactFullNameV1View(
imageUrl: String,
fullName: String,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.sizeIn(
minHeight = 56.dp,
)
.padding(
horizontal = 16.dp,
vertical = 8.dp,
),
horizontalArrangement = Arrangement.spacedBy(
space = 16.dp,
),
) {
Image(
painter = rememberAsyncImagePainter(model = imageUrl),
contentDescription = "contact image",
modifier = Modifier
.requiredSize(40.dp),
)
Text(
text = fullName,
modifier = Modifier
.align(Alignment.CenterVertically),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyMedium,
)
}
}

Let’s try to cover this with @Preview annotations, so we can see and compare the results.

@Preview
@Composable
private fun ContactFullNameV1ViewPreview() = ReusableComponentsTheme {
    ContactFullNameV1View(
        imageUrl = "https://images.stockcake.com/public/1/b/e/1be26278-b679-47a6-b17a-f3a66bc3db92_large/elegant-senior-portrait-stockcake.jpg",
        fullName = "Eleanor Pena",
        modifier = Modifier
            .fillMaxWidth()
            .background(MaterialTheme.colorScheme.surface),
    )
}

But after the first preview, a problem arises — how to view the result without launching the preview on a phone or emulator? Moreover, keeping links in the code doesn’t look very good, even for @Preview. Here, we need to change our approach to creating @Composable functions.

Overall, if we are trying to create some flexible component, we should somewhat abstract away from the model we are working with.

@Composable
fun ContactFullNameV2View(
    imagePainter: Painter,
    fullName: String,
    modifier: Modifier = Modifier,
)

By changing the reference to the Painter class type, we can solve three tasks simultaneously — viewing the result in @Preview, viewing the image on the device via a specific link (if desired), and having complete control over loading the image for actual @Composable.

@Preview
@Composable
fun ContactFullNameV2ViewPreview() = ReusableComponentsTheme {
    ContactFullNameV2View(
        imagePainter = when {
            LocalInspectionMode.current -> painterResource(id = R.drawable.eleanor_pena)
            else -> rememberAsyncImagePainter(model = "https://images.stockcake.com/public/1/b/e/1be26278-b679-47a6-b17a-f3a66bc3db92_large/elegant-senior-portrait-stockcake.jpg")
        },
        fullName = "Eleanor Pena",
        modifier = Modifier
            .fillMaxWidth()
            .background(MaterialTheme.colorScheme.surface),
    )
}

ContactActionsView

Let’s once again define the rough structure of our new component.

Row {
  Image
  Column {
    Text
    Text
  }
  IconButton
  IconButton
  IconButton
}

After comparing with the design, it will change slightly, but the essence remains the same — compared to the previous component, we are adding a new field and introducing action buttons. Using our existing knowledge, we accurately describe this function. Additional changes include margins and the minimum size of the container itself.

@Composable
fun ContactActionsV1View(
    imagePainter: Painter,
    fullName: String,
    modifiedTime: String,
    modifier: Modifier = Modifier,
    onChatClicked: () -> Unit = {},
    onMainClicked: () -> Unit = {},
    onCallClicked: () -> Unit = {},
)
Looks like it makes sense, right?
ContactDetailsView

We just added these buttons, and now they need to be removed… Designers! Let’s rethink the structure of our component again.

Row {
  Image
  Column {
    Text
    Text
    Row {
      Image
      Text
    }
  }
}

Here again, nothing special, a fairly simple layout where text and a new photo are added to the initial version. The differences lie in the margins, the minimum size of the container itself, and the alignment of the contact’s photo.

@Composable
fun ContactDetailsV1View(
    imagePainter: Painter,
    managerPainter: Painter,
    title: String,
    fullName: String,
    modifiedTime: String,
    modifier: Modifier = Modifier,
)

Job Offers

Job Offers


    Senior Android Engineer

    Carly Solutions GmbH
    Munich
    • Full Time
    apply now

    Senior Android Developer

    SumUp
    Berlin
    • Full Time
    apply now

OUR VIDEO RECOMMENDATION

Jobs

Creating a Frankenstein’s monster

The first idea that might come to mind is to gather all the parameters that exist in our components and create one very large and all-powerful @Composable that can do and know everything. However, during the assembly of this mishmash, we may notice various when conditions appearing in the code, which in large quantities can indicate that something is wrong. A drawback here is the access to many parameters that may not be logically related to our component in a specific configuration, as well as the instability of our component. If we receive another new requirement, the current flexibility may not be sufficient to add the desired functionality.

@Composable
fun ContactViewV1(
    imagePainter: Painter,
    managerPainter: Painter?,
    title: String?,
    fullName: String,
    modifiedTime: String?,
    modifier: Modifier = Modifier,
    onChatClicked: (() -> Unit)? = null,
    onMainClicked: (() -> Unit)? = null,
    onCallClicked: (() -> Unit)? = null,
) {
    Row(
        modifier = modifier
            .sizeIn(
                minHeight = when {
                    title.isNullOrBlank() && modifiedTime.isNullOrBlank() -> 56.dp
                    !title.isNullOrBlank() -> 72.dp
                    else -> 88.dp
                },
            )
            .padding(
                horizontal = 16.dp,
                vertical = when {
                    title.isNullOrBlank() && modifiedTime.isNullOrBlank() -> 8.dp
                    else -> 16.dp
                },
            ),
        horizontalArrangement = Arrangement.spacedBy(
            space = 16.dp,
        ),
    ) {

    ... other stuff ...
}
Theory

When building a new component aimed at high customization, it’s crucial to plan it meticulously to minimize pain when adding something new or creating a completely unique appearance.

The first rule of component construction is using Modifier as the first optional parameter of your @Composable. This guideline is critical as it allows us to control our component externally. If you find the need to add a second Modifier, it’s worth reviewing your function’s structure because guidelines suggest each function should contain only one Modifier, indicating a potential function design issue. Therefore, this Modifier should only be applied to the root @Composable.

Components can have various mechanisms for presenting their UI and functionality. Required parameters define the core essence of our function and should not have default values. Optional parameters with pre-defined values act as optional modifications that may or may not be used in our component.

It’s also important to consider how optional values are declared. Nullable means the functionality can be used or omitted entirely. An empty implementation means the functionality is mandatory but can use an empty value or custom logic. Default values should be non-nullable and clearly understood.

When working with component styles, some parameters can be left within the function body if they are short and straightforward, though for more complex components, grouping is advisable.

If your component consists of multiple logical blocks, these blocks are named as slots. This approach provides flexibility, allowing you to use standard implementation or customize it extensively for our needs.

Overall, we’ve covered the essentials. We’ll address everything else as we go along.

Creating beauty

First of all, we logically divide our components into slots. Here it’s quite straightforward: we have the logo slot, the information slot, and the actions slot. We will base our structure on this.

We always have the full name and the contact’s photo, so these data can be used as mandatory parameters for our @Composable function. For the photo, we will pass a Painter so that we can later control the photo loading from outside and preview the result in @Preview. Let’s also roughly sketch the structure of the component and make all the slots empty.

@Composable
private fun ContactView(
    leadingPainter: Painter,
    title: String,
    modifier: Modifier = Modifier,
    leadingView: @Composable RowScope.() -> Unit = {},
    content: @Composable ColumnScope.() -> Unit = {},
    trailingView: (@Composable RowScope.() -> Unit)? = null,
) {
    Row(
        modifier = modifier
            .sizeIn(
                minHeight = Dp.Unspecified,
            )
            .padding(
                horizontal = 16.dp,
                vertical = Dp.Unspecified,
            ),
        horizontalArrangement = Arrangement.spacedBy(
            space = 16.dp,
        ),
    ) {
        leadingView.invoke(this)

        Column(
            modifier = Modifier
                .align(Alignment.CenterVertically),
        ) {
            content.invoke(this)
        }

        trailingView?.invoke(this)
    }
}

We’re looking at the first slot. Here, the photo is always present; only its alignment changes, so we’ll leave these parameters for our function, as everything else remains constant.

@Composable
fun RowScope.ContactLeadingView(
    imagePainter: Painter,
    headerAlignment: Alignment.Vertical,
) {
    Image(
        painter = imagePainter,
        contentDescription = "contact image",
        modifier = Modifier
            .requiredSize(40.dp)
            .clip(CircleShape)
            .align(
                alignment = headerAlignment,
            ),
    )
}

For the second slot, we have one mandatory parameter, which we’ll call title, to ensure future compatibility. This way, if we pass more than just the first and last name there, the structure remains unchanged.

@Composable
fun ColumnScope.ContactContentView(
    title: String,
    header: String? = null,
    subtitle: String? = null,
    managerPainter: Painter? = null,
) {
    if (!header.isNullOrBlank()) { ... }

    Text(
        text = title,
        color = MaterialTheme.colorScheme.onSurface,
        style = MaterialTheme.typography.bodyMedium,
    )

    Row(
        modifier = Modifier,
        horizontalArrangement = Arrangement.spacedBy(
            space = 8.dp,
        ),
    ) {
        if (managerPainter != null) { ... }
        if (!subtitle.isNullOrBlank()) { ... }
    }
}

The third slot will contain actions with three different callbacks. They are marked as nullable here to have the ability to control their presence without adding new variables.

@Composable
fun RowScope.ContactActionsView(
    onChatClicked: (() -> Unit)? = null,
    onMainClicked: (() -> Unit)? = null,
    onCallClicked: (() -> Unit)? = null,
) {
    Row(
        modifier = Modifier
            .align(Alignment.CenterVertically),
    ) { ... }
}

After assembling all the slots and declaring them as the foundation for our function, the resulting structure should look like this:

@Composable
private fun ContactView(
    leadingPainter: Painter,
    title: String,
    modifier: Modifier = Modifier,
    config: ContactViewConfig = ContactViewDefaults.config(),
    leadingView: @Composable RowScope.() -> Unit = {
        ContactLeadingView(
            imagePainter = leadingPainter,
            headerAlignment = config.headerAlignment,
        )
    },
    content: @Composable ColumnScope.() -> Unit = {
        ContactContentView(
            title = title,
        )
    },
    trailingView: (@Composable RowScope.() -> Unit)? = null,
)

In this structure, the new addition is ContactViewConfig, which controls various settings of our component.

Ideally, these settings should be grouped together, so do not organize them as I did.

// do not do so
@Immutable
class ContactViewConfig internal constructor(
    internal val minHeight: Dp,
    internal val verticalPadding: Dp,
    internal val headerAlignment: Alignment.Vertical,
)

object ContactViewDefaults {

    @Composable
    fun config(
        minHeight: Dp = 56.dp,
        verticalPadding: Dp = 8.dp,
        headerAlignment: Alignment.Vertical = Alignment.CenterVertically,
    ): ContactViewConfig = ContactViewConfig(
        minHeight = minHeight,
        verticalPadding = verticalPadding,
        headerAlignment = headerAlignment,
    )
}

Alright, now we have a component that is customizable, has a working @Preview, and is ready for extension.

Next, we can build additional components on top of the base function to extend its functionality. As anoption you can use domain classes instead of simple types.

@Composable
fun FullNameContactView(
    imagePainter: Painter,
    fullName: String,
    modifier: Modifier = Modifier,
) {
    ContactView(
        leadingPainter = imagePainter,
        title = fullName,
        modifier = modifier,
    )
}
@Composable
fun ActionsContactView(
    imagePainter: Painter,
    fullName: String,
    modifiedTime: String,
    modifier: Modifier = Modifier,
    onChatClicked: (() -> Unit)? = null,
    onMainClicked: (() -> Unit)? = null,
    onCallClicked: (() -> Unit)? = null,
) {
    ContactView(
        leadingPainter = imagePainter,
        title = fullName,
        modifier = modifier,
        config = ContactViewDefaults.config(
            minHeight = 72.dp,
            verticalPadding = 16.dp,
        ),
        content = {
            ContactContentView(
                title = fullName,
                subtitle = modifiedTime,
            )
        },
        trailingView = {
            ContactActionsView(
                onChatClicked = onChatClicked,
                onMainClicked = onMainClicked,
                onCallClicked = onCallClicked,
            )
        },
    )
}
@Composable
fun DetailsContactView(
    imagePainter: Painter,
    managerPainter: Painter,
    title: String,
    fullName: String,
    modifiedTime: String,
    modifier: Modifier = Modifier,
) {
    ContactView(
        leadingPainter = imagePainter,
        title = fullName,
        modifier = modifier,
        config = ContactViewDefaults.config(
            minHeight = 88.dp,
            verticalPadding = 16.dp,
            headerAlignment = Alignment.Top,
        ),
        content = {
            ContactContentView(
                title = fullName,
                header = title,
                subtitle = modifiedTime,
                managerPainter = managerPainter,
            )
        },
    )
}
Some conclusions

Looking at @Preview, you won’t see any difference between components before and after. In terms of code, there will also be almost no difference — a highly flexible component with additional functions and configs will occupy just as many lines of code as three different independent functions. I hope this article helped you better understand how to write flexible components. For further development, I also recommend looking at this video and the code if necessary.

This article is previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu