Criar layouts personalizados usando cenas

O Navigation 3 apresenta um sistema eficiente e flexível para gerenciar o fluxo da interface do seu app usando Cenas. Com as cenas, é possível criar layouts altamente personalizados, adaptar-se a diferentes tamanhos de tela e gerenciar experiências complexas de vários painéis sem problemas.

Noções básicas sobre as cenas

Na Navegação 3, um Scene é a unidade fundamental que renderiza uma ou mais instâncias de NavEntry. Pense em um Scene como um estado visual ou uma seção distinta da sua interface que pode conter e gerenciar a exibição de conteúdo da sua pilha de retorno.

Cada instância Scene é identificada de maneira exclusiva pelo key e pela classe do próprio Scene. Esse identificador exclusivo é essencial porque impulsiona a animação de nível superior quando o Scene muda.

A interface Scene tem as seguintes propriedades:

  • key: Any: um identificador exclusivo para esta instância específica de Scene. Essa chave, combinada com a classe do Scene, garante a distinção, principalmente para fins de animação.
  • entries: List<NavEntry<T>>: uma lista de objetos NavEntry que o Scene é responsável por mostrar. É importante lembrar que, se o mesmo NavEntry for exibido em vários Scenes durante uma transição (por exemplo, em uma transição de elemento compartilhado), o conteúdo dele só será renderizado pelo Scene de destino mais recente que o estiver mostrando.
  • previousEntries: List<NavEntry<T>>: essa propriedade define os NavEntrys que resultariam se uma ação "voltar" ocorresse no Scene atual. É essencial para calcular o estado de retorno preditivo adequado, permitindo que o NavDisplay antecipe e faça a transição para o estado anterior correto, que pode ser uma cena com uma classe e/ou chave diferente.
  • content: @Composable () -> Unit: é a função combinável em que você define como o Scene renderiza o entries e os elementos da interface específicos desse Scene.

Entender as estratégias de cena

Um SceneStrategy é o mecanismo que determina como uma determinada lista de NavEntrys da pilha de retorno precisa ser organizada e transformada em uma Scene. Basicamente, quando apresentada às entradas atuais da pilha de retorno, uma SceneStrategy se faz duas perguntas principais:

  1. Posso criar um Scene com base nessas entradas? Se o SceneStrategy determinar que pode processar os NavEntrys especificados e formar um Scene significativo (por exemplo, uma caixa de diálogo ou um layout de vários painéis), ele vai continuar. Caso contrário, ele vai retornar null, dando a outras estratégias a chance de criar um Scene.
  2. Se sim, como devo organizar essas entradas no Scene?? Depois que um SceneStrategy se compromete a processar as entradas, ele assume a responsabilidade de construir um Scene e definir como os NavEntrys especificados serão mostrados nesse Scene.

O núcleo de um SceneStrategy é o método calculateScene:

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

Esse método é uma função de extensão em um SceneStrategyScope que usa o List<NavEntry<T>> atual da pilha de retorno. Ele precisa retornar um Scene<T> se puder formar um com as entradas fornecidas ou null se não puder.

O SceneStrategyScope é responsável por manter os argumentos opcionais que o SceneStrategy pode precisar, como um callback onBack.

O SceneStrategy também oferece uma função infixa then conveniente, permitindo encadear várias estratégias. Isso cria um pipeline flexível de tomada de decisões em que cada estratégia pode tentar calcular um Scene e, se não conseguir, delega para o próximo na cadeia.

Como as cenas e as estratégias de cena funcionam juntas

O NavDisplay é o elemento combinável central que observa a backstack e usa um SceneStrategy para determinar e renderizar o Scene adequado.

O parâmetro NavDisplay's sceneStrategy espera um SceneStrategy responsável por calcular o Scene a ser mostrado. Se nenhum Scene for calculado pela estratégia (ou cadeia de estratégias) fornecida, NavDisplay vai usar automaticamente um SinglePaneSceneStrategy por padrão.

Confira um resumo da interação:

  • Quando você adiciona ou remove chaves da pilha de retorno (por exemplo, usando backStack.add() ou backStack.removeLastOrNull()), o NavDisplay observa essas mudanças.
  • O NavDisplay transmite a lista atual de NavEntrys (derivada das chaves de pilha de retorno) ao método SceneStrategy's calculateScene configurado.
  • Se o SceneStrategy retornar um Scene, o NavDisplay vai renderizar o content desse Scene. O NavDisplay também gerencia animações e volta preditiva com base nas propriedades do Scene.

Exemplo: layout de painel único (comportamento padrão)

O layout personalizado mais simples é uma tela única, que é o comportamento padrão se nenhum outro SceneStrategy tiver precedência.

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

Exemplo: layout básico de lista e detalhes (cena e estratégia personalizadas)

Este exemplo mostra como criar um layout simples de lista-detalhe que é ativado com base em duas condições:

  1. A largura da janela é suficiente para acomodar dois painéis (ou seja, pelo menos WIDTH_DP_MEDIUM_LOWER_BOUND).
  2. A pilha de retorno contém entradas que declararam suporte para serem mostradas em um layout de lista-detalhe usando metadados específicos.

O snippet a seguir é o código-fonte de ListDetailScene.kt e contém ListDetailScene e ListDetailSceneStrategy:

// --- 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.containsKey(DETAIL_KEY) } ?: return null
        val listEntry = entries.findLast { it.metadata.containsKey(LIST_KEY) } ?: 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
        )
    }

    companion object {
        internal const val LIST_KEY = "ListDetailScene-List"
        internal const val DETAIL_KEY = "ListDetailScene-Detail"

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

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

Para usar esse ListDetailSceneStrategy no seu NavDisplay, modifique as chamadas de entryProvider para incluir metadados ListDetailScene.listPane() da entrada que você quer mostrar como um layout de lista e o ListDetailScene.detailPane() da entrada que você quer mostrar como um layout de detalhe. Em seguida, forneça ListDetailSceneStrategy() como seu sceneStrategy, usando o fallback padrão para cenários de painel único:

// 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() },
        sceneStrategy = 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)
}

Se você não quiser criar sua própria cena de detalhes e listas, use a cena de detalhes e listas do Material, que vem com detalhes sensíveis e o suporte para marcadores de posição, conforme mostrado na próxima seção.

Mostrar conteúdo de lista e detalhes em uma cena adaptável do Material

Para o caso de uso de detalhes e listas, o artefato androidx.compose.material3.adaptive:adaptive-navigation3 fornece um ListDetailSceneStrategy que cria um Scene de detalhes e listas. Esse Scene processa automaticamente arranjos complexos de vários painéis (lista, detalhes e painéis extras) e os adapta com base no tamanho da janela e no estado do dispositivo.

Para criar um Scene de lista-detalhe do Material, siga estas etapas:

  1. Adicione a dependência: inclua androidx.compose.material3.adaptive:adaptive-navigation3 no arquivo build.gradle.kts do projeto.
  2. Defina suas entradas com metadados ListDetailSceneStrategy: use listPane(), detailPane() e extraPane() para marcar seu NavEntrys para mostrar no painel apropriado. O helper listPane() também permite especificar um detailPlaceholder quando nenhum item está selecionado.
  3. Use rememberListDetailSceneStrategy(): essa função combinável fornece um ListDetailSceneStrategy pré-configurado que pode ser usado por um NavDisplay.

O snippet a seguir é um exemplo de Activity que demonstra o uso de ListDetailSceneStrategy:

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

Figura 1. Exemplo de conteúdo em execução na cena de detalhes e lista do Material.