DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

Cover image for Code your UI
Thomas KΓΌnneth
Thomas KΓΌnneth

Posted on

Code your UI

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. As usual, the library is added to a project as an implementation dependency.

dependencies {
  ...
  implementation "androidx.window:window:1.1.0-alpha04"
}
Enter fullscreen mode Exit fullscreen mode

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

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()
        )
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

@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
      )
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

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,
)
Enter fullscreen mode Exit fullscreen mode

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:

@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
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

@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()
}
Enter fullscreen mode Exit fullscreen mode

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

A smartphone screen in landscape mode

@Composable
fun SmartphoneScreen(foldDef: FoldDef) {
  Box(
    modifier = Modifier.fillMaxSize(),
    contentAlignment = Alignment.Center
  ) {
    YellowBox()
    PortraitOrLandscapeText(foldDef = foldDef)
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

if (foldDef.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED) {
Enter fullscreen mode Exit fullscreen mode

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

@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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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

Foldable with a hinge running horizontally

@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()
}
Enter fullscreen mode Exit fullscreen mode

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:

@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
  )
}
Enter fullscreen mode Exit fullscreen mode

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

@Composable
fun ColoredBox(modifier: Modifier, color: Color) {
  Box(
    modifier = modifier
      .background(color)
      .border(1.dp, Color.White)
  )
}
Enter fullscreen mode Exit fullscreen mode

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():

@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
  )
}
Enter fullscreen mode Exit fullscreen mode

This concludes the code walkthrough. You can find the project on GitHub.

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

Top comments (0)

🌚 Friends don't let friends browse without dark mode.

Just kidding, it's a personal preference. But you can change your theme, font, etc. in your settings.

The more you know. 🌈