DEV Community

Cover image for Foldable-aware app layout
Thomas Künneth
Thomas Künneth

Posted on

Foldable-aware app layout

Welcome to the final part of Understanding foldable devices. As this series has been going on for some time, let's start with a short recap.

In the first three installments, I showed you why foldables may or may not be large screen devices, how folds and hinges influence the layout of your UI elements, and which tools you can use to test your apps' behavior on different device classes and form factors.

The forth part provided all code you need to make your Compose app behave well on smartphones, tablets, foldables, and Freeform experiences. An important part of my sample utilizes Jetpack WindowManager to gather information about the screen, the app window, and - if present - the fold or hinge. One bit of information is called Window size class. I used it to define the inner app layout. As there are actually two Window size class implementations, the previous (fifth) installment reflected on them.

While my sample code did provide a basic app frame by using Scaffold() and TopAppBar(), it lacked one important aspect: navigation. In this sixth part, we will look at corresponding composables. As you will see shortly, the device category influences which one you should use. I'll also show you, that the different navigation composables affect the amount of space available for the content, as well as its location on screen.

Sounds interesting? Great, kindly read along.

Layout anatomy

The most important part of your app is its content. The content is the reason why your users run the app. Please recall that, depending on the device category and available space, I suggest one, two, or maybe even more columns. But what's inside these columns? We'll turn to this in section Content. The Material Design documentation, by the way, refers to the content as body. But I feel content is more expressive.

Another important area on screen is reserved for navigation. Looking at common Android apps, you'll spot a few places where navigation can happen:

  • at the top of the app window (TopAppBar(), …)
  • at the bottom of the app window (NavigationBar(), …)
  • at the left app window border (PermanentNavigationDrawer(), NavigationRail(), …)

In case you are wondering why I use the term app window instead of screen… While the space that is available to an app is limited by screen sizes, this does not mean than the app can fully occupy this area. On Freeform experiences we have resizable windows, and even on a smartphone we can run two apps side by side in split screen mode. So, the area on screen that is available to an app can change over time.

While app bars display information and actions, these actions may well trigger navigation, too. Therefore, I treat app bars as navigation components.

Here are some takeaways:

  1. There are two main layout regions, Navigation and Content.
  2. We have several navigation composables to choose from.
  3. The term layout may refer to the outer layout (location and size of navigation components and the content) and the inner layout (how the content is presented).

Parts of the outer layout are handled by provided composables (for example, Scaffold()). But some need to be managed by your app. Here's how the latest version of my FoldableDemo sample looks like in different situations:

FoldableDemo running in portrait smartphone mode

On smartphones in portrait mode we typically have app bars at the top and bottom. The top app bar may also include a modal navigation drawer (to keep things simple, FoldableDemo doesn't have one).

FoldableDemo running in landscape smartphone mode

If a device is wide enough (you'll see shortly how this is calculated), the app may switch to a navigation rail. On even larger screens this might also be a permanent navigation drawer (to keep things simple, FoldableDemo doesn't do so).

FoldableDemo running in portrait foldable mode

On an open foldable device in portrait mode, we will likely use a navigation rail or a permanent navigation drawer. The app uses a two column layout for the content. Please note that, because of the navigation rail, the left column is slightly smaller.

FoldableDemo running in landscape foldable mode

If we rotate the foldable (the fold or hinge is running horizontally), I suggest using one column but two rows for the content, particularly if the hinge is obstructing. As you know from the previous parts, my sample code takes obstructing folds and hinges into account, therefore no part of your content becomes invisible. But how do you use the two columns or rows? We'll tackle this in section Content.

Now, you may be thinking Well, that looks great, but how does the revised code look like?

Window size classes are a key element in defining the app layout. The previous version of FoldableDemo has already used them for the inner app layout, to differentiate between smartphone, foldables, large screen devices and Freeform experiences. It still does, but now the sample also uses these switches to toggle between …

  • Bottom bar navigation and navigation rail or navigation drawer
  • displaying a top app bar and not displaying a top app bar

Take a look:

setContent {
  val layoutInfo by WindowInfoTracker.getOrCreate(this@FoldableDemoActivity)
    .windowLayoutInfo(this@FoldableDemoActivity).collectAsState(
      initial = null
    )
  val windowMetrics = WindowMetricsCalculator.getOrCreate()
    .computeCurrentWindowMetrics(this@FoldableDemoActivity)
  // might become part of some UIState - kept here for simplicity
  val foldDef = createFoldDef(layoutInfo, windowMetrics)
  val hasTopBar =
    foldDef.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT
  val hasBottomBar =
    foldDef.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT
  val hasNavigationRail = !hasBottomBar
  val index = rememberSaveable { mutableStateOf(0) }
  MaterialTheme(
    content = {
      Scaffold(
        topBar = { MyTopBar(hasTopBar = hasTopBar) },
        bottomBar = {
          MyBottomBar(
            hasBottomBar = hasBottomBar,
            index = index
          )
        }
      ) { padding ->
        Content(
          foldDef = foldDef,
          paddingValues = padding,
          hasNavigationRail = hasNavigationRail,
          index = index
        )
      }
    },
    colorScheme = defaultColorScheme()
  )
}
Enter fullscreen mode Exit fullscreen mode

Based on foldDef, we define a few variables, hasTopBar, hasBottomBar, and hasNavigationRail, and pass them to several composables. Here's what MyBottomBar() does:

@Composable
fun MyBottomBar(hasBottomBar: Boolean, index: MutableState<Int>) {
  if (hasBottomBar)
    NavigationBar {
      for (i in 0..2)
        NavigationBarItem(selected = i == index.value,
                 onClick = { index.value = i },
                 icon = {
                   Icon(
                     painter = painterResource(id = R.drawable.ic_android_black_24dp),
                     contentDescription = null
                   )
                 },
                 label = {
                   MyText(index = i)
                 }
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

So, MyBottomBar() invokes NavigationBar {} only if hasBottomBar is true. Do you remember I said that Window size classes are used like switches? Take a look:

val hasBottomBar =
    foldDef.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT
Enter fullscreen mode Exit fullscreen mode

In your app you may want to put those switches in some UI state class and pass that state to your composables. I decided to keep the bare variables to make things more transparent and simple.

Content

Here's how the updated Content() composable looks like:

@Composable
fun Content(
  foldDef: FoldDef,
  paddingValues: PaddingValues,
  hasNavigationRail: Boolean,
  index: MutableState<Int>
) {
  Row(modifier = Modifier.fillMaxSize()) {
    if (hasNavigationRail)
      NavigationRail {
        for (i in 0..2)
          NavigationRailItem(selected = i == index.value,
                    onClick = {
                      index.value = i
                    },
                    icon = {
                      Icon(
                        painter = painterResource(id = R.drawable.ic_android_black_24dp),
                        contentDescription = null
                      )
                    },
                    label = {
                      MyText(index = i)
                    })
      }
    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

Have you noticed, that, besides the name of the composable, my NavigationRailItem and NavigationBarItem creation loops are the same? I really wish, Google would add a common composable, NavigationItem. This would greatly ease reuse and avoid code duplication. Anyway. While the BoxWithConstraints() was already present in the previous version, it is now a child of Row(). There's also a NavigationRail () if hasNavigationRail is true. So, depending on that switch, Row() has either one or two children. That's, by the way, the reason I said parts of the outer layout need to be handled by you. To make your app layout even more sophisticated, you can show a permanent navigation drawer instead of the navigation rail on large screens. To do this, you first need to define your switches like this:

val hasNavigationRail =
      foldDef.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.MEDIUM
val hasPermanentNavigationDrawer =
      foldDef.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED
Enter fullscreen mode Exit fullscreen mode

Then, inside Content(), you just invoke the corresponding composables based on that switches (either NavigationRail() or PermanentNavigationDrawer().

While I was adding support for navigation rails, I spotted a small glitch in my previous implementation. Can you guess what happened here?

Screenshot of a source code diff

You may remember that, at least on devices with a fold or hinge, I suggest a two column layout with equally sized areas. However, the navigation rail shrinks the space being available for content in the left column. We need to take this into account. While we could try to calculate the width of the navigation rail and subtract it, it is easier to let Jetpack Compose do the work.

Final Remarks

FoldableDemo uses colored boxes to represent the app content. Based on the device category and form factor, the content area may be divided into two ore more columns. Please recall that my sample code handles folds and hinges for you, so you just need to fill the columns. With what, depends on the the purpose of your app. Google has looked at lots of apps and has identified three general layout types:

  • Feed arranges cards or other content elements in a grid
  • List-detail displays item lists and the detail of one (selected, current) item side by side
  • Supporting pane organizes app content into primary and secondary display areas

Google calls them Canonical layouts, which are described as

Ready-to-use compositions that help layouts adapt for common use cases and screen sizes.

The idea is to use them as starting points for organizing common elements in your app. The Material Design website explains:

Each layout considers common use cases and components to address expectations and user needs for how apps adapt across window class sizes and breakpoints.

So, at least for now, canonical layouts are more like concepts than ready-to-use components. Yet, Google offers quite a few samples that make use of / implement them. Please refer to the Resources section for details. I may be providing an enhanced version of FoldableDemo later this year.

Well, that's it. I hope that you enjoyed this series. Please share your thoughts in the comments.


Unless specified otherwise, all pictures are (c) Thomas Künneth

Resources

Oldest comments (0)