Code your UI
Tags: android, foldable, jetpackcompose
Published: 2023-01-21 11:48:14

Welcome to the fourth part of *Understanding foldable devices*. In this installment we will finally be coding 👏. I show you how to create a Jetpack Compose app that honors folds and hinges, distinguishes between portrait and landscape mode, and takes advantage of large screens. This sounds like a big task, right?

It won't be one.

### Setup

To get information about folds and hinges we will be using [Jetpack WindowManager](https://developer.android.com/jetpack/androidx/releases/window). As usual, the library is added to a project as an implementation dependency.

```gradle
dependencies {
  ...
  implementation "androidx.window:window:1.1.0-alpha04"
}
```

As you will see shortly, just two functions, `windowLayoutInfo()` and `computeCurrentWindowMetrics()`, provide all data we need. Here is how they are invoked:

```kotlin
class FoldableDemoActivity : ComponentActivity() {
  @OptIn(ExperimentalMaterial3Api::class)
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    lifecycleScope.launchWhenResumed {
      setContent {
        val layoutInfo by WindowInfoTracker.getOrCreate(this@FoldableDemoActivity)
          .windowLayoutInfo(this@FoldableDemoActivity).collectAsState(
            initial = null
          )
        val windowMetrics = WindowMetricsCalculator.getOrCreate()
          .computeCurrentWindowMetrics(this@FoldableDemoActivity)
        MaterialTheme(
          content = {
            Scaffold(
              topBar = {
                TopAppBar(title = {
                  Text(stringResource(id = R.string.app_name))
                })
              }
            ) { padding ->
              Content(
                layoutInfo = layoutInfo,
                windowMetrics = windowMetrics,
                paddingValues = padding
              )
            }
          },
          colorScheme = if (isSystemInDarkTheme())
            darkColorScheme()
          else
            lightColorScheme()
        )
      }
    }
  }
}
```

In this code snippet there seems to be quite a few stuff going on. But it's actually really straight forward. `setContent { }` receives the root of our Compose UI, a `MaterialTheme()` (with colors for light and dark mode) containing a `Scaffold()` containing a `TopAppBar()` and our `Content()`. Because `windowLayoutInfo()` returns a flow, we launch a coroutine with `lifecycleScope.launchWhenResumed()`. 

That wasn't hard, right? The next one won't be either.

```kotlin
@Composable
fun Content(
  layoutInfo: WindowLayoutInfo?,
  windowMetrics: WindowMetrics,
  paddingValues: PaddingValues
) {
  val foldDef = createFoldDef(layoutInfo, windowMetrics)
  BoxWithConstraints(
    modifier = Modifier
      .fillMaxSize()
      .padding(paddingValues = paddingValues)
  ) {
    if (foldDef.hasFold) {
      FoldableScreen(
        foldDef = foldDef
      )
    } else if (foldDef.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED) {
      LargeScreen(
        foldDef = foldDef
      )
    } else {
      SmartphoneScreen(
        foldDef = foldDef
      )
    }
  }
}
```

`Content()` is basically a dispatcher (not in the sense of coroutines, of course 😊). It looks at `layoutInfo: WindowLayoutInfo?` and `windowMetrics: WindowMetrics` to determine the device type and then calls either `FoldableScreen()`, `LargeScreen()`, or `SmartphoneScreen()`. Please note that I wrap the invoked composable inside `BoxWithConstraints()` because that function provides some information regarding constraints to its content (which you may want to take advantage of). 

Before we look at the three device type screens, let's briefly turn to `FoldDef`.

```kotlin
data class FoldDef(
  val hasFold: Boolean,
  val foldOrientation: FoldingFeature.Orientation?,
  val foldWidth: Dp,
  val foldHeight: Dp,
  val widthLeftOrTop: Dp,
  val heightLeftOrTop: Dp,
  val widthRightOrBottom: Dp,
  val heightRightOrBottom: Dp,
  val isPortrait: Boolean,
  val windowSizeClass: WindowSizeClass,
)
```

The class holds information about the fold or hinge, for example its orientation and size. And it stores the dimensions of the areas to the left and right of the gap, which is where your app UI resides.

Here's how the data for `FoldDef` is obtained:

```kotlin
@Composable
fun createFoldDef(
  layoutInfo: WindowLayoutInfo?,
  windowMetrics: WindowMetrics
): FoldDef {
  var foldOrientation: FoldingFeature.Orientation? = null
  var widthLeftOrTop = 0
  var heightLeftOrTop = 0
  var widthRightOrBottom = 0
  var heightRightOrBottom = 0
  var foldWidth = 0
  var foldHeight = 0
  layoutInfo?.displayFeatures?.forEach { displayFeature ->
    (displayFeature as FoldingFeature).run {
      foldOrientation = orientation
      if (orientation == FoldingFeature.Orientation.VERTICAL) {
        widthLeftOrTop = bounds.left
        heightLeftOrTop = windowMetrics.bounds.height()
        widthRightOrBottom = windowMetrics.bounds.width() - bounds.right
        heightRightOrBottom = heightLeftOrTop
      } else if (orientation == FoldingFeature.Orientation.HORIZONTAL) {
        widthLeftOrTop = windowMetrics.bounds.width()
        heightLeftOrTop = bounds.top
        widthRightOrBottom = windowMetrics.bounds.width()
        heightRightOrBottom = windowMetrics.bounds.height() - bounds.bottom
      }
      foldWidth = bounds.width()
      foldHeight = bounds.height()
    }
  }
  return with(LocalDensity.current) {
    FoldDef(
      foldOrientation = foldOrientation,
      widthLeftOrTop = widthLeftOrTop.toDp(),
      heightLeftOrTop = heightLeftOrTop.toDp(),
      widthRightOrBottom = widthRightOrBottom.toDp(),
      heightRightOrBottom = heightRightOrBottom.toDp(),
      foldWidth = foldWidth.toDp(),
      foldHeight = foldHeight.toDp(),
      isPortrait = windowWidthDp(windowMetrics) / windowHeightDp(windowMetrics) <= 1F,
      windowSizeClass = WindowSizeClass.compute(
        dpWidth = windowWidthDp(windowMetrics = windowMetrics).value,
        dpHeight = windowHeightDp(windowMetrics = windowMetrics).value
      ),
      hasFold = foldOrientation != null
    )
  }
}
```

Now, here *are* quite a few things going on. But you don't need to bother because I made all the calculations for you 😍. If you are curious, feel free to dig in, though. To fully understand the code you need to see two more functions:

```kotlin
@Composable
fun windowWidthDp(windowMetrics: WindowMetrics): Dp = with(LocalDensity.current) {
  windowMetrics.bounds.width().toDp()
}

@Composable
fun windowHeightDp(windowMetrics: WindowMetrics): Dp = with(LocalDensity.current) {
  windowMetrics.bounds.height().toDp()
}
```

They return the width and height of a window in density independent pixels. 

### Content based on device types

But now let's look at the device type screens. We start with traditional smartphones.

![A smartphone screen in portrait mode](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/znxra0lppo904wtokvnm.png)


![A smartphone screen in landscape mode](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gflpf2xku3hf5qhtl7nd.png)



```kotlin
@Composable
fun SmartphoneScreen(foldDef: FoldDef) {
  Box(
    modifier = Modifier.fillMaxSize(),
    contentAlignment = Alignment.Center
  ) {
    YellowBox()
    PortraitOrLandscapeText(foldDef = foldDef)
  }
}
```

My implementation of `SmartphoneScreen()` shows a `Box()` containing `YellowBox()` and `PortraitOrLandscapeText()`. You would be putting the main content of your UI here. The app bar has already been set in `onCreate()`. Please note that your composables can take all available space (`modifier = Modifier.fillMaxSize()`).

Now let's turn to large screens. What a large screen actually is can be adjusted in `Content()`. My implementation just does this:

```kotlin
if (foldDef.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED) {
```

Depending on the layout of your app (what it wants to show) you may want to add additional conditions. For example, if the device is in portrait mode you may want to look at `windowHeightSizeClass`.


![FoldableDemo running on the Windows Subsystem for Android](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/f2k0bdtm2ut9bua0dkeh.png)



```kotlin
@Composable
fun LargeScreen(foldDef: FoldDef) {
  Box(
    modifier = Modifier.fillMaxSize(),
    contentAlignment = Alignment.Center
  ) {
    Row(
      modifier = Modifier.fillMaxSize(),
    ) {
      val localModifier = Modifier
        .fillMaxHeight()
        .weight(0.333F)
      Box(modifier = localModifier) {
        RedBox()
      }
      Box(modifier = localModifier) {
        YellowBox()
      }
      Box(modifier = localModifier) {
        GreenBox()
      }
    }
    PortraitOrLandscapeText(foldDef)
  }
}
```

My example stacks a `Row()` and `PortraitOrLandscapeText()` in a `Box()`, so you can see how easy it is to implement multi column layouts. If you recall the previous parts of this series, I strongly suggest utilizing two columns even on large screens, so this code is more showing the flexibility of my approach. As like in `SmartphoneScreen()`, your composable can take advantage of all available space (`modifier = Modifier.fillMaxSize()`).

Finally, let's tackle the supreme discipline, foldable devices with or without an obstructing hinge:


![Foldable with a vertically running hinge](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/r001rx576f3bff2e5e1w.png)


![Foldable with a hinge running horizontally](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/r2ibva7159z404n3wcvi.png)



```kotlin
@Composable
fun FoldableScreen(foldDef: FoldDef) {
  val hinge = @Composable {
    Spacer(
      modifier = Modifier
        .width(foldDef.foldWidth)
        .height(foldDef.foldHeight)
    )
  }
  val firstComposable = @Composable {
    RedBox()
  }
  val secondComposable = @Composable {
    GreenBox()
  }
  val container = @Composable {
    if (foldDef.foldOrientation == FoldingFeature.Orientation.VERTICAL) {
      Row(modifier = Modifier.fillMaxSize()) {
        Box(
          modifier = Modifier
            .fillMaxHeight()
            .width(foldDef.widthLeftOrTop)
        ) {
          firstComposable()
        }
        hinge()
        Box(
          modifier = Modifier
            .fillMaxHeight()
            .width(foldDef.widthRightOrBottom)
        ) {
          secondComposable()
        }
      }
    } else {
      Column(modifier = Modifier.fillMaxSize()) {
        Box(
          modifier = Modifier
            .fillMaxWidth()
            .weight(1.0F)
        ) {
          firstComposable()
        }
        hinge()
        Box(
          modifier = Modifier
            .fillMaxWidth()
            .height(foldDef.heightRightOrBottom)
        ) {
          secondComposable()
        }
      }
    }
  }
  container()
}
```

Now, this looks a little tougher, right? The good news is, you can reuse basically all code and replace only the contents of `firstComposable` and `secondComposable`. We'll look at them in a minute. First, let's understand what `FoldableScreen()` does.

We know that we are on a foldable device. Therefore, we check if the fold or hinge runs horizontally or vertically. A vertical fold means that there are two areas to the left and to the right of it. A horizontal fold means that these areas are above and below the fold or hinge. Translated to Jetpack Compose this means either `Row()` or `Column()`. This composable (`container`) receives three children:

- `firstComposable`
- `hinge` (I should probably rename it to `fold` 😂)
- `secondComposable`

The sizes of these children are set based on the data from `foldDef: FoldDef`. That's why your content can again use all available space. Take a look what my example does:

```kotlin
@Composable
fun RedBox() {
  ColoredBox(
    modifier = Modifier
      .fillMaxSize(),
    color = Color.Red
  )
}

@Composable
fun YellowBox() {
  ColoredBox(
    modifier = Modifier
      .fillMaxSize(),
    color = Color.Yellow
  )
}

@Composable
fun GreenBox() {
  ColoredBox(
    modifier = Modifier
      .fillMaxSize(),
    color = Color.Green
  )
}
```

All three are basically the same, besides passing a different color to `ColoredBox()`.

```kotlin
@Composable
fun ColoredBox(modifier: Modifier, color: Color) {
  Box(
    modifier = modifier
      .background(color)
      .border(1.dp, Color.White)
  )
}
```

`ColoredBox()` draws a small white border to visualize that the composable really is shown completely. I call this *visual debugging* 🤣. To make the code complete, here's one more composable, `PortraitOrLandscapeText()`:

```kotlin
@Composable
fun PortraitOrLandscapeText(foldDef: FoldDef) {
  Text(
    text = stringResource(
      id = if (foldDef.isPortrait)
        R.string.portrait
      else
        R.string.landscape
    ),
    style = MaterialTheme.typography.displayLarge,
    color = Color.Black
  )
}
```

This concludes the code walkthrough. You can find the project [on GitHub](https://github.com/tkuenneth/android-demos/tree/master/compose/FoldableDemo).

### Wrap up

As you have seen, supporting foldables and large screen devices is no big deal. At least, if you use my code snippets. Which I really invite you to, as they are completely free and can be used under any license you choose. I appreciate an attribution, but this is not required. So, there is literally no excuse for not properly supporting foldable and large screen devices.

In the following part, which will conclude this series, we will be looking at *Canonical layouts* and how they affect the code you have seen today. Please stay tuned.

---

Unless stated otherwise all images (c) Thomas Künneth