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

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

장면 이해

Navigation 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 요소를 렌더링하는 방법을 정의하는 컴포저블 함수입니다.
  • metadata: Map<String, Any>: NavDisplay와 같은 다른 라이브러리 구성요소에 장면별 정보를 제공합니다. 기본적으로 entries의 마지막 NavEntrymetadata를 반환합니다.

장면 전략 이해

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

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

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

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

이 메서드는 백 스택에서 현재 List<NavEntry<T>>를 가져오는 SceneStrategyScope의 확장 함수입니다. 제공된 항목에서 Scene<T> 를 성공적으로 형성할 수 있는 경우 Scene<T> 를 반환하고, 그렇지 않은 경우 null if it 반환해야 합니다.

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

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

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

NavDisplaysceneStrategies 매개변수는 표시할 Scene을 계산하는 SceneStrategy 인스턴스 목록을 예상합니다. 제공된 전략으로 Scene이 계산되지 않으면 NavDisplay는 기본적으로 을 사용하도록 자동으로SinglePaneSceneStrategy 대체됩니다.

상호작용에 대한 분석은 다음과 같습니다.

  • 백 스택에서 키를 추가하거나 삭제하면 (backStack.add() 또는 backStack.removeLastOrNull() 사용) NavDisplay가 이러한 변경사항을 관찰합니다.
  • NavDisplayScene이 반환될 때까지 각 항목에서 calculateScene을 호출하여 백 스택 키에서 파생된 현재 NavEntry 목록을 구성된 sceneStrategies에 순서대로 전달합니다.
  • 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)
        )
}

예: 기본 목록-세부정보 레이아웃 (맞춤 장면 및 전략)

이 예에서는 다음 두 가지 조건을 기반으로 활성화되는 간단한 목록-세부정보 레이아웃을 만드는 방법을 보여줍니다.

  1. 창 너비 가 두 개의 창을 지원할 만큼 충분히 넓습니다 (즉, WIDTH_DP_MEDIUM_LOWER_BOUND 이상).
  2. 백 스택에는 특정 메타데이터를 사용하여 목록-세부정보 레이아웃에 표시되도록 지원을 선언한 항목이 포함되어 있습니다.

다음 스니펫은 ListDetailScene.kt의 소스 코드이며 에는 ListDetailSceneListDetailSceneStrategy가 모두 포함되어 있습니다.

// --- ListDetailScene ---
/**
 * A [Scene] that displays a list and a detail [NavEntry] side-by-side in a 40/60 split.
 *
 */
class ListDetailScene<T : Any>(
    override val key: Any,
    override val previousEntries: List<NavEntry<T>>,
    val listEntry: NavEntry<T>,
    val detailEntry: NavEntry<T>,
) : Scene<T> {
    override val entries: List<NavEntry<T>> = listOf(listEntry, detailEntry)
    override val content: @Composable (() -> Unit) = {
        Row(modifier = Modifier.fillMaxSize()) {
            Column(modifier = Modifier.weight(0.4f)) {
                listEntry.Content()
            }
            Column(modifier = Modifier.weight(0.6f)) {
                detailEntry.Content()
            }
        }
    }
}

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

    return remember(windowSizeClass) {
        ListDetailSceneStrategy(windowSizeClass)
    }
}

// --- ListDetailSceneStrategy ---
/**
 * A [SceneStrategy] that returns a [ListDetailScene] if the window is wide enough, the last item
 * is the backstack is a detail, and before it, at any point in the backstack is a list.
 */
class ListDetailSceneStrategy<T : Any>(val windowSizeClass: WindowSizeClass) : SceneStrategy<T> {

    override fun SceneStrategyScope<T>.calculateScene(entries: List<NavEntry<T>>): Scene<T>? {

        if (!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) {
            return null
        }

        val detailEntry =
            entries.lastOrNull()?.takeIf { it.metadata.contains(DetailKey) } ?: return null
        val listEntry = entries.findLast { it.metadata.contains(ListKey) } ?: return null

        // We use the list's contentKey to uniquely identify the scene.
        // This allows the detail panes to be displayed instantly through recomposition, rather than
        // having NavDisplay animate the whole scene out when the selected detail item changes.
        val sceneKey = listEntry.contentKey

        return ListDetailScene(
            key = sceneKey,
            previousEntries = entries.dropLast(1),
            listEntry = listEntry,
            detailEntry = detailEntry
        )
    }

    object ListKey : NavMetadataKey<Boolean>
    object DetailKey : NavMetadataKey<Boolean>
    companion object {

        /**
         * Helper function to add metadata to a [NavEntry] indicating it can be displayed
         * as a list in the [ListDetailScene].
         */
        fun listPane() = metadata {
            put(ListKey, true)
        }

        /**
         * Helper function to add metadata to a [NavEntry] indicating it can be displayed
         * as a list in the [ListDetailScene].
         */
        fun detailPane() = metadata {
            put(DetailKey, true)
        }
    }
}

NavDisplay에서 이 ListDetailSceneStrategy를 사용하려면 목록 레이아웃으로 표시하려는 항목의 ListDetailScene.listPane() 메타데이터와 세부정보 레이아웃으로 표시하려는 항목의 ListDetailScene.detailPane()을 포함하도록 entryProvider 호출을 수정합니다. 그런 다음 ListDetailSceneStrategy()sceneStrategy로 제공하여 단일 창 시나리오의 기본 대체에 의존합니다.

// Define your navigation keys
@Serializable
data object ConversationList : NavKey

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

@Composable
fun MyAppContent() {
    val backStack = rememberNavBackStack(ConversationList)
    val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()

    NavDisplay(
        backStack = backStack,
        onBack = { backStack.removeLastOrNull() },
        sceneStrategies = listOf(listDetailStrategy),
        entryProvider = entryProvider {
            entry<ConversationList>(
                metadata = ListDetailSceneStrategy.listPane()
            ) {
                Column(modifier = Modifier.fillMaxSize()) {
                    Text(text = "I'm a Conversation List")
                    Button(onClick = { backStack.addDetail(ConversationDetail("123")) }) {
                        Text(text = "Open detail")
                    }
                }
            }
            entry<ConversationDetail>(
                metadata = ListDetailSceneStrategy.detailPane()
            ) {
                Text(text = "I'm a Conversation Detail")
            }
        }
    )
}

private fun NavBackStack<NavKey>.addDetail(detailRoute: ConversationDetail) {

    // Remove any existing detail routes, then add the new detail route
    removeIf { it is ConversationDetail }
    add(detailRoute)
}

자체 목록-세부정보 장면을 만들고 싶지 않다면 다음 섹션에서 보여주는 것처럼 적절한 세부정보와 자리표시자 지원이 제공되는 Material 목록-세부정보 장면을 사용할 수 있습니다.

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

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

Material 목록-세부정보 Scene을 만들려면 다음 단계를 따르세요.

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

다음 스니펫은 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() },
                    sceneStrategies = listOf(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 목록-세부정보 장면에서 실행되는 콘텐츠 예