Typowy schemat interfejsu

Ten przepis pokazuje, jak wdrożyć typowy wzorzec interfejsu nawigacji z dolnym paskiem nawigacyjnym i kilkoma stosami wstecznymi, w którym każda karta na pasku nawigacyjnym ma własną historię nawigacji.

Jak to działa

Ten przykład zawiera 3 miejsca docelowe najwyższego poziomu: Home, ChatListCamera. Miejsce docelowe ChatList ma też trasę podrzędną ChatDetail.

TopLevelBackStack

Podstawą tego przepisu jest klasa TopLevelBackStack, która odpowiada za zarządzanie stanem nawigacji. Działa to w ten sposób:

  • Dla każdego miejsca docelowego najwyższego poziomu (karty) utrzymuje oddzielny stos wsteczny.
  • Śledzi aktualnie wybrane miejsce docelowe najwyższego poziomu.
  • Zapewnia pojedynczy, spłaszczony stos wsteczny, który może być używany przez funkcję kompozycyjną NavDisplay. Spłaszczona lista wsteczna to połączenie poszczególnych list wstecznych wszystkich kart.

Struktura interfejsu

Interfejs użytkownika jest tworzony za pomocą komponentu Scaffold, a NavigationBar jest jego bottomBar.

  • NavigationBar wyświetla element dla każdego miejsca docelowego najwyższego poziomu. Gdy użytkownik kliknie element, wywoływana jest funkcja topLevelBackStack.addTopLevel, która przełącza się na odpowiednią kartę, zachowując historię nawigacji każdej karty.
  • Kompozycja NavDisplay jest umieszczana w obszarze treści Scaffold. Jest on odpowiedzialny za wyświetlanie bieżącego ekranu na podstawie spłaszczonej listy wstecznej dostarczonej przez TopLevelBackStack.

Takie podejście umożliwia stosowanie wspólnego wzorca nawigacji, w którym użytkownicy mogą przełączać się między różnymi sekcjami aplikacji, a każda sekcja zachowuje własną historię nawigacji.

Zachowywanie stanu

Zwróć uwagę na sposób zarządzania stanem nawigacji w tym przepisie. Gdy użytkownik opuści miejsce docelowe najwyższego poziomu (np. naciskając przycisk Wstecz, aż wróci do poprzedniej karty), cała historia nawigacji dla tego miejsca docelowego zostanie wyczyszczona. Stan nie jest zapisywany. Gdy użytkownik wróci później do tej karty, zacznie od jej ekranu początkowego.

Uwaga: w tym przykładzie trasa Home może znajdować się powyżej tras ChatListCamera, co oznacza, że powrót z trasy Home nie musi powodować zamknięcia aplikacji. Aplikacja zostanie zamknięta, gdy użytkownik wróci z jednej pozostałej trasy najwyższego poziomu na liście wstecznej.

/*
 * Copyright 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.example.nav3recipes.commonui

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Face
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.compose.dropUnlessResumed
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.NavDisplay
import com.example.nav3recipes.content.ContentBlue
import com.example.nav3recipes.content.ContentGreen
import com.example.nav3recipes.content.ContentPurple
import com.example.nav3recipes.content.ContentRed
import com.example.nav3recipes.ui.setEdgeToEdgeConfig

private sealed interface TopLevelRoute {
    val icon: ImageVector
}
private data object Home : TopLevelRoute { override val icon = Icons.Default.Home }
private data object ChatList : TopLevelRoute { override val icon = Icons.Default.Face }
private data object ChatDetail
private data object Camera : TopLevelRoute { override val icon = Icons.Default.PlayArrow }

private val TOP_LEVEL_ROUTES : List<TopLevelRoute> = listOf(Home, ChatList, Camera)

class CommonUiActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        setEdgeToEdgeConfig()
        super.onCreate(savedInstanceState)
        setContent {
            val topLevelBackStack = remember { TopLevelBackStack<Any>(Home) }

            Scaffold(
                bottomBar = {
                    NavigationBar {
                        TOP_LEVEL_ROUTES.forEach { topLevelRoute ->

                            val isSelected = topLevelRoute == topLevelBackStack.topLevelKey
                            NavigationBarItem(
                                selected = isSelected,
                                onClick = {
                                    topLevelBackStack.addTopLevel(topLevelRoute)
                                },
                                icon = {
                                    Icon(
                                        imageVector = topLevelRoute.icon,
                                        contentDescription = null
                                    )
                                }
                            )
                        }
                    }
                }
            ) { _ ->
                NavDisplay(
                    backStack = topLevelBackStack.backStack,
                    onBack = { topLevelBackStack.removeLast() },
                    entryProvider = entryProvider {
                        entry<Home>{
                            ContentRed("Home screen")
                        }
                        entry<ChatList>{
                            ContentGreen("Chat list screen"){
                                Button(onClick = dropUnlessResumed {
                                    topLevelBackStack.add(ChatDetail)
                                }) {
                                    Text("Go to conversation")
                                }
                            }
                        }
                        entry<ChatDetail>{
                            ContentBlue("Chat detail screen")
                        }
                        entry<Camera>{
                            ContentPurple("Camera screen")
                        }
                    },
                )
            }
        }
    }
}

class TopLevelBackStack<T: Any>(startKey: T) {

    // Maintain a stack for each top level route
    private var topLevelStacks : LinkedHashMap<T, SnapshotStateList<T>> = linkedMapOf(
        startKey to mutableStateListOf(startKey)
    )

    // Expose the current top level route for consumers
    var topLevelKey by mutableStateOf(startKey)
        private set

    // Expose the back stack so it can be rendered by the NavDisplay
    val backStack = mutableStateListOf(startKey)

    private fun updateBackStack() =
        backStack.apply {
            clear()
            addAll(topLevelStacks.flatMap { it.value })
        }

    fun addTopLevel(key: T){

        // If the top level doesn't exist, add it
        if (topLevelStacks[key] == null){
            topLevelStacks.put(key, mutableStateListOf(key))
        } else {
            // Otherwise just move it to the end of the stacks
            topLevelStacks.apply {
                remove(key)?.let {
                    put(key, it)
                }
            }
        }
        topLevelKey = key
        updateBackStack()
    }

    fun add(key: T){
        topLevelStacks[topLevelKey]?.add(key)
        updateBackStack()
    }

    fun removeLast(){
        val removedKey = topLevelStacks[topLevelKey]?.removeLastOrNull()
        // If the removed key was a top level key, remove the associated top level stack
        topLevelStacks.remove(removedKey)
        topLevelKey = topLevelStacks.keys.last()
        updateBackStack()
    }
}