Exploring some usecases of BoxWithConstraints.
With the ubiquitous nature of Jetpack Compose, which enables us to create UIs on mobiles, tablets, wearables, desktops and web, it stands to reason that apps should be built with responsive layouts that look good on any screen size and orientation.
But how can we create entire screens that naturally adapt to the amount of space available? How can create building blocks that together create screens, each of which adapt to the amount of space available?
Enter BoxWithConstraints. The docs describe it as:
A composable that defines its own content according to the available space, based on the incoming constraints or the current LayoutDirection.
When I first stumbled upon it, the way I understood, that it could be used to build dynamic and responsive layouts that react to the available space. It’s quite practical for creating composables that could be used in different orientations and configurations. Let’s checkout some of it’s many usecases.
Listing items in a Card with a “+{X}” badge
Here “+3” is calculated based on the number of images available that were not shown.
As the name suggests, BoxWithConstraints is essentially a Box so the children will be layed out on top of each other. In this example, we have a Card child which has Column as a child of it’s own to create this layout. We will focus on the Row at the bottom of the card so let’s break down how BoxWithConstraints helps in showing thumbnails based on the space available and how the value “+3” is calculated.
@Composable | |
private fun Thumbnails( | |
thumbnails: List<String>, | |
modifier: Modifier = Modifier, | |
) { | |
BoxWithConstraints(modifier) { | |
val boxWithConstraintsScope = this | |
val padding = Theme.dimens.grid_2 | |
val thumbnailSize = Theme.dimens.grid_6 | |
val numberOfThumbnailsToShow = max( | |
0, | |
boxWithConstraintsScope.maxWidth.div(padding + thumbnailSize).toInt().minus(1) | |
) | |
Row( | |
modifier = Modifier.fillMaxWidth(), | |
horizontalArrangement = Arrangement.spacedBy(padding), | |
verticalAlignment = Alignment.CenterVertically, | |
) { | |
thumbnails | |
.take(numberOfThumbnailsToShow) | |
.forEach { | |
Image( | |
painter = rememberImagePainter(data = it), | |
contentDescription = null, | |
modifier = Modifier | |
.width(thumbnailSize) | |
.aspectRatio(1f), | |
) | |
} | |
val remaining = thumbnails.size - numberOfThumbnailsToShow | |
if (tagNumber > 0) { | |
Badge(badge = Badge.Info("+$remaining")) | |
} | |
} | |
} | |
} |
What’s really happening here? 🤓
- The content of BoxWithConstraints is on scope BoxWithConstraintsScope. Using that we have access to the maxWidthavailable to the layout.
- On lines 11 to 14, numberOfThumbnailsToShow is calculated based on maxWidth and size of each thumbnail. Notice that we subtract 1 as an easy way to have space for the Badge to show +{remaining}.
- On lines 21 to 31, we iterate through numberOfThumbnailsToShow and call the Image composable with the url and the size of image that we knew already.
- We calculate remaining based on thumbnails.size and numberOfThumbnailsToShow and use that to display as the Badge at the end of the Row.
BoxWithConstraintsScope provides access to maxWidth, minWidth, maxHeight and minHeight, which are values in Dp. In addition to that, there is a constraints property which contains all the above 4 properties in pixels. If you are curious about the implementation of BoxWithConstraints, best place to check would be the source code.
Card in landscape mode
In landscape orientation, all the thumbnails are laid out without adding any specific cases. It just works as maxWidth property on BoxWithConstraintScope updates on changing the orientation.
One could argue that we could have used screenWidth from LocalConfiguration Composition Local and still achieve the same result, but then the composable can only be used in places where it fills the whole screen width, not really ideal. Composables should try to consider the size constraints when deciding how to render. When individual composables naturally adapt to the space available, then a screen can become reasonably responsive even if the author of the screen wasn’t explicitly thinking about responsive layout.
Let’s consider the case where we can place this card in a grid or Row on a wider screen or landscape mode. At the risk of sounding too repetitive, this just works without adding any specific cases as the constraints passed change based on the space available. Pretty neat, right? 😃
Job Offers
Displaying a defined number of items in LazyRow
In the previous example we wanted to list as many photos as possible and their size was known. In this example we want to show pre–determined number of photos and their size is based on the available space and the number of photos we would like to display.
Adjusting photo size to show 2 photos at a time
We are showing two items here, let’s jump in the code! 🤓
@Composable | |
private fun PhotosRow( | |
images: List<BeautifulCreature>, | |
numPhotosVisible: Float, | |
modifier: Modifier = Modifier, | |
) { | |
BoxWithConstraints(modifier = modifier) { | |
// Arbitrarily chosen 20 as number of "units" to divide the available width | |
val numGrids = 20 | |
// Using available space to calculate the space between items and itemWidth | |
val spaceBetweenItems = maxWidth.div(numGrids) | |
val itemWidth = (maxWidth - spaceBetweenItems).div(numPhotosVisible) | |
LazyRow { | |
items(images) { | |
PhotoCard( | |
onClick = { }, | |
photo = it.photo, | |
contentDescription = it.name, | |
modifier = Modifier | |
.width(itemWidth) | |
.aspectRatio(1f) | |
) | |
if (it != images.last()) { | |
Spacer(modifier = Modifier.width(spaceBetweenItems)) | |
} | |
} | |
} | |
} | |
} | |
@Composable | |
fun PhotoCard( | |
onClick: () -> Unit, | |
photo: String, | |
contentDescription: String, | |
modifier: Modifier = Modifier, | |
) { | |
Card( | |
onClick = onClick, | |
modifier = modifier | |
) { | |
Image( | |
painter = rememberImagePainter(data = photo), | |
contentDescription = contentDescription, | |
contentScale = ContentScale.Crop | |
) | |
} | |
} |
On lines 9 to 12, we are calculating itemWidth and spaceBetweenItems based on numPhotosVisible param passed to PhotosRow Composable. That’s it really!
Now, let’s say we want to show a bit of the third item so it feels like there are more items available to give a hint to the user that it’s scrollable. All we have to do is pass a different value for numPhotosVisible and it works like a charm!
numPhotosVisible set to 2.5f
Lazy Grid uses it under the hood
Have you ever wondered how LazyVerticalGrid is able to show dynamic number of columns based on the space available. Hmmm, Sound familiar? 🤔
Let’s peek into the Compose foundation package check how LazyVerticalGrid implements this functionality.
@ExperimentalFoundationApi | |
@Composable | |
fun LazyVerticalGrid( | |
cells: GridCells, | |
modifier: Modifier = Modifier, | |
state: LazyListState = rememberLazyListState(), | |
contentPadding: PaddingValues = PaddingValues(0.dp), | |
content: LazyGridScope.() -> Unit | |
) { | |
val scope = LazyGridScopeImpl() | |
scope.apply(content) | |
when (cells) { | |
is GridCells.Fixed -> | |
FixedLazyGrid( | |
nColumns = cells.count, | |
modifier = modifier, | |
state = state, | |
contentPadding = contentPadding, | |
scope = scope | |
) | |
is GridCells.Adaptive -> | |
BoxWithConstraints( | |
modifier = modifier | |
) { | |
val nColumns = maxOf((maxWidth / cells.minSize).toInt(), 1) | |
FixedLazyGrid( | |
nColumns = nColumns, | |
state = state, | |
contentPadding = contentPadding, | |
scope = scope | |
) | |
} | |
} | |
} | |
@ExperimentalFoundationApi | |
sealed class GridCells { | |
@ExperimentalFoundationApi | |
class Fixed(val count: Int) : GridCells() | |
/** | |
* Combines cells with adaptive number of rows or columns. It will try to position as many rows | |
* or columns as possible on the condition that every cell has at least [minSize] space and | |
* all extra space distributed evenly. | |
* | |
* For example, for the vertical [LazyVerticalGrid] Adaptive(20.dp) would mean that there will be as | |
* many columns as possible and every column will be at least 20.dp and all the columns will | |
* have equal width. If the screen is 88.dp wide then there will be 4 columns 22.dp each. | |
*/ | |
@ExperimentalFoundationApi | |
class Adaptive(val minSize: Dp) : GridCells() | |
} |
On lines 22 to 26, we see that it actually uses BoxWithConstraints to calculate the number of cells based on the minSize passed as param of GridCells.Adaptive(minSize). Adaptive actually uses FixedLazyGridunderneath, the same as if we set cells as GridCells.Fixed. The only difference being that it calcuates the nColumns instead of the caller specifying.
Bonus
Based on the width available, we can set different discrete values of fontSizes to the Text child. There’s a code sample and example in this Jetpack Compose Playground project.
There are innumerable scenarios where BoxWithConstraints will shine. It’s always good to know about the tool and then be able to choose to use it. Feel free to comment your favourite usecases and Happy Composing! 🎼
Thanks to Chirag Kunder, Mario Sanoguera de Lorenzo, and Jim.