Compose by example: BoxWithConstraints
November 18, 2022
BoxWithConstraints Composable lets us build adaptive layouts based on available height/width and other constraints. This post will provide an example when it can be useful.
Let’s imagine our Designer is asking to build a horizontal carousel with the following requirements:
in portrait mode, 2 cards are fully visible and an additional one is peeking letting users know that there is more content to scroll through
in landscape mode, 4 cards are fully visible and an additional one is peeking letting users know that there is more content to scroll through
To visualize this layout and behavior:
Since BoxWithConstraints gives us access to maxWidth and maxHeight, we can:
Figure out if we are currently in portrait or landscape mode
Calculate width of each card based on available screen width also taking into consideration that one card should be peeking (partially visible)
Let’s see the code:
BoxWithConstraints {
val boxWithConstraintsScope = this
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
contentPadding = PaddingValues(24.dp)
) {
items(cardData) { card ->
if (boxWithConstraintsScope.maxWidth > maxHeight) {
// in landscape mode
val cardWidth = boxWithConstraintsScope.maxWidth / 4
MyCard(
title = card.first,
subtitle = card.second,
height = boxWithConstraintsScope.maxHeight / 3,
width = cardWidth - cardWidth * 0.15f
)
} else {
// in portrait mode
val cardWidth = boxWithConstraintsScope.maxWidth / 2
MyCard(
title = card.first,
subtitle = card.second,
height = boxWithConstraintsScope.maxHeight / 4,
width = cardWidth - cardWidth * 0.2f
)
}
}
}
}
We can clean this up by creating an intermediate Composable scoped to BoxWithConstraintsScope: and use maxWidth and maxHeight without specifying scope every time.
@Composable
fun BoxWithConstraintsScope.AdaptiveLayoutCardList(cardData: List<Pair<String, String>>) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
contentPadding = PaddingValues(24.dp)
) {
items(cardData) { card ->
if (maxWidth > maxHeight) {
// in landscape mode
val cardWidth = maxWidth / 4
MyCard(
title = card.first,
subtitle = card.second,
height = maxHeight / 3,
width = cardWidth - cardWidth * 0.15f
)
} else {
// in portrait mode
val cardWidth = maxWidth / 2
MyCard(
title = card.first,
subtitle = card.second,
height = maxHeight / 4,
width = cardWidth - cardWidth * 0.2f
)
}
}
}
}And calling it like so:
setContent {
BoxWithConstraintsDemoTheme {
val cardData = remember { generateCards() }
BoxWithConstraints {
AdaptiveLayoutCardList(cardData)
}
}
}To summarize, BoxWithConstraints is similar to Box but gives us access to boxWithConstraintsScope that can help us analyze parent dimensions and available constraints to help you decide what content to display or how to lay it out.
Note that we calculate cardWidth based on boxWithConstraintsScope.maxWidth but boxWithConstraintsScope also provides access to these properties:
Affect on Compose phases
As a reminder, normally the order of Jetpack Compose phases is:
However, when BoxWithConstraints is used, these phases are affected as follows:
The Composition phase is deferred until the Layout phase until the constraints and dimensions are known
More work is done in the Layout phase which may be noticeable, especially in complex layouts
boxWithConstraintsScope makes it easy to figure if we are in landscape or portrait mode just by comparing width and height:
if (maxWidth > maxHeight) {
// in landscape
} else {
// in portrait mode
}A more verbose alternative to determine the orientation would be LocalConfiguration.
val configuration = LocalConfiguration.current
when (configuration.orientation) {
Configuration.ORIENTATION_LANDSCAPE -> {
// in landscape mode
}
else -> {
// in portrait mode
}
}
In this case, since we are already using BoxWithConstraints, using LocalConfiguration is unnecessary.
Full source for this example is below as well as at https://github.com/jshvarts/BoxWithConstraintsDemo
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
BoxWithConstraintsDemoTheme {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
val cardData = remember { generateCards() }
BoxWithConstraints {
AdaptiveLayoutCardList(cardData)
}
}
}
}
}
}
@Composable
fun BoxWithConstraintsScope.AdaptiveLayoutCardList(cardData: List<Pair<String, String>>) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
contentPadding = PaddingValues(24.dp)
) {
items(cardData) { card ->
if (maxWidth > maxHeight) {
// in landscape mode
val cardWidth = maxWidth / 4
MyCard(
title = card.first,
subtitle = card.second,
height = maxHeight / 3,
width = cardWidth - cardWidth * 0.15f
)
} else {
// in portrait mode
val cardWidth = maxWidth / 2
MyCard(
title = card.first,
subtitle = card.second,
height = maxHeight / 4,
width = cardWidth - cardWidth * 0.2f
)
}
}
}
}
@Composable
fun MyCard(
title: String,
subtitle: String,
height: Dp,
width: Dp
) {
Card(
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.height(height)
.width(width)
) {
Column(
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.background(Color.DarkGray)
.padding(24.dp)
) {
Text(
text = title,
color = Color.White,
style = MaterialTheme.typography.h6,
textAlign = TextAlign.Center
)
Text(
text = subtitle,
color = Color.White,
style = MaterialTheme.typography.subtitle1,
textAlign = TextAlign.Center
)
}
}
}
private fun generateCards(): List<Pair<String, String>> {
return MutableList(20) { index ->
val cardNumber = index + 1
"Title $cardNumber" to "Subtitle $cardNumber"
}
}
@Preview(showBackground = true)
@Composable
fun MyCardPreview(
title: String = "Title 1",
subtitle: String = "Subtitle 1",
height: Dp = 80.dp,
width: Dp = 60.dp
) {
BoxWithConstraintsDemoTheme {
MyCard(
title = title,
subtitle = subtitle,
height = height,
width = width
)
}
}