장면을 사용하여 맞춤 레이아웃 만들기

Navigation 3에서는 장면을 통해 앱의 UI 흐름을 관리하는 강력하고 유연한 시스템을 도입합니다. 장면을 사용하면 고도로 맞춤설정된 레이아웃을 만들고, 다양한 화면 크기에 맞게 조정하고, 복잡한 다중 창 환경을 원활하게 관리할 수 있습니다.

장면 이해하기

탐색 3에서 Scene는 하나 이상의 NavEntry 인스턴스를 렌더링하는 기본 단위입니다. Scene는 백 스택의 콘텐츠 표시를 포함하고 관리할 수 있는 UI의 고유한 시각적 상태 또는 섹션으로 생각할 수 있습니다.

Scene 인스턴스는 keyScene 자체의 클래스로 고유하게 식별됩니다. 이 고유 식별자는 Scene가 변경될 때 최상위 애니메이션을 구동하므로 중요합니다.

Scene 인터페이스에는 다음과 같은 속성이 있습니다.

  • key: Any: 이 특정 Scene 인스턴스의 고유 식별자입니다. 이 키는 Scene의 클래스와 결합하여 주로 애니메이션 목적으로 구별성을 보장합니다.
  • entries: List<NavEntry<T>>: Scene가 표시할 책임이 있는 NavEntry 객체 목록입니다. 중요한 점은 전환 중에 동일한 NavEntry가 여러 Scenes에 표시되는 경우 (예: 공유 요소 전환) 콘텐츠는 이를 표시하는 가장 최근 타겟 Scene에서만 렌더링된다는 것입니다.
  • previousEntries: List<NavEntry<T>>: 이 속성은 현재 Scene에서 '뒤로' 작업이 발생할 경우 발생할 NavEntry를 정의합니다. 적절한 뒤로 탐색 예측 상태를 계산하는 데 필수적입니다. 이를 통해 NavDisplay가 올바른 이전 상태를 예상하고 전환할 수 있습니다. 이 상태는 다른 클래스 또는 키가 있는 장면일 수 있습니다.
  • content: @Composable () -> Unit: Sceneentries 및 해당 Scene에만 해당하는 주변 UI 요소를 렌더링하는 방식을 정의하는 구성 가능한 함수입니다.

장면 전략 이해하기

SceneStrategy는 백 스택의 지정된 NavEntry 목록을 정렬하고 Scene로 전환하는 방법을 결정하는 메커니즘입니다. 기본적으로 SceneStrategy는 현재 백 스택 항목이 표시되면 다음 두 가지 주요 질문을 스스로 던집니다.

  1. 이 항목으로 Scene를 만들 수 있나요? SceneStrategy가 주어진 NavEntry를 처리하고 의미 있는 Scene(예: 대화상자 또는 다중 창 레이아웃)를 형성할 수 있다고 판단하면 계속 진행됩니다. 그렇지 않으면 null을 반환하여 다른 전략이 Scene를 만들 수 있는 기회를 제공합니다.
  2. 그렇다면 이러한 항목을 Scene?에 어떻게 배치해야 하나요? SceneStrategy가 항목 처리를 커밋하면 Scene를 구성하고 지정된 NavEntry가 해당 Scene 내에 표시되는 방식을 정의하는 책임을 맡게 됩니다.

SceneStrategy의 핵심은 calculateScene 메서드입니다.

@Composable
public fun calculateScene(
    entries: List<NavEntry<T>>,
    onBack: (count: Int) -> Unit,
): Scene<T>?

이 메서드는 백 스택에서 현재 List<NavEntry<T>>onBack 콜백을 사용합니다. 제공된 항목에서 하나를 성공적으로 형성할 수 있으면 Scene<T>를 반환하고, 형성할 수 없으면 null를 반환해야 합니다.

SceneStrategy는 또한 여러 전략을 함께 연결할 수 있는 편리한 then 중위 함수를 제공합니다. 이렇게 하면 각 전략이 Scene를 계산하려고 시도할 수 있는 유연한 의사결정 파이프라인이 생성되며, 계산할 수 없는 경우 체인의 다음 전략에 위임합니다.

장면과 장면 전략이 함께 작동하는 방식

NavDisplay는 백 스택을 관찰하고 SceneStrategy를 사용하여 적절한 Scene를 결정하고 렌더링하는 중앙 컴포저블입니다.

NavDisplay's sceneStrategy 매개변수는 표시할 Scene를 계산하는 SceneStrategy를 예상합니다. 제공된 전략 (또는 전략 체인)에서 계산된 Scene가 없는 경우 NavDisplay는 기본적으로 SinglePaneSceneStrategy를 사용하도록 자동으로 대체됩니다.

상호작용의 세부정보는 다음과 같습니다.

  • 백 스택에서 키를 추가하거나 삭제하면 (예: backStack.add() 또는 backStack.removeLastOrNull() 사용) NavDisplay가 이러한 변경사항을 관찰합니다.
  • NavDisplay는 현재 NavEntrys 목록 (백 스택 키에서 파생됨)을 구성된 SceneStrategy's calculateScene 메서드에 전달합니다.
  • SceneStrategyScene를 성공적으로 반환하면 NavDisplay는 해당 Scenecontent를 렌더링합니다. NavDisplayScene의 속성을 기반으로 애니메이션과 뒤로 탐색 예측도 관리합니다.

예: 단일 창 레이아웃 (기본 동작)

가장 간단한 맞춤 레이아웃은 단일 창 디스플레이로, 다른 SceneStrategy가 우선되지 않는 경우 기본 동작입니다.

data class SinglePaneScene<T : Any>(
    override val key: T,
    val entry: NavEntry<T>,
    override val previousEntries: List<NavEntry<T>>,
) : Scene<T> {
    override val entries: List<NavEntry<T>> = listOf(entry)
    override val content: @Composable () -> Unit = { entry.content.invoke(entry.key) }
}

/**
 * A [SceneStrategy] that always creates a 1-entry [Scene] simply displaying the last entry in the
 * list.
 */
public class SinglePaneSceneStrategy<T : Any> : SceneStrategy<T> {
    @Composable
    override fun calculateScene(entries: List<NavEntry<T>>, onBack: (Int) -> Unit): Scene<T> =
        SinglePaneScene(
            key = entries.last().key,
            entry = entries.last(),
            previousEntries = entries.dropLast(1)
        )
}

예: 기본 2개 창 레이아웃 (맞춤 장면 및 전략)

이 예에서는 두 가지 조건에 따라 활성화되는 간단한 두 창 레이아웃을 만드는 방법을 보여줍니다.

  1. 창 너비가 두 창을 지원하기에 충분히 넓습니다 (즉, WIDTH_DP_MEDIUM_LOWER_BOUND 이상).
  2. 백 스택의 상위 두 항목은 특정 메타데이터를 사용하여 2개 창 레이아웃에 표시되는 것을 명시적으로 선언합니다.

다음 스니펫은 TwoPaneScene.ktTwoPaneSceneStrategy.kt의 결합된 소스 코드입니다.

// --- TwoPaneScene ---
/**
 * A custom [Scene] that displays two [NavEntry]s side-by-side in a 50/50 split.
 */
class TwoPaneScene<T : Any>(
    override val key: Any,
    override val previousEntries: List<NavEntry<T>>,
    val firstEntry: NavEntry<T>,
    val secondEntry: NavEntry<T>
) : Scene<T> {
    override val entries: List<NavEntry<T>> = listOf(firstEntry, secondEntry)
    override val content: @Composable (() -> Unit) = {
        Row(modifier = Modifier.fillMaxSize()) {
            Column(modifier = Modifier.weight(0.5f)) {
                firstEntry.content.invoke(firstEntry.key)
            }
            Column(modifier = Modifier.weight(0.5f)) {
                secondEntry.content.invoke(secondEntry.key)
            }
        }
    }

    companion object {
        internal const val TWO_PANE_KEY = "TwoPane"
        /**
         * Helper function to add metadata to a [NavEntry] indicating it can be displayed
         * in a two-pane layout.
         */
        fun twoPane() = mapOf(TWO_PANE_KEY to true)
    }
}

// --- TwoPaneSceneStrategy ---
/**
 * A [SceneStrategy] that activates a [TwoPaneScene] if the window is wide enough
 * and the top two back stack entries declare support for two-pane display.
 */
class TwoPaneSceneStrategy<T : Any> : SceneStrategy<T> {
    @OptIn(ExperimentalMaterial3AdaptiveApi::class, ExperimentalMaterial3WindowSizeClassApi::class)
    @Composable
    override fun calculateScene(
        entries: List<NavEntry<T>>,
        onBack: (Int) -> Unit
    ): Scene<T>? {

        val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass

        // Condition 1: Only return a Scene if the window is sufficiently wide to render two panes.
        // We use isWidthAtLeastBreakpoint with WIDTH_DP_MEDIUM_LOWER_BOUND (600dp).
        if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) {
            return null
        }

        val lastTwoEntries = entries.takeLast(2)

        // Condition 2: Only return a Scene if there are two entries, and both have declared
        // they can be displayed in a two pane scene.
        return if (lastTwoEntries.size == 2 &&
            lastTwoEntries.all { it.metadata.containsKey(TwoPaneScene.TWO_PANE_KEY) }
        ) {
            val firstEntry = lastTwoEntries.first()
            val secondEntry = lastTwoEntries.last()

            // The scene key must uniquely represent the state of the scene.
            val sceneKey = Pair(firstEntry.key, secondEntry.key)

            TwoPaneScene(
                key = sceneKey,
                // Where we go back to is a UX decision. In this case, we only remove the top
                // entry from the back stack, despite displaying two entries in this scene.
                // This is because in this app we only ever add one entry to the
                // back stack at a time. It would therefore be confusing to the user to add one
                // when navigating forward, but remove two when navigating back.
                previousEntries = entries.dropLast(1),
                firstEntry = firstEntry,
                secondEntry = secondEntry
            )
        } else {
            null
        }
    }
}

NavDisplay에서 이 TwoPaneSceneStrategy를 사용하려면 entryProvider 호출을 수정하여 2개 창 레이아웃에 표시하려는 항목의 TwoPaneScene.twoPane() 메타데이터를 포함합니다. 그런 다음 TwoPaneSceneStrategy()sceneStrategy로 제공하고 단일 창 시나리오의 기본 대체를 사용합니다.

// Define your navigation keys
@Serializable
data object ProductList : NavKey
@Serializable
data class ProductDetail(val id: String) : NavKey

@Composable
fun MyAppContent() {
    val backStack = rememberNavBackStack(ProductList)

    NavDisplay(
        backStack = backStack,
        entryProvider = entryProvider {
            entry<ProductList>(
                // Mark this entry as eligible for two-pane display
                metadata = TwoPaneScene.twoPane()
            ) { key ->
                Column {
                    Text("Product List")
                    Button(onClick = { backStack.add(ProductDetail("ABC")) }) {
                        Text("View Details for ABC (Two-Pane Eligible)")
                    }
                }
            }

            entry<ProductDetail>(
                // Mark this entry as eligible for two-pane display
                metadata = TwoPaneScene.twoPane()
            ) { key ->
                Text("Product Detail: ${key.id} (Two-Pane Eligible)")
            }
            // ... other entries ...
        },
        // Simply provide your custom strategy. NavDisplay will fall back to SinglePaneSceneStrategy automatically.
        sceneStrategy = TwoPaneSceneStrategy<Any>(),
        onBack = { count ->
            repeat(count) {
                if (backStack.isNotEmpty()) {
                    backStack.removeLastOrNull()
                }
            }
        }
    )
}

Material 적응형 장면에서 목록 세부정보 콘텐츠 표시

목록 세부정보 사용 사례의 경우 androidx.compose.material3.adaptive:adaptive-navigation3 아티팩트는 목록 세부정보 Scene를 만드는 ListDetailSceneStrategy를 제공합니다. 이 Scene는 복잡한 다중 창 배열 (목록, 세부정보, 추가 창)을 자동으로 처리하고 창 크기 및 기기 상태에 따라 조정합니다.

Material list-detail Scene을 만들려면 다음 단계를 따르세요.

  1. 종속 항목 추가: 프로젝트의 build.gradle.kts 파일에 androidx.compose.material3.adaptive:adaptive-navigation3를 포함합니다.
  2. ListDetailSceneStrategy 메타데이터로 항목 정의: listPane(), detailPane()extraPane()를 사용하여 적절한 창 표시를 위해 NavEntrys를 표시합니다. listPane() 도우미를 사용하면 항목이 선택되지 않은 경우 detailPlaceholder를 지정할 수도 있습니다.
  3. rememberListDetailSceneStrategy 사용(): 이 구성 가능한 함수는 NavDisplay에서 사용할 수 있는 사전 구성된 ListDetailSceneStrategy를 제공합니다.

다음 스니펫은 ListDetailSceneStrategy 사용을 보여주는 샘플 Activity입니다.

@Serializable
object ProductList : NavKey

@Serializable
data class ProductDetail(val id: String) : NavKey

@Serializable
data object Profile : NavKey

class MaterialListDetailActivity : ComponentActivity() {

    @OptIn(ExperimentalMaterial3AdaptiveApi::class)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            Scaffold { paddingValues ->
                val backStack = rememberNavBackStack(ProductList)
                val listDetailStrategy = rememberListDetailSceneStrategy<Any>()

                NavDisplay(
                    backStack = backStack,
                    modifier = Modifier.padding(paddingValues),
                    onBack = { keysToRemove -> repeat(keysToRemove) { backStack.removeLastOrNull() } },
                    sceneStrategy = listDetailStrategy,
                    entryProvider = entryProvider {
                        entry<ProductList>(
                            metadata = ListDetailSceneStrategy.listPane(
                                detailPlaceholder = {
                                    ContentYellow("Choose a product from the list")
                                }
                            )
                        ) {
                            ContentRed("Welcome to Nav3") {
                                Button(onClick = {
                                    backStack.add(ProductDetail("ABC"))
                                }) {
                                    Text("View product")
                                }
                            }
                        }
                        entry<ProductDetail>(
                            metadata = ListDetailSceneStrategy.detailPane()
                        ) { product ->
                            ContentBlue("Product ${product.id} ", Modifier.background(PastelBlue)) {
                                Column(horizontalAlignment = Alignment.CenterHorizontally) {
                                    Button(onClick = {
                                        backStack.add(Profile)
                                    }) {
                                        Text("View profile")
                                    }
                                }
                            }
                        }
                        entry<Profile>(
                            metadata = ListDetailSceneStrategy.extraPane()
                        ) {
                            ContentGreen("Profile")
                        }
                    }
                )
            }
        }
    }
}

그림 1. Material 목록 세부정보 장면에서 실행되는 콘텐츠 예시