Jetpack Compose TwoPaneLayout update

Joy Liu

Kristen Halper

Hello Jetpack Compose developers!

This week, we’re excited to announce some big updates to TwoPaneLayout, our Jetpack Compose component for foldables and large screens.

We’ve just added a new TwoPaneLayout constructor with highly customizable navigation support, and the TwoPaneScope interface has been upgraded to provide more information while also protecting access to its fields and methods. These changes were substantial enough that we decided to bump to TwoPaneLayout version 1.0.1-xx, so now TwoPaneLayout 1.0.0 is the last version that still uses the old API.

To see examples of how to use and migrate to the newest TwoPaneLayout version, check out the documentation, library samples (TwoPaneLayout and TwoPaneLayoutNav), and the Surface Duo Compose samples.

Surface Duo 2 showing navigation sample with pane 1 and pane 2 screens visible

New TwoPaneLayoutNav constructor

TwoPaneLayout now offers the TwoPaneLayoutNav constructor, which is meant to be used in scenarios with complex navigation. We originally started looking into this idea after receiving some feedback on GitHub, and we eventually decided that a new constructor would be the best solution:

@Composable
fun TwoPaneLayoutNav(
    modifier: Modifier = Modifier,
    navController: NavHostController,
    paneMode: TwoPaneMode = TwoPaneMode.TwoPane,
    destinations: Array<Destination>,
    singlePaneStartDestination: String,
    pane1StartDestination: String,
    pane2StartDestination: String
)

As opposed to the original TwoPaneLayout constructor, TwoPaneLayoutNav can be used for apps that require more than two screens of content. Instead of passing in fixed composables for panes 1 and 2, you can now pass in an array of multiple app destinations via the destinations parameter. To control which destination is shown in each pane, you can then use internal TwoPaneLayoutNav navigation methods.

If we look inside the implementation of TwoPaneLayoutNav, we can see that the code is very similar to TwoPaneLayout when only one pane is shown. We still use a NavHost in the SinglePaneContainer, but now it contains multiple destinations instead of only two.

@Composable
internal fun SinglePaneContainer(
    navController: NavHostController,
    pane1: @Composable TwoPaneScope.() -> Unit,
    pane2: @Composable TwoPaneScope.() -> Unit
) {
    ...
    NavHost(
        navController = navController,
        startDestination = currentSinglePane
    ) {
        composable(Screen.Pane1.route) {
          TwoPaneScopeInstance.pane1()
        }
        composable(Screen.Pane2.route) {
          TwoPaneScopeInstance.pane2()
        }
    }
}
@Composable
internal fun SinglePaneContainer(
    destinations: Array<Destination>,
    startDestination: String,
    navController: NavHostController,
) {
...
  NavHost(
        navController = navController,
        startDestination = startDestination
    ) {
        destinations.forEach { pane ->
          composable(pane.route) {
              TwoPaneNavScopeInstance.(pane.content)()
            }
      }
  }
}

The main difference in implementation is within the TwoPaneContainer, because now we have to keep track of the routes for the current pane 1 and 2 destinations. These routes are used to access the content that will be shown in each pane of the layout.

@Composable
internal fun TwoPaneContainer(
    windowState: WindowState,
    modifier: Modifier,
    pane1: @Composable TwoPaneScope.() -> Unit,
    pane2: @Composable TwoPaneScope.() -> Unit
) {
    ...    
    Layout(
        content = {
          TwoPaneScopeInstance.pane1()
              TwoPaneScopeInstance.pane2()
          },
          measurePolicy = measurePolicy,
          modifier = modifier
    )
}
@Composable
internal fun TwoPaneContainer(
    windowState: WindowState,
    modifier: Modifier,
    destinations: Array<Destination>,
    pane1StartDestination: String,
    pane2StartDestination: String
) {
  ...
  var currentPane1 by rememberSaveable { mutableStateOf(pane1StartDestination) }
  var currentPane2 by rememberSaveable { mutableStateOf(pane2StartDestination) }
  // Find the destinations to display in each pane
  val pane1 = findDestination(currentPane1, destinations).content
  val pane2 = findDestination(currentPane2, destinations).content
  Layout(
        content = {
          TwoPaneNavScopeInstance.pane1()
          TwoPaneNavScopeInstance.pane2()
        },
        measurePolicy = measurePolicy,
        modifier = modifier
    )
}

To see an example of TwoPaneLayoutNav in action, you can check out our updated NavigationRail sample. If you read the blog post announcing the release of the sample, you may remember how complicated the original navigation hierarchy was; we had to use a combination of internal TwoPaneLayout navigation and our own NavHost/navController combo to achieve the desired app behavior. Now, after switching to TwoPaneLayoutNav, the navigation setup for the app is much simpler because we can achieve all of the same navigation patterns with just one shared NavHost/navController pair.

Updated scopes and testing support

The new TwoPaneLayout version also includes updates to TwoPaneScope and the addition of TwoPaneNavScope. In addition, we’ve added new test scope instances to help with handling the API changes in UI tests.

TwoPaneScope

Previously, TwoPaneScope was only used to provide access to the weight modifier attribute, while the navigateToPane1 and navigateToPane2 methods were accessible anywhere. Now, to ensure that certain methods and fields can only be accessed from within the proper scope, we’ve updated the TwoPaneScope interface to the following:

interface TwoPaneScope {
    fun Modifier.weight(weight: Float): Modifier
    fun navigateToPane1()
    fun navigateToPane2()
    val currentSinglePaneDestination: String
    val isSinglePane: Boolean
}

So for instance, let’s say your app code looked something like this:

@Composable
fun ExampleApp() {
    TwoPaneLayout(
        pane1 = { Pane1() },
        pane2 = { Pane2() }
  )
}
@Composable
fun Pane1() {
    Text(
        modifier = Modifier.clickable(
            onClick = { navigateToPane2() }
      ),
      text = "Pane 1!"
    )
}

With the new TwoPaneLayout version, navigateToPane1 and navigateToPane2 can now only be called within TwoPaneScope, so you would need to update your code to this:

@Composable
fun ExampleApp() {
    TwoPaneLayout(
        pane1 = { Pane1() },
        pane2 = { Pane2() }
    )
}
@Composable
fun TwoPaneScope.Pane1() {
    Text(
        modifier = Modifier.clickable(
            onClick = { navigateToPane2() }
        ),
        text = "Pane 1!"
    )
}

Regardless of the API changes, the navigation functionality remains the same, as shown in this animation:

Animation of TwoPaneLayout on a single screen device, where navigation between pane 1 and pane 2 is shown

Figure 1. Animation showing navigateToPane1 and navigateToPane2 on a single screen device

There also are additional fields available for use in your apps:

  • The currentSinglePaneDestination field reports the route of the current pane by returning either Screen.Pane1.route (“pane1”) or Screen.Pane2.route (“pane2”). It’s important to note that this value makes sense only when one pane is currently displayed.
  • The isSinglePane field returns true when TwoPaneLayout is only showing one pane, otherwise it returns false. For those of you familiar with our WindowState library, this may seem similar to isDualScreen, but the difference here is that isSinglePane also factors in the selected TwoPaneMode for the layout. This is useful in cases where you want to conditionally show content based on the number of panes, such as actions in a top bar.

TwoPaneNavScope

To help you use the new TwoPaneLayoutNav constructor, we’ve also created a new interface called TwoPaneNavScope:

interface TwoPaneNavScope {
    fun Modifier.weight(weight: Float): Modifier
    fun NavHostController.navigateTo(
        route: String,
        screen: Screen,
        navOptions: NavOptionsBuilder.() -> Unit = { },
    )
    val currentSinglePaneDestination: String
    val currentPane1Destination: String
    val currentPane2Destination: String
    val isSinglePane: Boolean
}

Since the TwoPaneLayoutNav constructor doesn’t limit you to only two destinations, navigateToPane1 and navigateToPane2 don’t make sense for internal navigation support anymore. Instead, we’ve provided a navigateTo method with the following parameters:

  • route – route of the destination you want to navigate to
  • screen – which screen or pane the destination should be shown in when two panes are displayed (possible values: Screen.Pane1 or Screen.Pane2)
  • navOptions – optional navigation options that will be used by the navController when one pane is displayed (can be used to implement different patterns, such as circular navigation)

For example, this animation shows how our TwoPaneLayoutNav sample uses navigateTo to set up a navigation pattern that works well regardless of how many panes are displayed:

Animation of TwoPaneLayout sample on Surface Duo
Figure 2. Animation showing TwoPaneLayoutNav sample behavior in one and two panes.

Like the updated TwoPaneScope, there are also fields that let you access the number of panes displayed (isSinglePane) and which destinations are currently displayed (currentSinglePaneDestination, currentPane1Destination, and currentPane2Destination).

Test scopes instances

Due to the changes described above, some composables may now require TwoPaneScope or TwoPaneNavScope to be explicitly specified as receivers. When writing your application code, this is not an issue because TwoPaneLayout provides an internal implementation of the necessary scope. However, when writing UI tests to test composables in isolation, you may find yourself trying to invoke a composable outside of TwoPaneLayout.

For instance, in the TwoPaneLayout sample, the top bar composable requires TwoPaneScope to decide what text to show:

@Composable
  fun TwoPaneScope.TopBar(pane: Int) {
      // Customize top bar text depending on the pane
      val paneString = if (!isSinglePane) " " + stringResource(pane) else ""
      TopAppBar(
          title = {
            Text(
                  text = stringResource(R.string.app_name) + paneString,
                  color = Color.White
              )
          },
          backgroundColor = blue
    )
  }

When trying to write a UI test just for this composable, though, Android Studio will show you this error:

Screenshot of UI test for top bar in Android Studio, with the following error: "Unresolved reference. None of the following candidates is applicable because of receiver type mismatch: public fun TwoPaneScope.TopBar(pane: Int): Unit defined in com.microsoft.device.dualscreen.twopanelayout in file MainActivity.kt

To solve this problem, we’ve added empty implementations of both scopes: TwoPaneScopeTest and TwoPaneNavScopeTest. These both allow you to manually set the value of scope fields in the constructor so you can set up your composables with the proper state before running your UI tests.

For instance, this is the top bar test updated with TwoPaneScopeTest:

@Test
fun topBar_singlePane_showsCorrectPaneString() {
    composeTestRule.setContent {
        val twoPaneScopeTest = TwoPaneScopeTest(isSinglePane = true)
        twoPaneScopeTest.TopBar(pane = R.string.pane1)
    }
    composeTestRule.onNodeWithText(
          composeTestRule.getString(R.string.app_name) + " " + composeTestRule.getString(R.string.pane1)
      ).assertDoesNotExist()
      composeTestRule.onNodeWithText(composeTestRule.getString(R.string.app_name)).assertIsDisplayed()
}

Get started with version migration

By this point in the blog post, I hope you’re convinced that the new TwoPaneLayout version is worth a try 😊 If you were already using TwoPaneLayout before and want to migrate to the newer version, follow these steps:

  1. Update version number

    The first and most obvious step is to upgrade your TwoPaneLayout version! The latest version with the new API is 1.0.1-alpha02, so your import statement would be as follows:

    implementation "com.microsoft.device.dualscreen:twopanelayout:1.0.1-alpha02"
  2. Add scope to composables

    The next step is to update any composables that use TwoPaneLayout navigation methods to have TwoPaneScope as a receiver. Since “navigateToPane1” and “navigateToPane2” are no longer accessible from anywhere, Android Studio won’t recognize the methods until the proper receiver is added.

  3. Use test scope instances in UI tests

    Once you’ve updated your composables to use TwoPaneScope as a receiver, it’s important to double check existing UI tests in your project. Some may need to be updated to use test scope instances in order to build successfully.

  4. Optional: replace posture checks with isSinglePane

    Instead of choosing layout options based on a combination of device and application logic, you can simplify your project code by switching to the isSinglePane field. For instance, if you were previously checking for the dual portrait posture when using the HorizontalSingle pane mode, you can now just check isSinglePane. If your project also uses WindowState, try searching for usages of isDualScreen, isDualPortrait, or isDualLandscape to see if you can simplify your code.

Resources and feedback

The code for TwoPaneLayout is available on GitHub.

If you have any questions, or would like to tell us about your apps, use the feedback forum or message us on Twitter @surfaceduodev.

Finally, please join us for our dual-screen developer livestream at 11am (Pacific time) each Friday – mark it in your calendar and check out the archives on YouTube.

1 comment

Discussion is closed. Login to edit/delete existing comments.

  • vexer13 0

    lovely!

Feedback usabilla icon