Stacked Cards Layout With Compose - And Cats

Eevis - Jul 11 - - Dev Community

I was writing a completely different blog post about playing around with Glance and app widgets, and I needed an example app. None of the existing ones served me, so I needed to build a new one. And I completely overdid it—I would have needed just something really simple, but I ended up creating a more polished app with some new concepts.

The app I built has cats — a lot of cats— and you can get even more. It has cat pictures as cards. I wanted to stack the cards in a pile just because I thought I could probably do it — and I could! So this blog post is about building that stacked card layout — and a bit about cats.

If you ever need cats, there's this awesome API: Cats as a service. I think it's one of the most important ones out there. Just saying.

Yes, I might end up being the cat lady in the future.

But okay, back to the coding. In the next sections, I'll first explain the stacked cards layout in more detail, then discuss custom layouts in Compose, and finally talk through the code for my version of the stacked cards layout.

Stacked Cards with Cats

The app's idea is to fetch a cat picture, and then get more cat pictures once you've seen one. Nothing complex, just cats. The app fetches a random picture's JSON data from Cataas-API and stores the picture's ID in a data store. Why a data store instead of, for example, Room? Well, this is a simple example app, never intended for production use, and it needs really simple data—a set of strings.

Further, it renders pictures with Coil's SubcomposeAsyncImage based on the URL, so it doesn't load the images to the device. Again, it's not a production app, and there is no need for offline support or similar things. For a more production-grade app, a lot of things should be improved. I've tried to make the code (a link at the end of the post!) as straightforward as possible, so I've cut some corners.

So, if you're someone recruiting Android devs, please don't look at this code as my masterpiece and the proof of all I can do. Trust me, I write more robust code in production. I promise!

The app UI shows the latest picture at the top of the screen and then the others stacked on the bottom of the screen. Users can remove the topmost picture from the stack by clicking the X-button that each card has in the top-right corner.

Now that I've been just hinting about cat pictures and the stacked card layout, let's finally see some pictures. Or a picture, to be more precise. Here's what the stacked cards look like when there are a lot of cards in the pile:

Pile of cards stacked on each other. The topmost card has a close button with an X-icon on the top-left corner and a picture of a cat lying on the side and watching a bit under the camera. The cat has grey and white long fur.

How did I accomplish this layout? I used the custom Layouts Compose provides. Let's talk about that a bit next.

Custom Layouts

Compose has some built-in layouts, such as Columns and Rows, but often custom layouts are needed. You can read more about custom layouts in Compose from the Android documentation: Custom Layouts.

There are two ways of creating custom layouts with composable components: with a layout- modifier or with a Layout composable. The layout- modifier modifies only the composable it's called on, so it's not an option in our case.

As this layout is about more than one component, we want to build the layout with a Layout composable. A Layout composable allows us to measure and lay out multiple composables. The process for creating the layout consists of three phases (quote from the Custom Layouts documentation):

Each node must:

  1. Measure any children
  2. Decide its own size
  3. Place its children

We'll get to a concrete example of all these in a bit, but this is the process for most custom layouts out there.

Show Me the Code

Okay, so to create the stacked cards layout, we'll need a custom component that takes modifier and content as parameters. Of course, this composable could take in other things as well, but for this example, just those two are needed.

// CardStack.kt

@Composable
fun CardStack(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit,
) {
    ...
}
Enter fullscreen mode Exit fullscreen mode

Then, inside the CardStack-component, we'll define a Layout, which takes in the same content and modifier-parameters. The third parameter is a MeasurePolicy lambda, responsible for all the action and layout creation. The lambda has two params: measurables, which is a list of Measurables, which all correspond to a layout child element. The other parameter is constraints, the constraints for the layout. The following code shows all this more concretely:

// CardStack.kt

Layout(
    content,
    modifier,
) { measurables, constraints ->
  ...
}
Enter fullscreen mode Exit fullscreen mode

Inside the Layout-composable, we'll need to do the three steps mentioned in the previous section. We first measure the children, then decide the size, and then place the children.

The first step is to measure the children. For that, we'll use the measurables-parameter, map through it, and for each Measurable item, call the measure-function. We then store all of these into a variable called placeable:

// CardStack.kt

val placeables =
    measurables.map { measurable ->
        measurable.measure(constraints)
    }
Enter fullscreen mode Exit fullscreen mode

The next step is to decide the size. As the cards are stacked on top of each other, the screen estate we need for the cards is pretty much the size of one card with some extra padding. Of course, we don't want the cards to be exactly on top of each other to have the effect of piled cards, so the amount of extra padding needs to depend on how many cards there are.

For the height of the layout, we want to check the height of one card, so we take the first card on the measured children variable (placeables) and its height. In this example, the size of the cards is always the same, so setting the height is straightforward. For cards with differing sizes, some more calculations for the height would be needed.

Then, we add some extra padding (in this case, 10 pixels) and multiply it by the children's size. As the layout can take zero or more children, we want to account for the case when there are no children - so if the placeables list is empty, we set the height to 0.

For the layout's width, we use the width of the first child if there are children and 0 if there are none. Then, we define layout with these height and width. This all looks the following in code:

// CardStack.kt

val height = if (placeables.isNotEmpty())
        placeables.first().height +
            (CardStack.EXTRA_PADDING * placeables.size)
    else 0

val width = if (placeables.isNotEmpty())
        placeables.first().width
    else 0

layout(width = width, height = height) {
    ...
}
Enter fullscreen mode Exit fullscreen mode

The final step is to place the children. That happens inside the layout we defined. We want to map through the children (placeables) and then call the place-method. It takes in x and y coordinates, and we want to use those coordinates for a bit of misalignment to create a more realistic look.

So, for the x-value, we either place it at coordinate 0 in the parent's coordinate system or put it to x-position of 5 (defined outside the code snippet). The decision depends on if the value is odd or even - if it's even, then we use 0. Otherwise, we use 5. The isEven-function is an extension function I've defined to reduce repetition for checking if an integer is even.

For the y-position, we want to multiply the y-position (5, defined outside the code snippet, see the full code below) with the index of the current element to create the stacked effect showing cards underneath the topmost card. This all translates to code the following way:

// CardStack.kt

layout(width = width, height = height) {
    placeables.mapIndexed { index, placeable ->
        placeable.place(
            x = if (index.isEven())
                0
            else
                CardStack.X_POSITION,
            y = CardStack.Y_POSITION * index,
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

The final code for the custom layout looks like this:

// CardStack.kt

@Composable
fun CardStack(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit,
) {
    Layout(
        content,
        modifier,
    ) { measurables, constraints ->

        val placeables =
            measurables.map { measurable ->
                measurable.measure(constraints)
            }

        val height = if (placeables.isNotEmpty())
                placeables.first().height +
                    (CardStack.EXTRA_PADDING * placeables.size)
            else 0

        val width = if (placeables.isNotEmpty())
                placeables.first().width
            else 0

        layout(width = width, height = height) {
            placeables.mapIndexed { index, placeable ->
                placeable.place(
                    x = if (index.isEven())
                        0
                    else
                        CardStack.X_POSITION,
                    y = CardStack.Y_POSITION * index,
                )
            }
        }
    }
}

object CardStack {
    const val EXTRA_PADDING = 10
    const val Y_POSITION = 5
    const val X_POSITION = 5
}
Enter fullscreen mode Exit fullscreen mode

But we still need to do one thing to accomplish the UI we saw. Right now, the stacked layout looks like this:

Cards are stacked in a way that they align horizontally almost perfectly. The topmost card has a picture of a cat sleeping in a basket, with the front paws cutely on the edge of the basket.

The cards are laid out on top of each other, aligning pretty well. However, we want a bit of randomness and rotation, like real, physical cards when they're piled. We'll add this effect to the cards themselves with a modifier.

In MainScreen, we map through cat picture ids and then show the CatCard component with the id. This is where we add the rotate modifier with a random amount of degrees for rotation.

Inside the mapping, we define the degrees- variable, remember it between recompositions, and then pass that value to the rotate-modifier. For the value of degrees, we want to have a number within a range from -2 to 2. As Random.nextFloat() doesn't allow us define the range, we'll use Random.nextInt() and then convert the value to a float.

You might ask now why we need to remember the variable. Otherwise, this random number would be regenerated on every recomposition (when an item is either added or removed from the list), causing the cards to change their positions on every recomposition.

// MainScreen.kt

CardStack {
    catIds.value.mapIndexed { index, id ->
        val degrees = remember {
            Random.nextInt(-2, 2).toFloat()
        }

        AnimatedCatCard(
            modifier = Modifier.rotate(degrees),
            ...
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

After these changes, we have a stacked card layout, shown in the Stacked Cards with Cats section.

Wrapping Up

In this blog post, we've looked into custom layouts with Compose by building a stacked cards layout for cat photos. We accomplished this with the Layout-composable, and some calculations. To finalize the layout, we added a bit of random rotation with a rotate-modifier.

The full code for this app can be found in the Cats-repository.

Have you built custom layouts? Anything fun to share? Or any learnings?

Links in the Blog Post

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .