Overlapping profile pictures with Jetpack Compose Modifier

Baptiste Carlier
5 min readMar 15, 2023

Because a picture paints a thousand words, I guess with this one you will understand what I wanted to achieve when I started to work on a profile picture row :

I would like to display profile pictures in a way that they are overlapping above the previous. It’s not a recent design trend, but it’s still elegant for 2023.

Main attributs:
- in light blue, the picture size with margin
- in pink, the negative spacing between pictures
- in green: margin of a picture

Obviously it could be great to make the the result customizable with those attributs.

Negative spacing

Before beginning this small POC in Jetpack Compose, I knew that Row and Column composable functions allow negative arrangement.

Let’s use it and start a ProfilePicture composable at the same time :

val size = 48.dp
val margin = 6.dp
val spacing = 12.dp

// Easy solution
Row(horizontalArrangement = Arrangement.spacedBy(-spacing.dp)) {

list.forEachIndexed { index, item ->
val enable = index != (list.size - 1)
ProfilePicture(
url = item.url,
imageSize = size,
margin = margin.dp,
negativeSpacing = spacing.dp,
cropped = enable
)
}
}

Negative spacing: ok ☑

The profile pictures are overlapped and the last one is over the others. Great.

But so far you may not have learn anythings, and there’s no blank between items.

Let’s try to add…

…Picture margin

👎 Using a border

My first attempt to add margin was to encapsulate every pictures inside a Box with a Modifier.border(…).

@Composable
fun WithBorder() {
// Hard coded stuff
val size = 48.dp
val strokeSize = 4
val containerColor = MaterialTheme.colorScheme.tertiary
val avatarBackgroundColor = MaterialTheme.colorScheme.secondaryContainer
Box(
modifier = Modifier
.border(
border = BorderStroke(strokeSize.dp, containerColor),
shape = CircleShape
)
) {
// This box represent the profile picture
Box(
modifier = Modifier
.clip(CircleShape)
.padding(strokeSize.dp)
.background(
color = avatarBackgroundColor,
shape = CircleShape
)
.size(size)
) {}
}
}

@Preview
@Composable
fun WithBorderPreview() {
MaterialTheme {
Row(
horizontalArrangement = Arrangement.spacedBy(-16.dp),
) {
(0..5).forEach {
WithBorder()
}
}
}
}

This solution is not cool for two reasons:

1 ) The border adds margin between items. It’s not a big issue because I can recalculate the result with the negative spacing.

But it also add margin to the Row itself. 😏
I’ve tried to move the offset and update the bounds of the Row but it hasn’t been successful.

2 ) The border is plain, not exclude. It means that the border color is displayed every time.

In case the background behind the Row isn’t known, or isn’t solid but gradient, this can be annoying.

I choose to skip this technical solution.

👌 Drawing into the DrawScope

The second option is to delete a negative part of every picture, except the last one.

With the three parameters (size, margin, spacing), I can place the perfectly sized circle to exclude.

For this purpose, I can use drawWithCache Modifier and onDrawWithContent method. They offer tools to display content over or under the modified composable.

This way, I can exclude the difference between the current profile picture and a second one where :

center x = size.width * 1.5 — negativeSpacingPx
center y = size.height / 2
radius = size.width / 2 + marginPx

The result is the combination of graphicalLayer Modifier and drawWithCache function like :

.graphicsLayer {
// Ensure BlendMode.Clear strategy works
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithCache {
val path = Path().apply {
addOval(
Rect(
topLeft = Offset.Zero,
bottomRight = Offset(size.width, size.height),
),
)
}
onDrawWithContent {
clipPath(path) {
// this draws the actual image
// if you don't call drawContent, it won't render anything
this@onDrawWithContent.drawContent()
}
val marginRadius = size.width / 2f + marginPx
val offset = size.width * 1.5f - negativeSpacingPx
drawCircle(
color = Color.Black,
radius = marginRadius,
center = Offset(x = offset, y = (size.height / 2f)),
blendMode = BlendMode.Clear,
)
}
}

I clip a path of the size of the content to render the circle that excludes the rounded portion.

Thanks the official documentation for the tips.

Do it with a Modifier

Finally, I can extract this into a Modifier extension in order to make it reusable. As result, I got 3 main elements.

The Row itself with few parameters

  • The (negative) spacing between pictures
  • The margin, which represent the border between two profile pictures
  • The image size

The ProfilePicture composable

It will use the three parameters + the url + a boolean in order apply to circle exclusion. As mentioned before, the last item will not be cropped.

I’m working with Coil to display image from URL.

Why do I chain Modifiers ? To make it more readable.
And because, once again, of the last item will only use the initialModifier.

The applyIf Modifier

Useful function to apply a Modifier in certain conditions. 🙂

The circleMask Modifier

Basically, you’ll find back elements see few lines ago.

Note that the size used inside the onDrawingWithContent is related to the current drawing environment. See drawWithCacheand CacheDrawScope documentation for more information.

Please enjoy everything written right here ↓

// Usage :
val spacing = 4.dp
val margin = 2.dp
val imageSize = 32.dp

Row(horizontalArrangement = Arrangement.spacedBy(-spacing.dp)) {
list.forEachIndexed { index, item ->
val enable = index != (list.size - 1)
val url = item.thumbUrl
ProfilePicture(
url = url,
imageSize = imageSize,
margin = margin,
negativeSpacing = spacing,
cropped = enable,
)
}
}
@Composable
fun ProfilePicture(
url: String,
imageSize: Dp,
margin: Dp = 0.dp,
negativeSpacing: Dp = 0.dp,
cropped: Boolean = false,
) {
val initialModifier = Modifier
.clip(CircleShape)
.size(imageSize)
.aspectRatio(1f)

val marginPx = margin.dpToPx()
val negativeSpacingPx = negativeSpacing.dpToPx()

AsyncImage(
modifier = initialModifier.applyIf(cropped) {
circleMask(marginPx, negativeSpacingPx)
},
model = ImageRequest.Builder(LocalContext.current)
.data(url)
.crossfade(true)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
)
}
fun Modifier.applyIf(
condition: Boolean,
modifierFunction: Modifier.() -> Modifier,
) = this.run {
if (condition) {
this.modifierFunction()
} else {
this
}
}
fun Modifier.circleMask(
marginPx: Float,
negativeSpacingPx: Float,
) = then(
Modifier
.graphicsLayer {
// Ensure BlendMode.Clear strategy works
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithCache {
val path = Path().apply {
addOval(
Rect(
topLeft = Offset.Zero,
bottomRight = Offset(size.width, size.height),
),
)
}
onDrawWithContent {
clipPath(path) {
// this draws the actual image
// if you don't call drawContent, it won't render anything
this@onDrawWithContent.drawContent()
}
val marginRadius = size.width / 2f + marginPx
val offset = size.width * 1.5f - negativeSpacingPx
drawCircle(
color = Color.Black,
radius = marginRadius,
center = Offset(x = offset, y = (size.height / 2f)),
blendMode = BlendMode.Clear,
)
}
},
)

Thanks for reading

You can contribute in the comment section.
I may update this story later if we improve it together. 😉

--

--