navigation3 で Sceneによって 画面単位で list detailといったAdaptive layoutが汲みやすくなる。 が、そもそも現状でAdaptiveLayoutの実装を知らんので軽くまとめる。
以下三つ
- NavigationSuiteScaffold
- ListDetailPaneScaffold
- SupportingPaneScaffold
NavigationSuiteScaffold
NavigationSuiteScaffold は 「画面サイズやデバイスの姿勢に応じて、NavigationBarと NavigationRailを自動で切り替えるコンテナ」 として機能
基本イメージ
Scaffold(
bottomBar = { NavigationBar { ... } }
) { innerPadding ->
NavHost( ... )
}
が
Scaffold(
topBar = { TopAppBar(...) }
) { innerPadding ->
NavigationSuiteScaffold(
modifier = Modifier.padding(innerPadding),
navigationSuiteItems = { ... }
) {
NavHost(
navController,
startDestination = "home",
) {... }
}
}
になる。
NavigationSuiteScaffold自体はcontentPaddingを渡してくれないので Scaffoldでラップする必要がある。
compact | medium以上 |
---|---|
![]() |
![]() |
layoutType: NavigationSuiteType パラメータ
NavigationSuiteTypeでどのNavigationUIを使うかを指定できる。
用意されているのは NavigationBar / NavigationRail / NavigationDrawer / None の4つ
windowSizeClass から 独自に判定挟むってこともできる。
val adaptiveInfo = androidx.compose.material3.adaptive.currentWindowAdaptiveInfo()
// androidx.compose.material3.adaptive:adaptive version 1.2.0-beta and later
val customType = with(adaptiveInfo) {
if (windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND)) {
NavigationSuiteType.NavigationDrawer
} else {
NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(adaptiveInfo)
}
}
デフォルトはこれ
layoutType: NavigationSuiteType = NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(WindowAdaptiveInfoDefault) // WindowAdaptiveInfoDefault = currentWindowAdaptiveInfo()
fun calculateFromAdaptiveInfo(adaptiveInfo: WindowAdaptiveInfo): NavigationSuiteType {
return with(adaptiveInfo) {
if (
windowPosture.isTabletop ||
windowSizeClass.windowHeightSizeClass == WindowHeightSizeClass.COMPACT
) {
NavigationSuiteType.NavigationBar
} else if (
windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED ||
windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.MEDIUM
) {
NavigationSuiteType.NavigationRail
} else {
NavigationSuiteType.NavigationBar
}
}
}
よしなに計算してくれている。
Modal使いたかったり、以下のみたいに特定のrouteでRailもBarも消したいって時以外はデフォルトでもよさそうな。
val customType = with(adaptiveInfo) {
if (isTopLevelRoute) {
NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(this)
} else {
NavigationSuiteType.None
}
}
ListDetailPaneScaffold
List - Detail のレイアウトを作成する。
navigationと統合した NavigableListDetailPaneScaffoldを使うと楽。
コールサイト視点だと これが
NavHost(
navController = navController,
startDestination = AppRoute.Home,
modifier = modifier
) {
composable<AppRoute.List> {
ListScreen(navController)
}
composable<AppRoute.Detail> {
val detail = it.toRoute<AppRoute.Detail>()
DetailScreen(detail.id, navController)
}
...
}
こう一本化できる。
NavHost(
navController = navController,
startDestination = AppRoute.Home,
modifier = modifier
) {
composable<AppRoute.List> {
ListDetailScreen(navController)
}
...
}
中身
@ExperimentalMaterial3AdaptiveApi
@Composable
fun ListDetailScreen(navController: NavController) {
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
val scope = rememberCoroutineScope()
NavigableListDetailPaneScaffold(
navigator = navigator,
listPane = {
AnimatedPane {
ListPaneContent(
items = items,
onItemClick = { id ->
scope.launch {
navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, id)
}
}
)
}
},
detailPane = {
AnimatedPane {
val id = navigator.currentDestination?.contentKey
if (id != null) {
DetailPaneContent(
itemId = id,
onOpenMain = { mainId -> navController.navigate(AppRoute.MainContent(mainId)) }
)
} else {
// placeholder
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No item selected")
}
}
}
}
)
}
コツは単発で使うComposable(ListやDetail)をnavControllerに依存させないでおくこと。
rememberListDetailPaneScaffoldNavigatorのThreePaneScaffoldNavigator<T>
という別のnavigatorを使うので、Paneに指定するComposableでnavControllerを使ったロジックがあると List と Detailの決号部分の引き剥がしが必要になる。
rememberListDetailPaneScaffoldNavigatorのパラメータ
fun <T> rememberListDetailPaneScaffoldNavigator(
scaffoldDirective: PaneScaffoldDirective =
calculatePaneScaffoldDirective(currentWindowAdaptiveInfo()),
adaptStrategies: ThreePaneScaffoldAdaptStrategies =
ListDetailPaneScaffoldDefaults.adaptStrategies(),
isDestinationHistoryAware: Boolean = true,
initialDestinationHistory: List<ThreePaneScaffoldDestinationItem<T>> =
DefaultListDetailPaneHistory,
): ThreePaneScaffoldNavigator<T>
- scaffoldDirective
windowSizeやposture に応じて、いくつのpaneを並べるか指定する。デフォルト実装の計算が参考。
fun calculatePaneScaffoldDirective(
windowAdaptiveInfo: WindowAdaptiveInfo,
verticalHingePolicy: HingePolicy = HingePolicy.AvoidSeparating,
): PaneScaffoldDirective {
val maxHorizontalPartitions: Int
val horizontalPartitionSpacerSize: Dp
val defaultPanePreferredWidth: Dp
when (windowAdaptiveInfo.windowSizeClass.minWidth) {
WindowSizeClass.WidthSizeClasses.Compact -> {
maxHorizontalPartitions = 1
horizontalPartitionSpacerSize = 0.dp
defaultPanePreferredWidth = PaneScaffoldDirective.DefaultPreferredWidth
}
WindowSizeClass.WidthSizeClasses.Medium -> {
maxHorizontalPartitions = 1
horizontalPartitionSpacerSize = 0.dp
defaultPanePreferredWidth = PaneScaffoldDirective.DefaultPreferredWidth
}
WindowSizeClass.WidthSizeClasses.Expanded -> {
maxHorizontalPartitions = 2
horizontalPartitionSpacerSize = 24.dp
defaultPanePreferredWidth = PaneScaffoldDirective.DefaultPreferredWidth
}
else -> {
maxHorizontalPartitions = 3
horizontalPartitionSpacerSize = 24.dp
defaultPanePreferredWidth = PaneScaffoldDirective.DefaultPreferredWidthXL
}
}
...
return PaneScaffoldDirective(...
- adaptStrategies: ThreePaneScaffoldAdaptStrategies
3paneの出し方の戦略をカスタマイズできる。
AdaptStrategy 3種類
• Hide … 出せない時は隠す(デフォルト挙動)
• Reflow(role) … 出せない時は指定ペインの下へ合流(1ペイン構成時などに、List と Detail を同じペインで切替表示させたい等)
• Levitate(alignment, scrim) … 出せない時は上に浮かせる(ダイアログ/シートのように)
例えばこれで,List側に合流させるみたいな面白いことができる
adaptStrategies = ThreePaneScaffoldAdaptStrategies(
// List
primaryPaneAdaptStrategy = AdaptStrategy.Hide, // 出せない時は隠す
// Detail
secondaryPaneAdaptStrategy = AdaptStrategy.Reflow(ThreePaneScaffoldRole.Primary), // 出せない時はPrimaryへ合流
// extra
tertiaryPaneAdaptStrategy = AdaptStrategy.Hide
)
デフォルト | ↑ |
---|---|
![]() |
![]() |
- isDestinationHistoryAware: Boolean = true
以下っぽいが基本はtrueでよい。
• true:2 ペイン時に List を展開したまま Detail へ遷移しても、Back で自然に戻るなど、履歴に“レイアウト変化”が反映される。
• false:レイアウト変化をまたぐ履歴をスキップ気味。Back 可能性や可視ペインの扱いが簡素化される。
- initialDestinationHistory: List>
ナビゲータの初期バックスタック
例えば以下とすると、Detailがすでに入った状態で表示される。
val initialStack = listOf(
ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.List),
ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.Detail, contentKey = "initialId")
)
val navigator = rememberListDetailPaneScaffoldNavigator(
initialDestinationHistory = initialStack
)
SupportingPaneScaffold
実装の構造や仕組みについては、ほぼListDetailと同じ。
val navigator = rememberSupportingPaneScaffoldNavigator<String>()
val scope = rememberCoroutineScope()
NavigableSupportingPaneScaffold(
navigator = navigator,
mainPane = {
AnimatedPane {
val hasSupporting = navigator.currentDestination?.contentKey != null
MainPaneContent(
itemId = itemId,
onOpenSub = { id ->
scope.launch {
navigator.navigateTo(SupportingPaneScaffoldRole.Supporting, id)
}
},
showSubButton = !hasSupporting // Hide button when SubContent is showing
)
}
},
supportingPane = {
AnimatedPane {
val subContentId = navigator.currentDestination?.contentKey
if (subContentId != null) {
SubPaneContent(
parentId = subContentId,
onClose = {
scope.launch {
navigator.navigateBack()
}
}
)
} else {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No sub content selected")
}
}
}
}
)
compact | expanded |
---|---|
![]() |
![]() |
デフォルトでcompactでも supporting pane が表示になる。
私の感覚だとCompactは普通にシングルペインで表示して欲しいので、前述のThreePaneScaffoldAdaptStrategiesで改変しておくと良いのかもしれない。
val directive = calculatePaneScaffoldDirective(currentWindowAdaptiveInfo())
val navigator = rememberSupportingPaneScaffoldNavigator<String>(
scaffoldDirective = directive,
adaptStrategies = ThreePaneScaffoldAdaptStrategies(
// main
primaryPaneAdaptStrategy = AdaptStrategy.Hide,
// supporting
secondaryPaneAdaptStrategy = AdaptStrategy.Hide,
// extra
tertiaryPaneAdaptStrategy = AdaptStrategy.Hide
)
)
NavigableSupportingPaneScaffold(
navigator = navigator,
mainPane = {
AnimatedPane {
val hasSupporting = navigator.currentDestination?.contentKey != null
MainPaneContent(
itemId = itemId,
onOpenSub = { id ->
// paneの数でナビゲートを分岐する
if (directive.maxHorizontalPartitions == 1) {
navController.navigate(AppRoute.SubContent)
} else {
scope.launch {
navigator.navigateTo(SupportingPaneScaffoldRole.Supporting, id)
}
}
},
showSubButton = !hasSupporting // Hide button when SubContent is showing
)
}
},
supportingPane = {
AnimatedPane {
val subContentId = navigator.currentDestination?.contentKey
if (subContentId != null) {
SubPaneContent(
parentId = subContentId,
onClose = {
scope.launch {
navigator.navigateBack()
}
}
)
} else {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No sub content selected")
}
}
}
}
)
おわり
Resizable Emulator 便利