适用于可折叠设备的 TwoPaneLayout Jetpack Compose 组件

重要

本文介绍的功能和指南为公共预览版,在正式发布之前可能会有重大修改。 Microsoft 不对此处提供的信息作任何明示或默示的担保。

TwoPaneLayout 是 Jetpack Compose 组件,可帮助你创建用于双屏设备、可折叠设备和大屏设备的 UI。 TwoPaneLayout 提供包含两个窗格的布局,用于 UI 的顶层。 当应用在双屏设备、可折叠设备和大屏设备上跨屏显示时,组件将并排放置两个窗格,否则只会显示一个窗格。 这些窗格可以水平放置,也可以垂直放置,具体取决于设备的方向和所选的 paneMode

注意

宽度窗口大小类展开时,TwoPaneLayout 将设备视为大屏幕设备,这意味着大于 840 dp。

当应用跨分隔的垂直铰链或折叠线显示时,或当宽度大于大屏设备的屏幕高度时,窗格 1 将置于左侧,而窗格 2 置于右侧。 如果设备旋转、应用跨分隔的水平铰链或折叠线显示或宽度小于大屏设备的屏幕高度,窗格 1 将置于顶部,而窗格 2 置于底部。

添加依赖项

  1. 确保顶级 build.gradle 文件中有 mavenCentral() 存储库:

    allprojects {
        repositories {
            google()
            mavenCentral()
         }
    }
    
  2. 将依赖项添加到模块级别的 build.gradle 文件中(最新版本可能与此处显示的不同):

    implementation "com.microsoft.device.dualscreen:twopanelayout:1.0.1-alpha05"
    
  3. 另请确保在compileSdkVersion模块级别 build.gradle 文件中将 设置为 API 33,并将 targetSdkVersion 设置为 API 32 或更高版本:

    android { 
        compileSdkVersion 33
    
        defaultConfig { 
            targetSdkVersion 32
        } 
        ... 
    }
    
  4. 使用 TwoPaneLayoutTwoPaneLayoutNav 生成布局。

    有关更多详细信息,请参阅 TwoPaneLayout 示例TwoPaneLayoutNav 示例

在项目中使用 TwoPaneLayout

在项目中使用 TwoPaneLayout 时,需要考虑几个重要概念:

  • TwoPaneLayout 构造函数

    根据你的应用程序,你可在项目的顶层使用三个不同的 TwoPaneLayout 构造函数:基本 TwoPaneLayout、带 navController 的TwoPaneLayout 以及 TwoPaneLayoutNav。

  • 自定义布局

    TwoPaneLayout 提供两种方法来自定义窗格的显示方式:权重和窗格模式。

  • 使用 TwoPaneLayout 导航

    TwoPaneLayout 还提供内部导航方法,这些方法可以控制每个窗格中显示的内容。 根据所使用的构造函数,你将有权访问 TwoPaneScopeTwoPaneNavScope 方法。

  • 测试 TwoPaneLayout 可组合项

    为了帮助测试使用 TwoPaneScopeTwoPaneNavScope的可组合项,TwoPaneLayout 提供了这两个范围的测试实现,以便在 UI 测试中使用。

TwoPaneLayout 构造函数

TwoPaneLayout 应始终是应用中的顶级可组合项,以便正确计算窗格大小。 有三种不同的 TwoPaneLayout 构造函数可用于不同的方案。 有关更多 API 参考信息,请查看 TwoPaneLayout README.md

基本 TwoPaneLayout

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

如果你只需要显示最多两个屏幕的内容,应使用基本 TwoPaneLayout 构造函数。 在 pane1pane2 可组合项中,可以访问 TwoPaneScope 接口提供的字段和方法。

用法示例:

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

带 navController 的 TwoPaneLayout

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

带 navController 的 TwoPaneLayout 构造函数应在以下情况下使用:

  • 只需显示最多两个屏幕的内容
  • 需要访问应用中的导航信息

pane1pane2 可组合项中,可以访问 TwoPaneScope 接口提供的字段和方法。

用法示例:

val navController = rememberNavController()

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

TwoPaneLayoutNav

@Composable
fun TwoPaneLayoutNav(
    modifier: Modifier = Modifier,
    navController: NavHostController,
    paneMode: TwoPaneMode = TwoPaneMode.TwoPane,
    singlePaneStartDestination: String,
    pane1StartDestination: String,
    pane2StartDestination: String,
    builder: NavGraphBuilder.() -> Unit
)

如果需要显示超过两个屏幕的内容,并且需要可自定义的导航支持,则应使用 TwoPaneLayoutNav 构造函数。 在每个目标可组合项中,可以访问 TwoPaneNavScope 接口提供的字段和方法。

用法示例:

val navController = rememberNavController()

TwoPaneLayoutNav(
    navController = navController,
    singlePaneStartDestination = "A",
    pane1StartDestination = "A",
    pane2StartDestination = "B"
) {
    composable("A") {
        ContentA()
    }
    composable("B") {
        ContentB()
    }
    composable("C") {
        ContentC()
    }
}

自定义布局

可通过两种方式自定义 TwoPaneLayout:

  • weight - 确定如何按比例排列两个窗格
  • paneMode - 确定何时在双屏模式下以水平和垂直方式显示一个或两个窗格

重量

TwoPaneLayout 可以根据使用 TwoPaneScope.weightTwoPaneNavScope.weight 修饰符提供的权重来分配子元素宽度或高度。

用法示例:

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

在这些不同的设备上,权重对布局的影响是不同的:

  • 大屏设备
  • 可折叠设备

大屏设备

如果未提供任何权重,则两个窗格将均匀划分。

如果提供了权重,则布局将根据权重的比率按比例划分。

例如,以下屏幕截图显示平板电脑上的 TwoPaneLayout,其权重比率为 3:7:

平板电脑/大屏幕设备上的 TwoPaneLayout,权重修饰符为 0.3 和 0.7,因此窗格按 3:7 比率划分

可折叠设备

如果存在分隔折叠线,无论是否提供权重,布局都会根据折叠线的边界进行划分。

如果该折叠线不是分隔折叠线,则设备将被视为大屏设备或单屏设备,具体取决于其大小。

例如,下图显示双屏设备上的 TwoPaneLayout 布局,该设备具有分隔折叠线:

双屏设备 (Surface Duo) 上的 TwoPaneLayout,不管权重如何,窗格都会根据折叠边界进行划分

窗格模式

窗格模式会影响何时为 TwoPaneLayout 显示两个窗格。 默认情况下,每当有 分隔折叠大窗口时,都会显示两个窗格,但在这种情况下,你可以通过更改窗格模式来选择仅显示一个窗格。

分隔折叠表示有一个 FoldingFeature 呈现,该表示为 isSeparating 属性返回 true。

大窗口是宽度为 WindowSizeClassEXPANDED高度大小类至少MEDIUM为 的窗口。

用法示例:

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

有四个可能 paneMode 的值:

  • TwoPane
  • HorizontalSingle
  • VerticalSingle
  • SinglePane

TwoPane

TwoPane 是默认窗格模式,无论方向如何,当有 一个分隔折叠大窗口时,它始终显示两个窗格

可折叠设备上的 TwoPane 窗格模式

HorizontalSingle

HorizontalSingle 当有 水平分隔折叠纵向大窗口 时,显示一个大窗格 (将顶部/底部窗格组合) 。

双屏设备上的 HorizontalSingle 窗格模式

VerticalSingle

VerticalSingle 当有 垂直分隔折叠横向大窗口 (合并左/右窗格) 时,显示一个大窗格。

可折叠设备上的 VerticalSingle 窗格模式

SinglePane

SinglePane 无论窗口功能和方向如何,始终显示一个窗格。

窗格模式行为表

总结一下,下表说明了何时为不同的窗格模式和设备配置显示一 🟩 个或两个 🟦🟦 窗格:

窗格模式 没有分隔折叠的小窗口 纵向大窗口/水平分隔折叠 横向大窗口/垂直分隔折叠
TwoPane 🟩 🟦🟦 🟦🟦
HorizontalSingle 🟩 🟩 🟦🟦
VerticalSingle 🟩 🟦🟦 🟩
SinglePane 🟩 🟩 🟩

TwoPaneLayout 提供两个带有内部导航选项的接口。 根据你使用的构造函数,你可访问不同的字段和方法。

TwoPaneScope

interface TwoPaneScope {
    ...

    fun navigateToPane1()

    fun navigateToPane2()

    val currentSinglePaneDestination: String

    ...
}

使用 TwoPaneScope,可以在单窗格模式下在窗格 1 和 2 之间导航。

展示 navigateToPane1 和 navigateToPane2 方法的实际动画

还可访问当前单窗格目标的路由,该路由将等于 Screen.Pane1.routeScreen.Pane2.route

用法示例:

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

TwoPaneNavScope

interface TwoPaneNavScope {
    ...

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

    fun NavHostController.navigateBack(): Boolean
   
    val twoPaneBackStack: MutableList<TwoPaneBackStackEntry>

    val currentSinglePaneDestination: String

    val currentPane1Destination: String

    val currentPane2Destination: String

    val isSinglePane: Boolean

    ...
}

使用 TwoPaneNavScope,可以在单窗格模式和双窗格模式下导航到不同的目标。

展示如何使用 TwoPaneLayoutNav 在单窗格和双窗格模式下在两个以上的目的地之间导航的动画。

还可访问当前目标的路由,无论是单窗格目标还是窗格 1 和窗格 2 目标。 这些值将取决于传递到 TwoPaneLayoutNav 构造函数的目标的路由。

TwoPaneLayoutNav 维护内部后退堆栈,因此在一个窗格和两个窗格之间切换时,将保存导航历史记录。 默认组件行为仅支持在单窗格模式下向后按下。 如果要将后退处理添加到两个窗格模式,或在单窗格模式下替代默认行为,请在可组合对象中创建自定义 BackHandler ,以调用 navigateBack。 这将确保正确维护内部后退堆栈。 还通过带有 twoPaneBackStack 字段的 接口公开了 backstack,因此可以根据需要访问 backstack 大小和内容。

显示 TwoPaneLayoutNav 如何在单窗格模式下维护后置并支持后退行为动画。

用法示例:

val navController = rememberNavController()

TwoPaneLayoutNav(
    navController = navController,
    singlePaneStartDestination = "A",
    pane1StartDestination = "A",
    pane2StartDestination = "B"
) {
    composable("A") {
        ContentA(Modifier.clickable { navController.navigateTo("B", Screen.Pane2) })
    }
    composable("B") {
        ContentB(Modifier.clickable { navController.navigateTo("C", Screen.Pane2) })
    }
    composable("C") {
        ContentC(Modifier.clickable { navController.navigateTo("A", Screen.Pane1) })
    }
}

测试 TwoPaneLayout 可组合项

为 TwoPaneLayout 中使用的可组合项编写 UI 测试时,可使用测试范围类来设置测试。 这些类 TwoPaneScopeTestTwoPaneNavScopeTest 为所有接口方法提供空实现,使你能够在类构造函数中设置字段值。

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

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

用法示例:

// 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()
}