Skip to content

tinytinycn/learn-compose-navigation

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

6 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

compose navigation

Decompose 文档


以下文档是 Compose Multiplatform 教程的一部分 navigation

Navigation 导航

General attitude 一般态度

Jetpack Compose 导航库 (navigation-compose) 是一个仅限 Android 的库,因此不能与 Compose for Desktop 一起使用。我们的一般态度不是“强迫”人们使用特定的第一方库。但是有可用的第三方库。可以考虑 Decompose 作为可能的解决方案。

Patterns 模式

导航 Navigation 不仅仅是关于切换子组件 components 和管理后台堆栈 back stack 。它还可能影响应用程序的架构。

Compose 中有两种常见的导航模式:导航逻辑可以在 @Composable 世界内部或外部进行保存和管理。每种方法都有其优点和缺点,所以请明智地决定。

本教程描述了这两种模式,如何在它们之间进行选择,以及 Decompose 库如何提供帮助。

一. Prerequisites 先决条件

本教程使用一个非常简单的 List-Details 应用程序示例,它只有两个屏幕:ItemList 和 ItemDetails。我们需要首先做的事情很少。

1 安装

首先让我们将 Decompose 库添加到项目中。请参阅文档的 入门 部分。

2 Item 模型 和 数据库

这是我们需要的 Item 数据类:

data class Item(val id: Long, val text: String)

还有一个简单的数据库接口,将被子屏幕 child screens 使用(为了简单起见,没有并发):

interface Database { fun getAll(): List<Item> fun getById(id: Long): Item }

3 child screens 的基础 UI

我们将需要一些用于列表 List 和详细信息 Details 屏幕的基本 UI。

ItemListScreen`` @Composable 组件显示 Items 列表并在单击 item 时调用 onItemClick 回调:

import androidx.compose.foundation.clickable import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @Composable fun ItemListScreen(items: List<Item>, onItemClick: (id: Long) -> Unit) { LazyColumn { items(items = items) { item -> Text( text = item.text, modifier = Modifier.clickable { onItemClick(item.id) } ) } } }

ItemDetailsScreen @Composable 组件显示之前选中的 Item 并在点击 TopAppBar 中的返回按钮时调用 onBackClick 回调:

import androidx.compose.foundation.layout.Column import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.Text import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.runtime.Composable @Composable fun ItemDetailsScreen(item: Item, onBackClick: () -> Unit) { Column { TopAppBar( title = { Text("Item details") }, navigationIcon = { IconButton(onClick = onBackClick) { Icon( imageVector = Icons.Default.ArrowBack, contentDescription = null ) } } ) Text(text = item.text) } }

4 Children configuration 子配置

Decompose 库的主要目标之一是编译时安全。每个 child 都由一个名为 Configuration 的类来描述。 配置的目的是描述应该使用哪些 child 以及它有什么参数。因此,对于每个 child 来说,都有一个自己的 Configuration 类的实例。 通常导航涉及多个子级,因此,整套配置通常是一个密封类。

例如,对于一个简单的 List-Details 导航,我们只需要两个条目:

import com.arkivanov.decompose.statekeeper.Parcelable sealed class Configuration : Parcelable { object List : Configuration() data class Details(val itemId: Long) : Configuration() }

这种方法看起来有点冗长,但它在以下情况下带来了编译时的安全性:

  • Child 参数在编译时进行验证(与通过字符串、Bundles 等传递参数不同)。
  • 可以详尽地检查配置,因此如果没有涵盖所有子项,则编译将失败。

4.1 Android 中的 Parcelable 配置

Desktop Compose 实际上是一个多平台库,也可以在 Android 中使用。这也使得共享导航逻辑成为可能。但 Android 对导航有额外的要求 - 后台堆栈应该能够在 配置更改 后继续存在。 一般来说,当此类事件发生时,应该保存和恢复后退堆栈。

为了使这成为可能,所有子配置都必须是 Parcelable 。为方便起见,Decompose 使用 expect/actual 定义 Parcelable@Parcelize

  • Parcelable - 此接口由 commonMain 源集中的 Decompose 定义。它是针对 Android target 的 Android Parcelable 接口类型化的,在所有其他目标(包括 JVM/Desktop)中只是一个空接口。
  • @Parcelize - 此注释也在 commonMain 源集中定义。它被类型化为 kotlin-parcelize插件提供的 @Parcelize 注解。并且在 non-Android target 中缺少(因为不需要)。

如果您需要 Android 支持,请确保您已启用 kotlin-parcelize 插件。所有配置应如下所示:

import com.arkivanov.decompose.statekeeper.Parcelable import com.arkivanov.decompose.statekeeper.Parcelize sealed class Configuration : Parcelable { @Parcelize object List : Configuration() @Parcelize data class Details(val itemId: Long) : Configuration() }

二. 在 @Composable 世界之外管理导航

如果以下任何一项适用,则应选择此模式:

  1. 您支持具有不同 UI 框架的 Multipaltform targets,并且您希望在它们之间共享导航逻辑。例如,如果您支持带有 Compose UI 的桌面、带有 SwiftUI 的 iOS 和/或带有 React UI 的 JavaScript。
  2. 您想让 children 在后堆栈中运行(停止,但未销毁)。
  3. 您的目标是 Android 并且需要在 children(又名 AndroidX ViewModels)中保留实例功能,并且您希望将此逻辑隐藏为实现细节。
  4. 您希望将导航逻辑(可能还有业务逻辑)与 UI 分开。

第一点很明显。如果 Compose 不是您使用的唯一 UI,并且您希望共享导航逻辑,则 Compose 无法对其进行管理。

第二点在 Desktop 中可能特别有用。当一个 children 被 push 推到后堆栈中,它会停止但不会被销毁。所以它在没有 UI 的情况下一直在“后台”运行。这使得在导航时将 children 的状态保存在内存中成为可能。

第三点是关于实例保留的,比如AndroidX ViewModels,主要用于Android。它允许在发生 Android 配置更改并重新创建整个导航堆栈时保留(保留在内存中)一些数据。在这种模式中,实例保留最重要的优点是它被封装在 children 中作为实现细节。

第四点不是那么明显,但可能非常重要。将导航和业务逻辑与用户界面分离可以提高可测试性。例如。可以通过简单的 JUnit 测试来测试非 UI 代码。 UI 也可以使用其他测试框架单独测试。

您可以在 TodoApp 示例中找到一些集成测试:

Decompose 库鼓励这种模式。如果这是您的选择,那么您可以使用其推荐的方法。

主要思想是通过多个组件拆分(分解)您的项目。组件可以以树状结构组织,每个级别可以(但不是必须)有多个 路由器 。每个组件只是一个普通的接口/类,是底层逻辑的入口点。

用户界面的 唯一职责 是监听组件的状态变化并触发它们的事件。

以下资源可以帮助解决此模式:

文章 "Fully cross-platform Kotlin applications (almost)"

一个非常基本的例子:

ItemList child 的 UI:

import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf class ItemList( database: Database, // Accept the Database as dependency val onItemSelected: (itemId: Long) -> Unit // Called on item click ) { // No concurrency involved just for simplicity. The state can be updated if needed. private val _state = mutableStateOf(database.getAll()) val state: State<List<Item>> = _state } @Composable fun ItemListUi(list: ItemList) { ItemListScreen( items = list.state.value, onItemClick = list.onItemSelected ) }

ItemDetails child 的 UI:

import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf class ItemDetails( itemId: Long, // An item id to be loaded and displayed database: Database, // Accept the Database as dependency val onFinished: () -> Unit // Called on TopAppBar back button click ) { // No concurrency involved just for simplicity. The state can be updated if needed. private val _state = mutableStateOf(database.getById(id = itemId)) val state: State<Item> = _state } @Composable fun ItemDetailsUi(details: ItemDetails) { ItemDetailsScreen( item = details.state.value, onBackClick = details.onFinished ) }

带导航的 Root(假设仅使用 Compose UI):

import androidx.compose.runtime.Composable import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.extensions.compose.jetbrains.Children import com.arkivanov.decompose.pop import com.arkivanov.decompose.push import com.arkivanov.decompose.router typealias Content = @Composable () -> Unit fun <T : Any> T.asContent(content: @Composable (T) -> Unit): Content = { content(this) } class Root( componentContext: ComponentContext, // In Decompose each component has its own ComponentContext private val database: Database // Accept the Database as dependency ) : ComponentContext by componentContext { private val router = router<Configuration, Content>( initialConfiguration = Configuration.List, // Starting with List childFactory = ::createChild // The Router calls this function, providing the child Configuration and ComponentContext  ) val routerState = router.state private fun createChild(configuration: Configuration, context: ComponentContext): Content = when (configuration) { is Configuration.List -> list() is Configuration.Details -> details(configuration) } // Configurations are handled exhaustively private fun list(): Content = ItemList( database = database, // Supply dependencies onItemSelected = { router.push(Configuration.Details(itemId = it)) } // Push Details on item click ).asContent { ItemListUi(it) } private fun details(configuration: Configuration.Details): Content = ItemDetails( itemId = configuration.itemId, // Safely pass arguments database = database, // Supply dependencies onFinished = router::pop // Go back to List ).asContent { ItemDetailsUi(it) } } @Composable fun RootUi(root: Root) { Children(root.routerState) { child -> child.instance() } }

Application 和 Root 的初始化

import androidx.compose.desktop.DesktopTheme import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.window.singleWindowApplication import com.arkivanov.decompose.extensions.compose.jetbrains.rememberRootComponent fun main() = singleWindowApplication( title = "Navigation tutorial" ) { Surface(modifier = Modifier.fillMaxSize()) { MaterialTheme { DesktopTheme { RootUi(root()) // Render the Root and its children } } } } @Composable private fun root(): Root = // The rememberRootComponent function provides the root ComponentContext and remembers the instance or Root rememberRootComponent { componentContext -> Root( componentContext = componentContext, database = DatabaseImpl() // Supply dependencies ) }

二. 在 @Composable 世界之内管理导航

通过使用这种模式,导航逻辑在 @Composable 函数中得以保存和管理。例如,Jetpack Compose navigation-compose 库使用此模式。 在实践中, 通常有一个像 @Composable fun Navigator(...)@Composable fun NavHost(...) 这样的函数来管理后台堆栈 back stack 并呈现当前活动的 child 。函数如何呈现子元素的方式取决于它的 API。

如果您更喜欢使用 Compose (不仅仅用于 UI 之外),则应选择此模式,并且第一个模式的要点都不适用。

Decompose 没有提供任何开箱即用的 @Composable 导航 API。但是用它来编写你自己的很容易。您可以试验并提出自己的 API。

实现细节请参考以下文章:"A comprehensive hundred-line navigation for Jetpack/Desktop Compose " 。它还解释了一些附加功能,如后退按钮处理、过渡动画等。

一个非常基本的例子:

import androidx.compose.runtime.Composable import com.arkivanov.decompose.Router import com.arkivanov.decompose.statekeeper.Parcelable @Composable inline fun <reified C : Parcelable> rememberRouter( noinline initialConfiguration: () -> C ): Router<C, Any> = TODO("See the article mentioned above for the implementation")

首先,我们需要 Decompose 库中的路由器。一旦我们有了它,我们需要做的就是使用 Children 函数。 Children 函数侦听 Router 状态变化,并使用提供的回调呈现当前活动的 child 。上面提到的文章解释了实现细节。

使用 Router:

import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import com.arkivanov.composenavigatorexample.navigator.rememberRouter import com.arkivanov.decompose.extensions.compose.jetbrains.Children import com.arkivanov.decompose.pop import com.arkivanov.decompose.push @Composable fun ItemList( database: Database, onItemClick: (itemId: Long) -> Unit ) { // No concurrency involved just for simplicity. The state can be updated if needed. val items = remember { mutableStateOf(database.getAll()) } ItemListScreen( items = items.value, onItemClick = onItemClick ) } @Composable fun ItemDetails( itemId: Long, database: Database, onBackClick: () -> Unit ) { // No concurrency involved just for simplicity. The state can be updated if needed. val item = remember { mutableStateOf(database.getById(id = itemId)) } ItemDetailsScreen( item = item.value, onBackClick = onBackClick ) } @Composable fun Root(database: Database) { // Create and remember the Router val router = rememberRouter<Configuration>( initialConfiguration = { Configuration.List } // Start with the List screen ) // Render children Children(routerState = router.state) { screen -> when (val configuration = screen.configuration) { is Configuration.List -> ItemList( database = database, // Supply dependencies onItemClick = { router.push(Configuration.Details(itemId = it)) } // Push Details on item click ) is Configuration.Details -> ItemDetails( itemId = configuration.itemId, // Safely pass arguments database = database, // Supply dependencies onBackClick = router::pop // Go back to List ) }.let {} // Ensure exhaustiveness } }

About

学习使用compose、decompose进行组件之间的导航功能

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages