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:

  1. 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

  2. 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:

  1. Figure out if we are currently in portrait or landscape mode

  2. 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:

  1. The Composition phase is deferred until the Layout phase until the constraints and dimensions are known

  2. 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
    )
  }
}
Previous
Previous

Testing functions with lambdas using MockK

Next
Next

Slim down your Android components with LifecycleObserver