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 escenas te permiten crear diseños muy personalizados, adaptarte a diferentes tamaños de pantalla y administrar experiencias complejas de varios paneles sin problemas.

Información sobre las escenas

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

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

La interfaz Scene tiene las siguientes propiedades:

  • key: Any: Es un identificador único para esta instancia de Scene específica. Esta clave, combinada con la clase de Scene, garantiza la distinción, principalmente con fines de animación.
  • entries: List<NavEntry<T>>: Es una lista de objetos NavEntry que el Scene se encarga de mostrar. Es importante destacar que, si se muestra el mismo NavEntry en varios Scenes durante una transición (p.ej., en una transición de elementos compartidos), su contenido solo se renderizará con el Scene de destino más reciente que lo muestre.
  • previousEntries: List<NavEntry<T>>: Esta propiedad define los objetos NavEntry que se generarían si se produce una acción "atrás" desde el objeto 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 escena con una clase o clave diferente, o ambas.
  • content: @Composable () -> Unit: Esta es la función de componibilidad en la que defines cómo el Scene renderiza su 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, devuelve el metadata del último NavEntry en entries.

Implementa equals y hashCode en escenas personalizadas

Las implementaciones personalizadas de Scene deben implementar correctamente equals y hashCode para admitir lo siguiente:

  • Transiciones de estado de la escena: Cada vez que la lista de estrategias de escena calcula una escena, NavDisplay verifica la igualdad de la escena nueva y la anterior. Si detecta un cambio, actualiza el SeekableTransition que se usa para realizar la transición entre escenas, lo que, a su vez, afecta el estado del ciclo de vida de las escenas.
  • Administración de superposiciones: Para los OverlayScene (como los diálogos), NavDisplay usa el objeto de escena como un key para hacer un seguimiento de los ciclos de vida de las superposiciones y las animaciones de salida.

Verifica que todas las propiedades que definen el contenido de la escena, como key, entries y previousEntries, se incluyan en las implementaciones de equals y hashCode. En general, evita incluir devoluciones de llamada (como onBack) en la implementación de estos métodos, ya que pueden cambiar las instancias sin cambiar el estado lógico de la escena.

Comprende las estrategias de escenas

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

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

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

@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 el List<NavEntry<T>> actual de la pila de actividades. Debería devolver 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 pueda necesitar el SceneStrategy, como una devolución de llamada de onBack.

Cómo funcionan juntas las escenas y las estrategias de escenas

NavDisplay es el elemento componible central que observa tu pila de actividades y usa uno o más SceneStrategy para determinar y renderizar el Scene adecuado.

El parámetro sceneStrategies de NavDisplay espera una lista de instancias de SceneStrategy que son responsables de calcular el Scene que se mostrará. Si las estrategias proporcionadas no calculan ningún Scene, NavDisplay recurre automáticamente al uso de un SinglePaneSceneStrategy de forma predeterminada.

Aquí tienes un desglose de la interacción:

  • Cuando agregas o quitas claves de tu pila de actividades (p.ej., con backStack.add() o backStack.removeLastOrNull()), NavDisplay observa estos cambios.
  • El NavDisplay pasa la lista actual de NavEntry (derivados de las claves de la pila de actividades) al sceneStrategies configurado en orden, llamando a calculateScene en cada uno hasta que se devuelva un Scene.
  • Cuando un SceneStrategy devuelve correctamente un Scene, el NavDisplay renderiza el content de ese Scene. NavDisplay también administra las animaciones y el gesto atrás predictivo según las propiedades de Scene.

Ejemplo: Diseño de panel único (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 no tiene prioridad ningún otro 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)
        )
}

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

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

  1. El ancho de la ventana es lo suficientemente amplio como 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.
 *
 */
data 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 este ListDetailSceneStrategy en tu NavDisplay, modifica tus llamadas a entryProvider para incluir los metadatos de ListDetailScene.listPane() de la entrada que quieres mostrar como un diseño de lista y el ListDetailScene.detailPane() de la entrada que quieres mostrar como un diseño de detalle. Luego, proporciona ListDetailSceneStrategy() como tu sceneStrategy, confiando en el resguardo predeterminado para situaciones de panel ú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)
}

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

Mostrar contenido de lista-detalles en una escena adaptable de Material

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

Para crear un 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 metadatos de ListDetailSceneStrategy: Usa listPane(), detailPane() y extraPane() para marcar tu NavEntrys para que se muestre el panel adecuado. El ayudante listPane() también te permite especificar un detailPlaceholder cuando no se selecciona ningún elemento.
  3. Usa rememberListDetailSceneStrategy(): Esta función de componibilidad proporciona un ListDetailSceneStrategy preconfigurado que puede usar un NavDisplay.

El siguiente fragmento es un Activity de ejemplo que muestra 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 escena de lista-detalles de Material.