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 seção distinta da sua interface que pode conter e gerenciar a exibição de conteúdo da sua backstack.

Cada instância Scene é identificada de maneira exclusiva pelo key e pela classe do próprio Scene. Esse identificador exclusivo é crucial 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 mostrado 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 exibindo.
  • 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 volta preditiva 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.
  • metadata: Map<String, Any>: fornece informações específicas da cena a outros componentes da biblioteca, como NavDisplay. Por padrão, retorna o metadata do último NavEntry em entries.

Entender as estratégias de cena

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

  1. Posso criar um Scene com essas 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 do backstack. 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.

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 detalhamento da interação:

  • Quando você adiciona ou remove chaves do backstack (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 backstack) 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 a volta preditiva com base nas propriedades do Scene.

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

O layout personalizado mais simples é uma tela de painel único, 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 detalhes e listas (cena e estratégia personalizadas)

Este exemplo mostra como criar um layout simples de detalhes e listas 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 backstack contém entradas que declararam suporte para serem mostradas em um layout de detalhes e listas 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.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)
        }
    }
}

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

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 detalhes e listas em uma cena adaptativa 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. Isso 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 detalhes e listas 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() },
                    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")
                        }
                    }
                )
            }
        }
    }
}

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