Overlapping profile pictures with Jetpack Compose Modifier
--
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 drawWithCache
and 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. 😉
- Twitter : @bapness
- YouTube : @baptistemobiledev