页面布局

在文档的这一部分,我们已经讨论了 pages(为特定路由向用户呈现的独特UI)和 the application root(所有页面的可组合入口点)。

在两者之间还有一个有用的层:页面布局。

页面的布局拥有在您网站的多个页面中共享的通用UI和结构。例如,如果您的网站有导航头部和页脚,它们将在您的布局中声明。

@Layout

布局是可组合方法,它们(可选地)接受一个 PageContext 参数和一个(必需的)可组合内容回调(即 content: @Composable () -> Unit)。您必须使用 @Layout 注解来标注它们,以便 Kobweb 可以发现和注册它们:

@Layout
@Composable
fun PageLayout(ctx: PageContext, content: @Composable () -> Unit) {
    /* ... */
    content()
}
jsMain/kotlin/com/mysite/components/layouts/PageLayout.kt
Note

您可以在任何地方、任何文件中声明布局。但是,大多数 Kobweb 用户会期望在 components.layouts 包下找到它们,所以我们建议您将自己的布局放在那里。

一旦声明,您可以通过在页面上也添加 @Layout 注解来指导页面使用布局,这次指定一个目标路径:

@Page
@Layout(".components.layouts.PageLayout")
@Composable
fun HomePage() {
    /* ... */
}
jsMain/kotlin/com/mysite/pages/Index.kt
Note

您可能已经注意到上面的代码路径以 . 为前缀(这里是 .components.layouts.PageLayout)。Kobweb 检测到这一点并自动将其解析为限定包(这里是 com.mysite.components.layouts.PageLayout)。

此时,当您访问主页(来自上面的示例)时,您的网站将按如下方式组合:

App {
    Layout {
        Page()
    }
}

当您在所有使用相同布局的页面之间导航时,布局中的许多部分和小部件甚至可能不会重新组合,您 remember 的任何值都将在页面之间保持。

如果您明确希望布局代码在新页面上发生变化,请将 ctx.route.path 值传递给任何接受键的相关 Compose 方法:

@Layout
@Composable
fun PageLayout(ctx: PageContext, content: @Composable () -> Unit) {
    LaunchedEffect(ctx.route.path) {
        // 每页重新运行此逻辑
    }

    val perPageValue = remember(ctx.route.page) {
        // 每页创建新值
    }
}

默认布局

由于您网站中的许多页面(也许甚至是所有页面?)都将使用相同的 @Layout,Kobweb 允许您用布局注解标记文件(以及扩展的相关包):

@file:Layout(".components.layouts.PageLayout")

package com.mysite.pages

import com.varabyte.kobweb.core.layout.Layout
jsMain/kotlin/com/mysite/pages/Layout.kt

一旦您这样做了,那么在该包下定义的任何页面都将自动应用该布局(除非它明确声明自己的布局)。

如果您的网站定义了多个默认布局,比如将 PageLayoutcom.mysite.pages 关联,将 BlogLayoutcom.mysite.pages.blog 关联,那么最具体的那个将适用。换句话说,对于 blog 子包下的所有页面,BlogLayout 将优先于 PageLayout,而其他所有内容都将使用 PageLayout

扩展布局

布局本身可以声明父布局。如果您想创建一个扩展另一个布局的布局,这很有用,也许为您网站的一个子集添加一些额外的脚手架。

例如,想象您有一些通用的网站UI,您希望仅为网站上的文章页面补充额外的侧边栏内容,允许您通过点击浏览文章部分标题。

要做到这一点,用 @Layout 注解标记布局(就像您通常做的那样),但包含到其他布局的路径,就像您定义 @Page 时所做的那样:

@Layout
@Composable
fun PageLayout(content: @Composable () -> Unit) { /*...*/ }
jsMain/kotlin/com/mysite/components/layouts/PageLayout.kt
@Layout(".components.layouts.PageLayout")
@Composable
fun ArticleLayout(content: @Composable () -> Unit) { /*...*/ }
jsMain/kotlin/com/mysite/components/layouts/ArticleLayout.kt

此时,如果您访问布局设置为 ".components.layouts.ArticleLayout" 的页面,组合层次结构将如下所示:

App {
    PageLayout {
        ArticleLayout {
            ArticlePage()
        }
    }
}

当您在网站中导航时,即使在文章页面和非文章页面之间,PageLayout 可组合项将始终在调用层次结构中的同一位置,因此它将避免不必要的重新组合并记住 remember 的值。

@NoLayout

页面可以通过使用 @NoLayout 注解来明确表示它根本不想使用任何布局:

@Page
@NoLayout
@Composable
fun LayoutlessPage() { /* ... */ }

通常您不需要这样做,除非您已经设置了应用于某个包下所有页面的默认布局,此时您可以使用此注解来选择退出。

没有布局对于您不想用网站的正常脚手架装饰的页面偶尔会很有用,比如您想要完全控制其外观的某些特殊页面。

没有布局,组合层次结构基本上完全跳过布局层:

App {
    Page()
}

这确实意味着当您导航回布局的页面时,Compose 会将其视为新的组合。

布局和页面之间的通信

向布局传递数据

使用布局时,用户很快就会发现自己在问一个问题:"我如何根据我所在的页面配置布局的一部分?"

例如,许多网站使用的一个常见模式是设置每页更新的标题:

@Layout
@Composable
fun PageLayout(content: @Composable () -> Unit) {
    val title = getTitleSomehow() // ???
    H1 { Text(title) }
    content()
}

可能很想定义这样的布局,接受 title 参数:

@Layout
@Composable
fun PageLayout(
    title: String,
    content: @Composable () -> Unit
) { /*...*/ }

但请记住,您不会直接调用布局方法。Kobweb 为您做这件事!

因此,如果您向布局函数添加了几个参数,您还需要一种间接的方式让每个页面指示它想要如何设置这些参数。在实践中,许多方法需要大量嘈杂、脆弱的样板代码来实现这一点。

相反,我们决定支持通过页面数据与布局通信。

具体来说,PageContext 实例提供了一个 data 属性,这是一个简单的数据存储,让您可以向其中添加任何您想要的数据值,然后可以按类型查询。

因此,对于上面的 title 示例,让我们将其包装在一个类中(为了类型唯一性)。我们暂时回避从页面的确切位置创建和添加数据。模式如下所示:

class PageLayoutData(val title: String)

// 某个地方...?
ctx.data.add(PageLayoutData("Home Page"))

@Layout
@Composable
fun PageLayout(ctx: PageContext, content: @Composable () -> Unit) {
   val title = ctx.data.getValue<PageLayoutData>().title
   H1 { Text(title) }
   content()
}

到目前为止一切都很好,但现在我们需要弄清楚在我们的代码库中实际可以在哪里添加数据。

我们不能将其放在页面方法中,因为当我们调用它时,我们已经在渲染中,布局组合过程已经发生了。那太晚了!

@InitRoute

这就是 @InitRoute 的用武之地。

任何定义 @Layout@Page 方法的文件都可以额外定义一个 @InitRoute 方法,如果存在,将在页面及其布局开始第一次渲染过程之前被调用。

@InitRoute 方法必须接受单个 InitRouteContext 参数,它提供对 data 属性的可变访问。

Important

当从布局或页面内部查询 data 时,它将具有只读视图。换句话说,您将无法在那时添加或删除值。

将所有内容整合在一起,您的最终代码应该如下所示:

class PageLayoutData(val title: String)

@Layout
@Composable
fun PageLayout(ctx: PageContext, content: @Composable () -> Unit) {
   val title = ctx.data.getValue<PageLayoutData>().title
   H1 { Text(title) }
   /*...*/
   content()
}
jsMain/kotlin/com/mysite/components/layouts/PageLayout.kt
@InitRoute
fun initHomePage(ctx: InitRouteContext) {
    ctx.data.add(PageLayoutData("Home Page"))
}

@Page
@Layout(".components.layouts.PageLayout")
@Composable
fun HomePage() {
    /*...*/
}
jsMain/kotlin/com/mysite/pages/HomePage.kt

请注意在上面的示例中,由于我们编写代码的方式,PageLayoutData 必须被初始化,否则 getValue 调用将抛出异常。

对于相信快速失败的人来说,这可能就是您想要的!但是,您可以使用 ctx.data.get<PageLayoutData>() 调用,它将返回 null 而不是崩溃。

但是,我们推荐的方法是在 PageLayout 级别提供 @InitRoute 方法,使用 addIfAbsent 辅助方法,这给您带来了知道您的数据将始终被设置的好处,但以一种您仍然可以在页面被遗漏时通知开发人员的方式:

class PageLayoutData(val title: String)

@InitRoute
fun initPageLayout(ctx: InitRouteContext) {
    ctx.data.addIfAbsent {
        console.warn("${ctx.route.path} 没有设置 PageLayoutData")
        PageLayoutData("(缺少标题)")
    }
}

@Layout
@Composable
fun PageLayout(ctx: PageContext, content: @Composable () -> Unit) {
    // 保证存在
    ctx.data.getValue<PageLayoutData>()
}
jsMain/kotlin/com/mysite/components/layouts/PageLayout.kt
Caution

每次访问新页面时,ctx.data 存储都会被清除,即使您在页面之间使用相同的布局。如果这对您的用例是一个限制,请考虑查看本地存储和会话存储,在 Persisting State 中有更详细的讨论。

@InitRoute 调用顺序

值得了解 @InitRoute 方法的调用顺序。 它们首先触发子级,然后向上通过所有祖先布局。 一旦渲染过程开始,就会以相反的顺序发生。

换句话说,如果您有 BaseLayoutChildLayoutPage,每个都有自己对应的初始化方法,那么调用顺序将是:

  • initPage()
  • initChildLayout()
  • initBaseLayout()
  • BaseLayout()
  • ChildLayout()
  • Page()

这允许的代码是您在初始化布局链时不断追加/修改数据,然后在开始渲染时,所有数据都将存在。

至于渲染从另一个方向进行,从上到下,嗯,这就是渲染的工作方式!

布局回调

偶尔,用户会实现想要暴露回调的布局,特别是因为 Compose 建议尽可能多地进行状态提升。

让我们使用一个简单(愚蠢)的想法,只是为了提供一个具体的例子。

首先,我们将创建一个上面有按钮的布局。当点击此按钮时,它给任何页面一个响应它的机会。

class ButtonLayoutData(
    val onClick: () -> Unit
)

@Layout
@Composable
fun ButtonLayout(ctx: PageContext, content: @Composable () -> Unit) {
    Column {
        Button(onClick = {
            ctx.data.getValue<ButtonLayoutData>().onClick()
        }) {
            Text("点击我")
        }
        content()
    }
} 

现在,假设我们想创建一个页面来跟踪该按钮被点击的次数,显示计数。

如果我们编写直接的 Compose 代码,我们会创建一个布局可组合项,它只是接受回调作为其参数之一。这样的代码可能如下所示:

var clickCount by remember { mutableStateOf(0) }
ButtonLayout(onClick = { clickCount++ }) {
    Text("您点击了 $clickCount 次!")
}

但是,在我们分离页面和布局的世界中,您需要在 @InitRoute 方法中注册回调处理程序,这意味着改变状态的方法与使用它的方法不同。

为了解决这个问题,我们建议您将可变状态声明为文件中的私有顶级属性,此时您可以在 @InitRoute 调用中设置它,并在您的 @Composable 页面或布局中引用它:

private var clickCount by mutableStateOf(0)

@InitRoute
fun initButtonCountPage(ctx: InitRouteContext) {
    ctx.data.add(ButtonLayoutData(onClick = { clickCount++ }))
}

@Page
@Layout(".components.layouts.ButtonLayout")
@Composable
fun ButtonCountPage() {
    Text("您点击了 $clickCount 次!")
}

确实更冗长一些,但这是我们确定的完成工作的最佳方式!

从布局传递数据

前面的部分清楚地说明了如何从页面或子布局与其父布局通信,但另一个方向呢?我们如何在父布局中定义我们希望向下提供的值?

Kobweb 通过允许您在布局的 content 回调上定义接收器作用域来支持这一点:

class PageLayoutScope { /*...*/ }

@Layout
@Composable
fun PageLayout(
    content: @Composable PageLayoutScope.() -> Unit
) {
    val scope = remember { PageLayoutScope() }
    scope.content()
}

此时,如果您还用相同的接收器限定您的页面或子布局的作用域,您将能够访问父布局提供的值。

@Page
@Layout(".components.layouts.PageLayout")
fun PageLayoutScope.ExamplePage() {
    /*...*/
}

就是这样!

没有任何接收器的页面或布局仍然可以声明自己是提供接收器的布局的子级。

但是,一旦页面或布局声明了接收器,它只能使用在其 content 回调中也声明相同接收器的布局。如果不是,Kobweb 将在编译时发出错误。

使用布局作用域可以是布局传递任何子页面或布局都可以调用的实用方法的有效方式:

interface ShoppingPageScope {
    fun addItemToCart(id: Int)
    fun navigateToCart()
}

@Layout
@Composable
fun ShoppingPageLayout(
    ctx: PageContext,
    content: @Composable ShoppingPageScope.() -> Unit
) {
    val scope = remember {
        object : ShoppingPageScope {
            override fun addItemToCart(id: Int) { /*...*/ }
            override fun navigateToCart() {
                ctx.router.navigateTo("/cart")
            }
        }
    }
    scope.content()
}
@Page
@Layout(".components.layouts.ShoppingPageLayout")
@Composable
fun ShoppingPageScope.BrowseItemPage(ctx: PageContext) {
    val itemId = ctx.route.params.getValue("item-id")
    /*...*/
    Button(onClick = {
        addItemToCart(itemId)
        navigateToCart() 
    }) {
        Text("立即购买!")
    }
}

布局是必需的吗?

在布局存在之前,Kobweb 只是建议用户创建一个可组合方法,并将其作为每个页面内的第一个方法调用。

例如:

@Composable
fun PageLayout(title: String, content: @Composable () -> Unit) { /*...*/ }
PageLayout.kt
@Page
@Composable
fun Page1() {
    PageLayout("page 1") {
        /*...*/
    }
}
Page1.kt
@Page
@Composable
fun Page2() {
    PageLayout("page 2") {
        /*...*/
    }
}
Page2.kt

老实说,如果这种模式适用于您的项目,这是一种合法的方法。它既类型安全又直接。

但是,请确保您了解您的组合层次结构如下所示:

App {
    Page {
        Layout { content() }
    }
}

如果您发现在页面之间导航时某些状态被丢弃(例如,侧边栏内容的展开/折叠状态被重置),这表明可能是迁移的好时机。除了在页面之间更一致的状态行为外,您还减少了一级缩进,这总是很好的。

关于 movableContentOf 的快速说明

一些用户可能知道 Compose API 提供了一个名为 movableContentOf 的功能,如果您熟悉它,似乎在这里可能很有用。

但是,我们的调查发现,至少目前,它的实现做出了一些与 Compose HTML 不兼容的假设(相关 YouTrack)。因此,在可预见的未来,使用可移动内容可能是不可行的,我们不能正式推荐它。