Large Screens & Foldables Tutorial for Android

Learn how to build great user experiences for large screens & foldables in Android. Also learn how to design and test adaptive Android apps. By Beatrice Kinya.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

Looking Into Device Fold Posture

A foldable device can be in various states and postures. It may be folded or unfolded, in portrait or landscape orientation. It could be in a tabletop or book posture. An adaptive design supports different foldable postures.

Jetpack WindowManager library’s WindowLayoutInfo class provides the following information about foldable displays:

  • state: This describes the fold state. Its value is FLAT when the device is fully opened, or HALF_OPENED.
  • orientation: The orientation of the hinge. It can be HORIZONTAL or VERTICAL.
  • occlusionType: The value is FULL when the hinge hides part of the display. Otherwise the value is NONE.
  • isSeparating: It’s true when the hinge creates two logical displays.

You’ll use this information to determine device fold posture. Open presentation ▸ util ▸ DevicePostureUtil.kt. DevicePosture interface defines the following postures:

  • Normal posture: Whether a device is fully opened or fully folded.
  • Book posture: The device is in portrait orientation and its fold state is HALF_OPENED.
  • Separating posture: The device is completely open and its fold state is FLAT. It’s similar to the case of device posture where occlusionType is FULL because of a physical hinge. Avoid placing touchable or visible parts under the hinge.

Analyzing Device Fold Posture

To get device fold posture, open MainActivity.kt and replace // TODO 3 with the following:

// 1
val devicePostureFlow = WindowInfoTracker.getOrCreate(this).windowLayoutInfo(this)
  .flowWithLifecycle(this.lifecycle)
  // 2
  .map { layoutInfo ->
    val foldingFeature =
      layoutInfo.displayFeatures
        .filterIsInstance()
        .firstOrNull()
    when {
      isBookPosture(foldingFeature) ->
        DevicePosture.BookPosture(foldingFeature.bounds)

      isSeparating(foldingFeature) ->
        DevicePosture.Separating(foldingFeature.bounds, foldingFeature.orientation)

      else -> DevicePosture.NormalPosture
    }
  }
  .stateIn(
    scope = lifecycleScope,
    started = SharingStarted.Eagerly,
    initialValue = DevicePosture.NormalPosture
  )

Also include the following imports to avoid Android Studio’s complaints:

import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoTracker
import com.yourcompany.android.craftynotebook.presentation.util.DevicePosture
import com.yourcompany.android.craftynotebook.presentation.util.isBookPosture
import com.yourcompany.android.craftynotebook.presentation.util.isSeparating
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn

In the code above, you’re using Kotlin Flows to work with WindowLayoutInfo data collection.

  1. windowLayoutInfo(activity: Activity) returns display information of a device as Flow. The method emits WindowLayoutInfo every time the display information changes.
  2. It uses map operator and display information returned by windowLayoutInfo(activity: Activity) to determine the device fold posture.

Next, you’ll observe device posture as compose state. In MainActivity.kt, replace // TODO 4 with the following and import the corresponding package.

val devicePosture = devicePostureFlow.collectAsState().value

Then, pass devicePosture in NoteApp() composable call. Replace // TODO 5 with the following:

devicePosture = devicePosture,

Up to this point using window size classes, the app knows the screen space available. It also knows the device fold posture. You’ll use this information to determine the app UI. First, you’ll implement responsive navigation.

Choosing Appropriate Navigation Type

Responsive UIs include different types of navigation elements corresponding to display size changes.

Material library provides navigation components like bottom navigation, navigation rail and navigation drawer. You’ll implement the most appropriate navigation depending on the window size class of a device:

  • Bottom navigation: Bottom navigation is most appropriate for compact window sizes.
  • Navigation rail: Use navigation rail for medium screen sizes.
  • Navigation drawer: This would be suitable for large-screen devices like tablets. There are two types of navigation drawers: modal and permanent. Use a modal navigation drawer for compact to medium sizes because it can be expanded as an overlay on the content or hidden. Use a permanent navigation drawer for fixed navigation on large screens like tablets and Chrome OS devices.

Now, you’ll switch between different navigation types depending on the window size of a class and device fold posture.

Open NoteApp.kt and replace // TODO 6 with the following and import the package for NavigationType:

// 1
val navigationType: NavigationType
// 2
when (windowSizeClass) {
  WindowWidthSizeClass.Compact -> {
    navigationType = NavigationType.BOTTOM_NAVIGATION
    // TODO 13
  }
  WindowWidthSizeClass.Medium -> {
    navigationType = NavigationType.NAVIGATION_RAIL
    // TODO 14
  }
  WindowWidthSizeClass.Expanded -> {
    // 3
    navigationType = if (devicePosture is DevicePosture.BookPosture) {
      NavigationType.NAVIGATION_RAIL
    } else {
      NavigationType.PERMANENT_NAVIGATION_DRAWER
    }
    // TODO 15
  }
  else -> {
    navigationType = NavigationType.BOTTOM_NAVIGATION
    // TODO 16
  }
}

The code above does the following:

  1. Declares the navigationType variable.
  2. Using a switch statement, it initializes navigationType with the correct value depending on the window size class.
  3. Handles fold state to avoid placing content or touching action at the hinge area. When a device is in BookPosture, use a navigation rail and divide content around the hinge. For large desktops or tablets, use a permanent navigation drawer.

Next, you’ll pass navigationType to NoteNavigationWrapperUi() composable call. In NoteApp.kt, replace // TODO 7 with the following:

navigationType = navigationType,

Now, the app knows navigation types to apply to different window size classes and device fold postures. Next, you’ll implement different navigation to ensure excellent interaction and reachability.

Implementing Responsive Navigation

Open NoteNavigationWrapperUi.kt. Replace NoteAppContent() composable call with the following:

if (navigationType == NavigationType.PERMANENT_NAVIGATION_DRAWER) {
  PermanentNavigationDrawer(drawerContent = {
    NavigationDrawerContent(
      navController = navController
    )
  }) {
    NoteAppContent(
      navigationType = navigationType,
      contentType = contentType,
      modifier = modifier,
      navController = navController,
      notesViewModel = notesViewModel
    )
  }
} else {
  ModalNavigationDrawer(
    drawerContent = {
      NavigationDrawerContent(
        navController = navController,
        onDrawerClicked = {
          scope.launch {
            drawerState.close()
          }
        }
      )
    },
    drawerState = drawerState
  ) {
    NoteAppContent(
      navigationType = navigationType,
      contentType = contentType,
      modifier = modifier,
      navController = navController,
      notesViewModel = notesViewModel,
      onDrawerClicked = {
        scope.launch {
          drawerState.open()
        }
      }
    )
  }
}

As usual, there are a few imports you need to add as well:

import kotlinx.coroutines.launch
import androidx.compose.material3.*

The navigation drawer is the container for notes UI. In the code above, you’re wrapping the NoteAppContent() composable call with a permanent or modal navigation drawer depending on the value of navigationType.

In NoteAppContent.kt, replace the Column() composable with the following:

Row(modifier = Modifier.fillMaxSize()) {
  AnimatedVisibility(visible = navigationType == NavigationType.NAVIGATION_RAIL) {
    NoteNavigationRail(
      onDrawerClicked = onDrawerClicked,
      navController = navController
    )
  }
  Column(
    modifier = modifier.fillMaxSize()
  ) {
    NoteNavHost(
      modifier = modifier.weight(1f),
      contentType = contentType,
      navController = navController,
      notesViewModel = notesViewModel
    )
    AnimatedVisibility(visible = navigationType == NavigationType.BOTTOM_NAVIGATION) {
      NoteBottomNavigationBar(navController = navController)
    }
  }
}

To make Android Studio happy, add the following imports as well:

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Row

The code above uses navigationType to determine placement of navigation rail or bottom navigation. You wrapped both navigation rail and bottom navigation in the AnimatedVisibility() composable. This animates the entry and exit visibility of each navigation depending on navigationType .

Build and run.

For compact window size class like a phone, the app uses bottom navigation like in the screen below:

A compact screen window size class like a phone uses bottom navigation

In a medium window size class, the app uses a navigation rail like in the screen below:

A medium window size class like unfolded foldable uses navigation rail

The app uses a permanent navigation drawer in an expanded window size class, like this:

A large screen using a permanent navigation drawer

Congratulations! You’ve successfully implemented dynamic navigation on different devices. Next, you’ll utilize the additional screen space to show more content. You’ll implement list-detail on large screens.