为自适应界面实现导航

导航是指支持用户导航、进入和退出应用中不同内容片段的互动。Compose 中的自适应界面不会从根本上改变导航过程,因此您仍然应该遵守所有导航原则。借助 Navigation 组件,您可以轻松应用推荐的模式,并且可以在具有高度自适应布局的应用中继续使用它。

除了上述原则外,还需注意一些其他事项,以便在使用自适应布局的应用中实现良好的用户体验。正如构建自适应布局指南中所述,界面的结构可能取决于可供应用使用的空间。这些额外的导航原则全都考虑了当可供应用使用的屏幕空间发生变化时会发生什么。

自适应导航界面

为了尽可能向用户提供最佳导航体验,您应提供的导航界面应针对可供应用使用的空间量身定制。根据可用的屏幕空间和应用的独特风格,您可能希望使用底部应用栏、始终显示或可收起的抽屉式导航栏侧边栏,又或许是一些全新的元素。

由于这些组件会占据屏幕的整个宽度或高度,因此确定应使用哪个组件的逻辑是屏幕级布局决策。因此,我们建议您使用窗口尺寸类来确定要显示的导航界面类型。窗口尺寸类是一种断点,旨在于简单性和灵活性之间实现平衡,以针对大多数独特情形优化应用。

enum class WindowSizeClass { Compact, Medium, Expanded }

@Composable
fun MyApp(windowSizeClass: WindowSizeClass) {
    // Perform logic on the size class to decide whether to show
    // the nav rail
    val showNavRail = windowSizeClass != WindowSizeClass.Compact
    MyScreen(
        showNavRail = showNavRail,
        /* ... */
    )
}

完全自适应目的地

Chrome 操作系统上的多窗口模式、可折叠设备和自由式窗口都可能导致可供应用使用的空间比以往发生更大变化。

为了在导航主机中为用户提供无缝体验,请使用一个导航图,其中每个目的地都是自适应的。此方法强调了自适应界面的主要原则:灵活性和连续性。如果每个目的地都妥善处理大小调整事件,相应更改将仅与界面相关,应用状态的其余部分(包括导航)将会得到保留,从而有助于实现连续性。

使用并行导航图时,每当应用转换为另一个尺寸类时,您都必须在另一个图中确定用户的当前目的地,重新构造返回堆栈,并调整在各图中有所不同的其他状态信息。这种方法很复杂,而且容易出错。

在特定目的地,您可以通过多种方式构建自适应布局。您可以调整间距、使用备用布局、添加其他信息列以使用更多空间,或显示在较小空间容纳不下的附加详细信息。如需详细了解可用于实现这些更改的工具,请参阅构建自适应布局

为了提供更好的用户体验,您可以向支持大屏幕规范布局(例如列表/详情视图)的特定目的地添加更多内容。下文探讨了此类设计的导航注意事项。

区分路线和屏幕

导航组件允许您定义路线,每个路线对应于一个目的地。导航会使当前显示的目的地发生变化,同时跟踪返回堆栈,即用户之前所在的目的地列表。

在特定目的地,您可以展示任何所需内容。对于处理应用主导航的 NavHost,您通常在每个目的地显示不同的屏幕,这会占用可供您的应用使用的整个空间。

通常,每个目的地都负责显示一个屏幕,每个屏幕仅在一个目的地显示。不过,这不是硬性要求。实际上,根据您的应用的可用屏幕尺寸,在要显示的多个屏幕之间选择一个目的地会很有帮助。

我们来看看 JetNews,它是一个 Compose 官方示例。该应用的主要功能是显示文章,用户可以从列表中进行选择。当该应用有足够的空间时,它可以同时显示列表和文章。此接口是一个列表/详情布局,是一种 Material Design 规范布局

JetNews 中的“列表”、“详情”和“列表 + 详细信息”屏幕

虽然这 3 个屏幕在视觉上是不同的,但该应用会在同一个 "home" 路线下显示所有这三个屏幕。

在代码中,目的地会调用 HomeRoute

@Composable
fun JetnewsNavGraph(
    navController: NavHostController,
    isExpandedScreen: Boolean,
    // ...
) {
    // ...
    NavHost(
        navController = navController,
        startDestination = JetnewsDestinations.HomeRoute
    ) {
        composable(JetnewsDestinations.HomeRoute) {
            // ...
            HomeRoute(
                isExpandedScreen = isExpandedScreen,
                // ...
            )
        }
        // ...
    }
}

然后,HomeRoute 代码会决定要显示三个屏幕中的哪一个,每个可组合项都以 Screen 作为后缀。应用会根据 HomeViewModel 中存储的应用状态以及描述当前可用空间的窗口尺寸类来做出此决定。

@Composable
fun HomeRoute(
    // if the window size class is expanded
    isExpandedScreen: Boolean,
    // if the user is focused on the selected article
    isArticleOpen: Boolean,
    // ...
) {
    // ...
    if (isExpandedScreen) {
        HomeListWithArticleDetailsScreen(/* ... */)
    } else {
        // if we don't have room for both the list and article details,
        // show one of them based on the user's focus
        if (isArticleOpen) {
            ArticleScreen(/* ... */)
        } else {
            HomeListScreen(/* ... */)
        }
    }
}

采用这种方法时,该应用会明确地将以下两种导航操作区分开:一种是会将整个 HomeRoute 替换成另一目的地(通过对 NavController) 调用 navigate())的导航操作,另一种是仅会影响此目的地中的内容(例如从列表中选择文章)的导航操作。我们建议您通过更新适用于所有窗口大小的共享状态来处理这些事件,即使列表和文章屏幕之间的转换在用户看来好像是导航操作(如果该应用仅显示一个窗格)。

因此,当我们点按列表中的某篇文章时,布尔标记 isArticleOpen: 会更新

class HomeViewModel(/* ... */) {
    fun selectArticle(articleId: String) {
        viewModelState.update {
            it.copy(
                isArticleOpen = true,
                selectedArticleId = articleId
            )
        }
    }
}

@Composable
fun HomeRoute(
    isExpandedScreen: Boolean,
    isArticleOpen: Boolean,
    selectedArticleId: String,
    onSelectArticle: (String) -> Unit,
    // ...
) {
    // ...
    if (isExpandedScreen) {
        HomeListWithArticleDetailsScreen(
            selectedArticleId = selectedArticleId,
            onSelectArticle = onSelectArticle,
            // ...
        )
    } else {
        // if we don't have room for both the list and article details,
        // show one of them based on the user's focus
        if (isArticleOpen) {
            ArticleScreen(
                selectedArticleId = selectedArticleId,
                // ...
            )
        } else {
            HomeListScreen(
                onSelectArticle = onSelectArticle,
                // ...
            )
        }
    }
}

同样,我们仅在显示文章屏幕时安装自定义 BackHandler,这会将 isArticleOpen 设置回 false。

class HomeViewModel(/* ... */) {
    fun onArticleBackPress() {
        viewModelState.update {
            it.copy(isArticleOpen = false)
        }
    }
}

@Composable
fun HomeRoute(
    isExpandedScreen: Boolean,
    isArticleOpen: Boolean,
    selectedArticleId: String,
    onSelectArticle: (String) -> Unit,
    onArticleBackPress: () -> Unit,
    // ...
) {
    // ...
    if (isExpandedScreen) {
        HomeListWithArticleDetailsScreen(/* ... */)
    } else {
        // if we don't have room for both the list and article details,
        // show one of them based on the user's focus
        if (isArticleOpen) {
            ArticleScreen(
                selectedArticleId = selectedArticleId,
                onUpPressed = onArticleBackPress,
                // ...
            )
            BackHandler {
                onArticleBackPress()
            }
        } else {
            HomeListScreen(/* ... */)
        }
    }
}

此分层汇总了设计 Compose 应用时的许多重要概念。通过让屏幕可重复使用并允许提升其重要状态,您可以轻松更换整个屏幕。通过将 ViewModel 中的应用状态与可用大小信息相结合,确定要显示哪个屏幕由一小段简单的逻辑控制。最后,通过保留单向数据流,自适应界面将始终利用可用空间,同时保留用户的状态。

如需了解完整实现,请查看 GitHub 上的 JetNews 示例。

保留用户的状态

对于自适应界面,最重要的考虑因素是在设备旋转或折叠或调整应用窗口的大小时保留用户的状态。具体而言,所有这些大小调整操作都应该可撤消

例如,假设用户正在查看应用中的某个屏幕,然后旋转设备。如果用户撤消该旋转操作(即,将设备旋转回初始模式),他们应该返回到和开始时完全相同的屏幕,并保留其所有状态。如果用户在滚动浏览完一段内容的一半后旋转设备,那么在将设备旋转回初始模式后,他们应该返回到相同的滚动位置。

旋转后保存列表滚动位置

屏幕方向更改和窗口大小调整会使配置发生更改,默认情况下,这会重新创建 Activity 和可组合项。您可以使用 rememberSaveableViewModel 保存发生配置更改时的状态(如需了解详情,请参阅状态和 Jetpack Compose)。如果您不使用这类工具,用户的状态将会丢失。

自适应布局往往具有其他状态,因为它们可能会以不同的屏幕尺寸显示不同的内容片段。因此,为此类其他内容片段保存用户的状态也很重要,即使对于不再可见的组件也是如此。

假设一些滚动内容仅在较大的宽度下显示。如果旋转会使宽度过小,导致无法显示滚动内容,那么滚动内容会被隐藏。当用户将设备旋转回原始模式时,滚动内容将再次变得可见,并且应恢复原始滚动位置。

旋转时保存详情滚动位置

在 Compose 中,您可以通过状态提升来实现此目的。通过提升可组合项在组合树中的状态,可以保留其状态,即使它们不再可见。

在 JetNews 中,我们将状态提升为 HomeRoute,以便在更改可见的屏幕时保留并重复使用该状态:

@Composable
fun HomeRoute(
    // if the window size class is expanded
    isExpandedScreen: Boolean,
    // if the user is focused on the selected article
    isArticleOpen: Boolean,
    selectedArticleId: String,
    // ...
) {
    val homeListState = rememberHomeListState()
    val articleState = rememberSaveable(
        selectedArticleId,
        saver = ArticleState.Saver
    ) {
        ArticleState()
    }

    if (isExpandedScreen) {
        HomeListWithArticleDetailsScreen(
            homeListState = homeListState,
            articleState = articleState,
            // ...
        )
    } else {
        // if we don't have room for both the list and article details,
        // show one of them based on the user's focus
        if (isArticleOpen) {
            ArticleScreen(
                articleState = articleState,
                // ...
            )
        } else {
            HomeListScreen(
                homeListState = homeListState,
                // ...
            )
        }
    }
}

避免在导航时改变屏幕尺寸

如果您向应用中添加一个屏幕,以便利用更大的显示屏能够提供的额外空间,那么您可能很想针对新设计的布局向应用添加新目的地。

但是,假设用户在可折叠设备的内部屏幕上查看了这个新布局。如果用户折叠设备,外部屏幕上可能没有足够空间显示新布局。这便要求在新的屏幕尺寸太小时导航到其他位置。这会带来一些问题:

  • 在导航时进行组合可能会使旧目的地暂时可见,因为它需要在导航发生之前显示
  • 为了保持可撤消,我们还需要在展开时返回
  • 在这些更改之间保持用户状态将会非常困难,因为导航可能会在弹出返回堆栈时擦除旧状态

需要额外考虑的是,当发生这些更改时,您的应用甚至可能未在前台运行。您的应用可能展示了一个需要更多空间的布局,然后用户将该应用置于后台。如果用户稍后回到该应用,那么自应用上次恢复以来,屏幕方向、尺寸和物理屏幕都可能已更改。

如果您发现自己只想针对特定屏幕尺寸显示某些目的地,请考虑将相关的目的地合并到一条路线中,并在该路线下显示不同的屏幕(如上文所述)。