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

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>>를 가져오는 SceneStrategyScope의 확장 함수입니다. 제공된 항목에서 항목을 성공적으로 형성할 수 있는 경우 Scene<T>를 반환하고, 그렇지 않은 경우 null를 반환해야 합니다.

SceneStrategyScopeSceneStrategy에 필요할 수 있는 선택적 인수(예: onBack 콜백)를 유지 관리합니다.

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: Any,
    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() }
}

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

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

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

  1. 창 너비가 두 개의 창을 지원할 만큼 충분히 넓습니다 (즉, WIDTH_DP_MEDIUM_LOWER_BOUND 이상).
  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()
            }
            Column(modifier = Modifier.weight(0.5f)) {
                secondEntry.Content()
            }
        }
    }

    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)
    }
}

@Composable
fun <T : Any> rememberTwoPaneSceneStrategy(): TwoPaneSceneStrategy<T> {
    val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass

    return remember(windowSizeClass) {
        TwoPaneSceneStrategy(windowSizeClass)
    }
}

// --- 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>(val windowSizeClass: WindowSizeClass) : SceneStrategy<T> {
    override fun SceneStrategyScope<T>.calculateScene(entries: List<NavEntry<T>>): Scene<T>? {
        // 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.contentKey, secondEntry.contentKey)

            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 호출을 수정하여 투 패널 레이아웃에 표시하려는 항목의 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 = rememberTwoPaneSceneStrategy(),
        onBack = {
            if (backStack.isNotEmpty()) {
                backStack.removeLastOrNull()
            }
        }
    )
}

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

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

Material 목록-세부정보 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<NavKey>()

                NavDisplay(
                    backStack = backStack,
                    modifier = Modifier.padding(paddingValues),
                    onBack = { 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 목록-세부정보 장면에서 실행되는 콘텐츠의 예