Crea diseños personalizados con escenas

Navigation 3 presenta un sistema potente y flexible para administrar el flujo de la IU de tu app a través de Scenes. Las Scenes te permiten crear diseños altamente personalizados, adaptarte a diferentes tamaños de pantalla y administrar experiencias complejas de varios paneles sin problemas.

Información sobre las Scenes

En Navigation 3, un Scene es la unidad fundamental que renderiza una o más instancias de NavEntry. Piensa en una Scene como un estado visual o una sección distinta de tu IU que puede contener y administrar la visualización de contenido de tu pila de actividades.

Cada instancia de Scene se identifica de forma única por su key y la clase de la Scene en sí. Este identificador único es fundamental porque controla la animación de nivel superior cuando cambia Scene.

La interfaz Scene tiene las siguientes propiedades:

  • key: Any: Es un identificador único para esta instancia específica de Scene. Esta clave, combinada con la clase de Scene, garantiza la distinción, principalmente para fines de animación.
  • entries: List<NavEntry<T>>: Es una lista de objetos NavEntry que la Scene es responsable de mostrar. Es importante destacar que, si la misma NavEntry se muestra en varias Scenes durante una transición (p.ej., en una transición de elementos compartidos), su contenido solo se renderizará con la Scene de destino más reciente que lo muestre.
  • previousEntries: List<NavEntry<T>>: Esta propiedad define los NavEntry que se producirían si se realiza una acción "atrás" desde el Scene actual. Es esencial para calcular el estado de atrás predictivo adecuado, lo que permite que NavDisplay anticipe y realice la transición al estado anterior correcto, que puede ser una Scene con una clase o clave diferente.
  • content: @Composable () -> Unit: Es la función de componibilidad en la que defines cómo el Scene renderiza sus entries y cualquier elemento de la IU circundante específico de ese Scene.
  • metadata: Map<String, Any>: Proporciona información específica de la escena a otros componentes de la biblioteca, como NavDisplay. De forma predeterminada, muestra los metadata de la última NavEntry en entries.

Información sobre las estrategias de escena

Un SceneStrategy es el mecanismo que determina cómo se debe organizar una lista determinada de NavEntry de la pila de actividades y cómo se debe realizar la transición a un Scene. Básicamente, cuando se presentan las entradas actuales de la pila de actividades, una SceneStrategy se hace dos preguntas clave:

  1. ¿Puedo crear un Scene a partir de estas entradas? Si la SceneStrategy determina que puede controlar las NavEntry dadas y formar una Scene (p.ej., un diálogo o un diseño de varios paneles), continúa. De lo contrario, muestra null, lo que les da a otras estrategias la oportunidad de crear una Scene.
  2. Si es así, ¿cómo debo organizar esas entradas en la Scene? Una vez que una SceneStrategy se compromete a controlar las entradas, asume la responsabilidad de construir una Scene y definir cómo se mostrarán las NavEntry especificadas dentro de esa Scene.

El núcleo de un SceneStrategy es su calculateScene método:

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

Este método es una función de extensión en un SceneStrategyScope que toma la actual List<NavEntry<T>> de la pila de actividades. Debe mostrar un Scene<T> si puede formar uno correctamente a partir de las entradas proporcionadas o null si no puede.

El SceneStrategyScope es responsable de mantener los argumentos opcionales que el SceneStrategy podría necesitar, como una devolución de llamada onBack.

Cómo funcionan juntas las Scenes y las estrategias de escena

El NavDisplay es el componible central que observa tu pila de actividades y usa una o más SceneStrategy para determinar y renderizar la Scene adecuada.

El parámetro sceneStrategies de NavDisplay's espera una lista de instancias de SceneStrategy que son responsables de calcular la Scene que se mostrará. Si las estrategias proporcionadas no calculan ninguna Scene, NavDisplay vuelve automáticamente a usar una SinglePaneSceneStrategy de forma predeterminada.

Este es un desglose de la interacción:

  • Cuando agregas o quitas claves de tu pila de actividades (p.ej., con backStack.add() o backStack.removeLastOrNull()), el NavDisplay observa estos cambios.
  • El NavDisplay pasa la lista actual de NavEntry (derivada de las claves de la pila de actividades) a las sceneStrategies configuradas en orden, y llama a calculateScene en cada una hasta que se muestra una Scene.
  • Cuando un SceneStrategy muestra correctamente un Scene, el NavDisplay entonces renderiza el content de ese Scene. El NavDisplay también administra las animaciones y el atrás predictivo en función de las propiedades de la Scene.

Ejemplo: Diseño de un solo panel (comportamiento predeterminado)

El diseño personalizado más simple que puedes tener es una pantalla de un solo panel, que es el comportamiento predeterminado si ninguna otra SceneStrategy tiene prioridad.

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

Ejemplo: Diseño básico de lista-detalles (Scene y Strategy personalizadas)

En este ejemplo, se muestra cómo crear un diseño simple de lista-detalles que se activa en función de dos condiciones:

  1. El ancho de la ventana es lo suficientemente ancho para admitir dos paneles (es decir, al menos WIDTH_DP_MEDIUM_LOWER_BOUND).
  2. La pila de actividades contiene entradas que declararon su compatibilidad para mostrarse en un diseño de lista-detalles con metadatos específicos.

El siguiente fragmento es el código fuente de ListDetailScene.kt y contiene ListDetailScene y 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 esta ListDetailSceneStrategy en tu NavDisplay, modifica tus llamadas a entryProvider para incluir metadatos ListDetailScene.listPane() para la entrada que deseas mostrar como un diseño de lista y ListDetailScene.detailPane() para la entrada que deseas mostrar como diseño de detalles. Luego, proporciona ListDetailSceneStrategy() como tu sceneStrategy, y confía en el resguardo predeterminado para situaciones de un solo panel:

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

Si no deseas crear tu propia escena de lista-detalles, puedes usar la escena de lista-detalles de Material, que incluye detalles sensibles y compatibilidad con marcadores de posición, como se muestra en la siguiente sección.

Cómo mostrar contenido de lista-detalles en una Scene adaptable de Material

Para el caso de uso de lista-detalles, el androidx.compose.material3.adaptive:adaptive-navigation3 artefacto proporciona un ListDetailSceneStrategy que crea una Scene de lista-detalles. Esta Scene controla automáticamente las disposiciones complejas de varios paneles (lista, detalles y paneles adicionales) y las adapta según el tamaño de la ventana y el estado del dispositivo.

Para crear una Scene de lista-detalles de Material, sigue estos pasos:

  1. Agrega la dependencia: Incluye androidx.compose.material3.adaptive:adaptive-navigation3 en el archivo build.gradle.kts de tu proyecto.
  2. Define tus entradas con ListDetailSceneStrategy metadatos: Usa listPane(), detailPane(), y extraPane() para marcar tus NavEntrys para la visualización adecuada del panel. El objeto auxiliar listPane() también te permite especificar un detailPlaceholder cuando no se selecciona ningún elemento.
  3. Usa rememberListDetailSceneStrategy(): Esta función de componibilidad proporciona una ListDetailSceneStrategy preconfigurada que puede usar un NavDisplay.

El siguiente fragmento es una muestra de Activity que demuestra el 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: Ejemplo de contenido que se ejecuta en la Scene de lista-detalles de Material.