TwoPaneLayout Jetpack Compose component for foldables

Important

This article describes functionality and guidance that is in public preview and may be substantially modified before it's generally available. Microsoft makes no warranties, express or implied, with respect to the information provided here.

TwoPaneLayout is a Jetpack Compose component that helps you create UI for dual-screen, foldable, and large-screen devices. TwoPaneLayout provides a two-pane layout for use at the top level of a UI. The component will place two panes side-by-side when the app is spanned on dual-screen, foldable and large-screen devices, otherwise only one pane will be shown. These panes can be horizontal or vertical, depending on the orientation of the device and the selected paneMode.

Note

TwoPaneLayout considers a device to be large-screen when the width window size class is expanded, meaning greater than 840 dp.

When the app is spanned across a separating vertical hinge or fold, or when the width is larger than the height of the screen on a large-screen device, pane 1 will be placed on the left, while pane 2 will be on the right. If the device rotates, the app is spanned across a separating horizontal hinge or fold, or the width is smaller than the height of screen on large-screen device, pane 1 will be placed on the top and pane 2 will be on the bottom.

Add dependency

  1. Make sure you have the mavenCentral() repository in your top-level build.gradle file:

    allprojects {
        repositories {
            google()
            mavenCentral()
         }
    }
    
  2. Add dependencies to the module-level build.gradle file (current version may be different from what's shown here):

    implementation "com.microsoft.device.dualscreen:twopanelayout:1.0.1-alpha02"
    
  3. Also ensure the compileSdkVersion and targetSdkVersion are set to API 31 or newer in the module-level build.gradle file:

    android { 
        compileSdkVersion 31
    
        defaultConfig { 
            targetSdkVersion 31
        } 
        ... 
    }
    
  4. Build layout with TwoPaneLayout or TwoPaneLayoutNav.

    Please refer to the TwoPaneLayout sample and TwoPaneLayoutNav sample for more details.

Use TwoPaneLayout in your project

There are several important concepts to consider when using TwoPaneLayout in your projects:

  • TwoPaneLayout constructors

    Depending on your application, there are three different TwoPaneLayout constructors you can use at the top level of your project: basic TwoPaneLayout, TwoPaneLayout with navController, and TwoPaneLayoutNav.

  • Customize your layout

    TwoPaneLayout offers two ways to customize how panes are displayed: weight and pane mode.

  • Navigate with TwoPaneLayout

    TwoPaneLayout also offers internal navigation methods that can control the content shown in each pane. Depending on which constructor is used, you will have access to either TwoPaneScope or TwoPaneNavScope methods.

  • Test TwoPaneLayout composables

    To help test composables that use TwoPaneScope or TwoPaneNavScope, TwoPaneLayout offers test implementations of both scopes for use in UI tests.

TwoPaneLayout constructors

TwoPaneLayout should always be the top-level composable in your app for pane size to be computed correctly. There are three different TwoPaneLayout constructors available for use in different scenarios. For more API reference information, check out the TwoPaneLayout README.md.

Basic TwoPaneLayout

@Composable
fun TwoPaneLayout(
    modifier: Modifier = Modifier,
    paneMode: TwoPaneMode = TwoPaneMode.TwoPane,
    pane1: @Composable TwoPaneScope.() -> Unit,
    pane2: @Composable TwoPaneScope.() -> Unit
)

The basic TwoPaneLayout constructor should be used when you only need to display up to two screens of content. Within the pane1 and pane2 composables, you can access the fields and methods provided by the TwoPaneScope interface.

Example usage:

TwoPaneLayout(
    pane1 = { Pane1Content() },
    pane2 = { Pane2Content() }
)

TwoPaneLayout with navController

@Composable
fun TwoPaneLayout(
    modifier: Modifier = Modifier,
    paneMode: TwoPaneMode = TwoPaneMode.TwoPane,
    navController: NavHostController,
    pane1: @Composable TwoPaneScope.() -> Unit,
    pane2: @Composable TwoPaneScope.() -> Unit
)

The TwoPaneLayout with navController constructor should be used when:

  • you only need to display up to two screens of content
  • you need access to navigation information in your app

Within the pane1 and pane2 composables, you can access the fields and methods provided by the TwoPaneScope interface.

Example usage:

val navController = rememberNavController()

TwoPaneLayout(
    navController = navController,
    pane1 = { Pane1Content() },
    pane2 = { Pane2Content() }
)

TwoPaneLayoutNav

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

The TwoPaneLayoutNav constructor should be used when you want to display more than two screens of content and you need customizable navigation support. Within each destination composable, you can access the fields and methods provided by the TwoPaneNavScope interface.

Example usage:

val navController = rememberNavController()
val destinations = arrayOf(
    Destination("A") { ContentA() },
    Destination("B") { ContentB() },
    Destination("C") { ContentC() }
)

TwoPaneLayoutNav(
    navController = navController,
    destinations = destinations,
    singlePaneStartDestination = "A",
    pane1StartDestination = "A",
    pane2StartDestination = "B"
)

Customize your layout

There are two ways to customize TwoPaneLayout:

  • weight - determines how to lay out the two panes proportionally
  • paneMode - determines when to show one or two panes in dual-screen mode horizontally and vertically

Weight

TwoPaneLayout is able to assign children widths or heights according to their weights provided using the TwoPaneScope.weight and TwoPaneNavScope.weight modifiers.

Example usage:

TwoPaneLayout(
    pane1 = { Pane1Content(modifier = Modifier.weight(.3f)) },
    pane2 = { Pane2Content(modifier = Modifier.weight(.7f)) }
)

Weight affects the layout differently on these different devices:

  • large screens
  • foldables

Large screens

When no weight is provided, the two panes are divided equally.

When weight is provided, the layout is split up proportionally according to the ratio of weights.

For example, this screenshot shows TwoPaneLayout on a tablet with a 3:7 weight ratio:

TwoPaneLayout on a tablet/large screen device, with weight modifiers of 0.3 and 0.7 so panes are divided in a 3:7 ratio

Foldables

When a separating fold is present, the layout is split up according to the fold's boundaries, regardless of whether or not weight was provided.

If the fold is non-separating, the device is treated as a large or single screen, depending on its size.

For instance, this image shows TwoPaneLayout layout on a dual-screen device, which has a separating fold:

TwoPaneLayout on a dual-screen device (Surface Duo), regardless of weight the panes are divided according to the fold boundaries

Pane mode

The pane mode controls when two panes are shown for TwoPaneLayout. By default, whenever there is a separating fold or a large screen, two panes will be shown, but you can choose to show only one pane in these cases by changing the pane mode.

Example usage:

TwoPaneLayout(
    paneMode = TwoPaneMode.HorizontalSingle,
    pane1 = { Pane1Content() },
    pane2 = { Pane2Content() }
)

There are three possible paneMode values:

  • TwoPane
  • HorizontalSingle
  • VerticalSingle

TwoPane

TwoPane is the default pane mode, and it always shows two panes on large screens and foldables, regardless of orientation.

TwoPane pane mode on a foldable device

HorizontalSingle

HorizontalSingle mode shows one big pane when in the horizontal orientation on large screens and foldables.

HorizontalSingle pane mode on a dual-screen device

VerticalSingle

VerticalSingle mode shows one big pane when in the vertical orientation on large screens and foldables.

VerticalSingle pane mode on a foldable device

Note

Horizontal orientation means the width of hinge/fold is larger than the height, so the panes are top/bottom; Vertical orientation means the height of hinge/fold is larger than the width, so the panes are left/right

TwoPaneLayout provides two interfaces with options for internal navigation. Depending on which constructor you use, you will have access to different fields and methods.

TwoPaneScope

interface TwoPaneScope {
    ...

    fun navigateToPane1()

    fun navigateToPane2()

    val currentSinglePaneDestination: String

    ...
}

With TwoPaneScope, you can navigate between panes 1 and 2 in single pane mode.

Animation showing navigateToPane1 and navigateToPane2 methods in action

You can also access the route of the current single pane destination, which will either equal Screen.Pane1.route or Screen.Pane2.route.

Example usage:

TwoPaneLayout(
        pane1 = { Pane1Content(modifier = Modifier.clickable { navigateToPane2() }) },
        pane2 = { Pane2Content(modifier = Modifier.clickable { navigateToPane1() }) }
)

TwoPaneNavScope

interface TwoPaneNavScope {
    ...

    fun NavHostController.navigateTo(
        route: String,
        screen: Screen,
        navOptions: NavOptionsBuilder.() -> Unit = { },
    )

    val currentSinglePaneDestination: String

    val currentPane1Destination: String

    val currentPane2Destination: String

    ...
}

With TwoPaneNavScope, you can navigate to different destinations in both single and two pane mode.

Animation showing how TwoPaneLayoutNav can be used to navigate between more than two destinations in both single and two pane mode.

You can also access the routes of the current destinations, whether that's the single pane destination or the pane 1 and pane 2 destinations. These values will depend on the routes of the destinations passed into the TwoPaneLayoutNav constructor.

Example usage:

val navController = rememberNavController()
val destinations = arrayOf(
    Destination("A") {
        ContentA(modifier = Modifier.clickable {
            navController.navigateTo("B", Screen.Pane2)
        })
    },
    Destination("B") {
        ContentB(modifier = Modifier.clickable {
            navController.navigateTo("C", Screen.Pane2)
        })
    },
    Destination("C") {
        ContentC(modifier = Modifier.clickable {
            navController.navigateTo("A", Screen.Pane1)
        })
    }
)

TwoPaneLayoutNav(
    navController = navController,
    destinations = destinations,
    singlePaneStartDestination = "A",
    pane1StartDestination = "A",
    pane2StartDestination = "B"
)

Test TwoPaneLayout composables

When writing UI tests for composables that are used within TwoPaneLayout, you can use test scope classes to set up your tests. These classes, TwoPaneScopeTest and TwoPaneNavScopeTest, provide empty implementations for all interface methods and allow you to set field values in the class constructor.

class TwoPaneScopeTest(
    currentSinglePaneDestination: String  = "",
    isSinglePane: Boolean = true
) : TwoPaneScope

class TwoPaneNavScopeTest(
    currentSinglePaneDestination: String  = "",
    currentPane1Destination: String = "",
    currentPane2Destination: String = "",
    isSinglePane: Boolean = true
) : TwoPaneNavScope

Example usage:

// Composable function in app
@Composable
fun TwoPaneScope.Example() {
    if (isSinglePane)
        Text("single pane")
    else
        Text("two pane")
}

...

// UI test in androidTest directory
@Test
fun exampleTest() {
    composeTestRule.setContent {
        val twoPaneScope = TwoPaneScopeTest(isSinglePane = true)
        twoPaneScope.Example()
    }

    composeTestRule.onNodeWithText("single pane").assertIsDisplayed()
    composeTestRule.onNodeWithText("two pane").assertDoesNotExist()
}