推荐看的文章:https://juejin.cn/post/7568873939033260082

Navigation3

整体模型

1
2
3
4
5
6
7
8
9
10
11
backStack

entryProvider

NavEntry

entryDecorators

SceneStrategy / Scene

最终显示的 UI

开发者维护一个backStack,Navigation根据backStack里面的key生成NavEntry,再把NavEntry组织成当前UI。

backStack——导航栈

backStack通常就是一个列表:

1
val backStack = remember { mutableStateListOf<Any>(Home) }

表示当前导航的一个历史,比如:[Home, Detail, Sheet]通常里面的最后一个元素代表当前栈顶页面或当前的覆盖层。

需要注意的是,导航栈里面放的也不是UI,而是导航Key,比如:

1
2
3
data object Home
data class Detail(val id: String)
data object Settings

然后添加时:

1
backStack.add(Detail(id = "1001"))

我们可以发现添加的时候也没有添加UI,而是一个导航key,代表目的地以及其参数,真正的UI是通过entryProvider生成的。

entryProvider

entryProvider是key到NavEntry的映射:

1
2
3
4
5
6
7
8
9
entryProvider {
entry<Home> {
HomeScreen()
}

entry<Detail> { key ->
DetailScreen(id = key.id)
}
}

Navigation3 会根据 entryProvider 创建对应的 NavEntry,以便这些NavEntry参与后续流程。

NavEntry非常重要,这是一个被Navigation3管理的条目,包含了对应的key,metdata以及页面内容。

NavEntry 不是最终的 UI 容器,它只是导航条目。

而最终UI怎么展示还是要看Scene。

需要注意的是,在Nav2中,NavBackStackEntry本身就是一个ViewmodelStoreOwner,所以你在页面里面直接获取viewModel(),是可以直接拿到当前作用域的ViewModel的。但在Navigation3中,NavEntry却不是ViewModelStoreOwner,如果你希望每个 NavEntry 都有自己独立的 ViewModel 作用域,需要额外使用:

1
rememberViewModelStoreNavEntryDecorator()

这个装饰器会给每个 NavEntry 关联对应的 ViewModelStoreOwner

metadata

NavEntry除了可以提供页面内容,还可以携带metadata。metadata的作用是让后面的SceneStrategy能否识别这个entry是否需要特殊处理。

可以拓展Navigation3,比如我使用metadata实现了BottomSheetSceneStrategy。

如果说 backStack 是导航状态,entryProvider 是映射规则,NavEntry 是导航条目,那么 NavDisplay 就是最终把这些东西组合起来并显示的 Composable。

典型写法类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
NavDisplay(
backStack = backStack,
entryDecorators = listOf(
rememberViewModelStoreNavEntryDecorator(),
rememberSaveableStateHolderNavEntryDecorator(),
),
entryProvider = entryProvider {
entry<Home> {
HomeScreen()
}

entry<Detail> { key ->
DetailScreen(id = key.id)
}
}
)

NavDisplay有默认的返回方法onBack():

1
2
3
if (backStack is MutableList<T>) {
backStack.removeLastOrNull()
}

默认会移除最后一个元素。

Navigation3 里一个非常重要的设计是 NavEntryDecorator

它的作用可以理解成:

NavEntry 的页面内容外面包一层能力。

Navigation3 没有把所有能力都硬塞进 NavEntry 本身,而是通过装饰器按需添加。

最常见的两个装饰器是:

1
2
rememberViewModelStoreNavEntryDecorator()
rememberSaveableStateHolderNavEntryDecorator()

Scene

Scene就是真正描述UI的地方。

比如普通的单栈页面:[Home, Detail],最终通常都是显示Detail,但是可能有覆盖场景,所以说Scene关注的是显示和如何显示Entry,以及哪些是底层/覆盖内容。

SceneStrategy

计算当前Scene的策略。SceneStrategy 处理的是整个 entries 列表,而不是单个 entry。即,对整个entries列表判断当前应该生成普通Scene,BottomSheet的Scene还是Dialog Scene。

sceneStrategies是按顺序执行的,可以理解为一条链:

1
2
3
4
5
6
7
for (strategy in sceneStrategies) {
val scene = strategy.calculateScene(entries)

if (scene != null) {
return scene
}
}

因为策略是按顺序执行的,所以顺序会影响结果。

如果一个普通单栈策略放在最前面,并且它总是可以返回 Scene,那么后面的特殊策略就没有机会执行。

因此通常应该把更特殊的策略放前面,把兜底策略放后面。比如BottomSheet/Dialog的Scene就应该放最前面,兜底策略(普通单栈策略)放最后。

每次 backStack 发生变化,Navigation3 都会拿新的 entries 重新跑一遍策略链。sceneStrategies也不会消失。

所以 Scene 是动态计算出来的结果,不是一次绑定永久生效。

最后放一下掌邮的BottomSheetSceneStrategy:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
package com.cyxbs.components.view.ui

import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavMetadataKey
import androidx.navigation3.runtime.metadata
import androidx.navigation3.scene.Scene
import androidx.navigation3.scene.SceneStrategy
import androidx.navigation3.scene.SceneStrategyScope
import androidx.navigation3.runtime.get
import androidx.navigation3.scene.OverlayScene


/**
* description: 自定义BottomSheet的Strategy(决定如何显示导航栈)
*
* 把带有 [BottomSheetSceneStrategy.Companion.BottomSheetKey] metadata 的 NavEntry 以
* [BottomSheetCompose] 的形式作为 overlay 渲染,使其支持压栈/出栈。
*
* ## 设计要点
* - **复用外部 [BottomSheetState]**:通过 [Properties.stateProvider] 注入由业务持有的 state,
* 而非在 Scene 内部新建。这样地图等场景里 controller 对同一个 state 的 collapse/expand/hide
* 联动逻辑无需改动。
* - **出栈语义**:只有当 state 进入 [BottomSheetValueState.Hide](彻底隐藏 / 拖到底)时才调用
* [SceneStrategyScope.onBack] 出栈;collapse / 露出 peek 不出栈。
* - **退场动画**:出栈时 [OverlayScene.onRemove] 会先 `hide()` 播放收起动画,再离开组合。
*
* author: zzx
* email: 1487144524@qq.com
* date: 2026/5/30 15:24
*/
class BottomSheetSceneStrategy : SceneStrategy<Any> {

override fun SceneStrategyScope<Any>.calculateScene(entries: List<NavEntry<Any>>): Scene<Any>? {
// 只处理backStack最顶上的entry
val entry = entries.lastOrNull() ?: return null
// 如果这个entry没有声明BottomSheetKey metadata,就交给后面的来处理
val properties = entry.metadata[BottomSheetKey] ?: return null
return BottomSheetScene(
key = entry.contentKey,
previousEntries = entries.dropLast(1),
overlaidEntries = entries.dropLast(1),
entry = entry,
properties = properties,
onBack = onBack,
)
}

companion object {

/**
* BottomSheet配置项
*/
data class Properties(
/**
* 注入外部持有的 [BottomSheetState],Scene 会复用它而非新建。
* 一般写成引用全局/持有者的无捕获 lambda 以保持稳定(如 `{ holder.vm?.bottomSheetState }`)。
*
* 返回 null 表示外部尚未准备好(如进程恢复时外部 VM 还没发布),此时 Scene 不渲染任何内容,
* 待返回非空后自动重组显示。
*/
val stateProvider: @Composable () -> BottomSheetState?,
val peekHeight: Dp = 0.dp,
val expandOnShow: Boolean = false, // 出现时是否展开到最大高度
val dismissOnBackPress: Boolean = true, // 是否让 BottomSheetCompose 自己处理返回键
val dismissOnClickOutside: Boolean = false, // 点击 sheet 外部区域是否 dismiss
val scrimColor: Color = Color.Transparent, // 背景遮罩颜色
val modifier: Modifier = Modifier.navigationBarsPadding(),
/**
* 是否在 state 进入 [BottomSheetValueState.Hide] 时自动出栈。
* 默认 true
* 若由业务自行管理 entry 的进出栈(如需要稳定 z-order 的多 sheet 叠加场景),可设为 false。
*/
val popOnHide: Boolean = true,
)

object BottomSheetKey : NavMetadataKey<Properties>

/**
* 暴露给AppNavEntry.buildMetadata()的快捷方法
* ``` kotlin
* override fun buildMetadata(argument: Xxx): Map<String, Any> {
* return AppBottomSheetSceneStrategy.bottomSheet(
* AppBottomSheetSceneStrategy.Properties(peekHeight = 112.dp)
* )
* }
* ```
*/
fun bottomSheet(
properties: Properties
): Map<String, Any> = metadata {
put(BottomSheetKey, properties)
}

}

}

/**
* 负责渲染 bottomSheet的Scene
* OverlayScene 表示:
* - 当前 entry 是覆盖层;
* - previousEntries / overlaidEntries 是底下仍然显示的页面;
* - content 里决定覆盖层长什么样。
*/
private class BottomSheetScene(
override val key: Any,
override val previousEntries: List<NavEntry<Any>>,
override val overlaidEntries: List<NavEntry<Any>>,
private val entry: NavEntry<Any>,
private val properties: BottomSheetSceneStrategy.Companion.Properties,
private val onBack: () -> Unit,
) : OverlayScene<Any> {

override val entries: List<NavEntry<Any>> = listOf(entry)

private lateinit var bottomSheetState: BottomSheetState

override val content: @Composable (() -> Unit) = {

// 返回 null 表示外部 state 尚未就绪(如进程恢复时外部 VM 还没发布),此时不渲染,待就绪后自动重组
val state = properties.stateProvider()
if (state != null) {
bottomSheetState = state

BottomSheetCompose(
bottomSheetState = state,
modifier = properties.modifier,
peekHeight = properties.peekHeight,
dismissOnBackPress = properties.dismissOnBackPress,
dismissOnClickOutside = properties.dismissOnClickOutside,
scrimColor = properties.scrimColor
) {
// entry.Content() 不携带 BottomSheetScope receiver,这里通过 CompositionLocal 下传,
// 供内层内容调用 bottomSheetDraggable()
CompositionLocalProvider(LocalBottomSheetScope provides this) {
entry.Content()
}
}

if (properties.expandOnShow) {
LaunchedEffect(state) {
state.expand()
}
}

// 只有彻底 Hide(拖到底 / 业务调用 hide())才出栈;collapse / peek 不出栈。
// hasShown 防止 entry 刚挂载时 state 恰为 Hide 导致的立即出栈。
if (properties.popOnHide) {
LaunchedEffect(state) {
var hasShown = false
state.stateFlow.collect { value ->
if (value != BottomSheetValueState.Hide) {
hasShown = true
} else if (hasShown) {
onBack()
}
}
}
}
}
}

override suspend fun onRemove() {
if (::bottomSheetState.isInitialized) {
bottomSheetState.hide()
}
}

// 参考官方 DialogScene,实现 equals/hashCode 保证 Scene 身份稳定(不纳入 onBack)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false

other as BottomSheetScene

return key == other.key &&
previousEntries == other.previousEntries &&
overlaidEntries == other.overlaidEntries &&
entry == other.entry &&
properties == other.properties
}

override fun hashCode(): Int {
return key.hashCode() * 31 +
previousEntries.hashCode() * 31 +
overlaidEntries.hashCode() * 31 +
entry.hashCode() * 31 +
properties.hashCode() * 31
}

override fun toString(): String {
return "BottomSheetScene(key=$key, entry=$entry, previousEntries=$previousEntries, " +
"overlaidEntries=$overlaidEntries, properties=$properties)"
}

}