Nawigacja przy tworzeniu wiadomości

Komponent nawigacji obsługuje aplikacje Jetpack Compose. Możesz przechodzić między funkcjami kompozycyjnymi, korzystając z infrastruktury i funkcji komponentu Navigation.

Najnowszą bibliotekę nawigacji w wersji alfa, stworzoną specjalnie z myślą o Compose, znajdziesz w dokumentacji Navigation 3.

Konfiguracja

Aby obsługiwać Compose, użyj w pliku build.gradle modułu aplikacji tej zależności:

Groovy

dependencies {
    def nav_version = "2.9.1"

    implementation "androidx.navigation:navigation-compose:$nav_version"
}

Kotlin

dependencies {
    val nav_version = "2.9.1"

    implementation("androidx.navigation:navigation-compose:$nav_version")
}

Rozpocznij

Podczas wdrażania nawigacji w aplikacji zaimplementuj hosta nawigacji, wykres i kontroler. Więcej informacji znajdziesz w omówieniu Nawigacji.

Informacje o tworzeniu NavController w Compose znajdziesz w sekcji Compose w artykule Tworzenie kontrolera nawigacji.

Tworzenie elementu NavHost

Informacje o tworzeniu elementu NavHost w Compose znajdziesz w sekcji Compose w artykule Projektowanie wykresu nawigacji.

Informacje o przechodzeniu do funkcji Composable znajdziesz w sekcji Przechodzenie do miejsca docelowego w dokumentacji architektury.

Informacje o przekazywaniu argumentów między miejscami docelowymi w kompozycji znajdziesz w sekcji poświęconej kompozycji w artykule Projektowanie wykresu nawigacji.

Pobieranie złożonych danych podczas nawigacji

Podczas nawigacji zdecydowanie nie zaleca się przekazywania złożonych obiektów danych. Zamiast tego podczas wykonywania działań nawigacyjnych należy przekazywać jako argumenty minimum niezbędnych informacji, takich jak unikalny identyfikator lub inna forma identyfikatora:

// Pass only the user ID when navigating to a new destination as argument
navController.navigate(Profile(id = "user1234"))

Złożone obiekty powinny być przechowywane jako dane w jednym źródle informacji, np. w warstwie danych. Po dotarciu do miejsca docelowego możesz za pomocą przekazanego identyfikatora wczytać wymagane informacje z jednego źródła danych. Aby pobrać argumenty w funkcji ViewModel, która odpowiada za dostęp do warstwy danych, użyj funkcji SavedStateHandle funkcji ViewModel:

class UserViewModel(
    savedStateHandle: SavedStateHandle,
    private val userInfoRepository: UserInfoRepository
) : ViewModel() {

    private val profile = savedStateHandle.toRoute<Profile>()

    // Fetch the relevant user information from the data layer,
    // ie. userInfoRepository, based on the passed userId argument
    private val userInfo: Flow<UserInfo> = userInfoRepository.getUserInfo(profile.id)

// …

}

Takie podejście pomaga zapobiegać utracie danych podczas wprowadzania zmian w konfiguracji i wszelkim niespójnościom podczas aktualizowania lub modyfikowania danego obiektu.

Bardziej szczegółowe wyjaśnienie, dlaczego należy unikać przekazywania złożonych danych jako argumentów, oraz listę obsługiwanych typów argumentów znajdziesz w artykule Przekazywanie danych między miejscami docelowymi.

Navigation Compose obsługuje precyzyjne linki, które można zdefiniować w ramach funkcji composable(). Jego parametr deepLinks akceptuje listę obiektów NavDeepLink, które można szybko utworzyć za pomocą metody navDeepLink():

@Serializable data class Profile(val id: String)
val uri = "https://www.example.com"

composable<Profile>(
  deepLinks = listOf(
    navDeepLink<Profile>(basePath = "$uri/profile")
  )
) { backStackEntry ->
  ProfileScreen(id = backStackEntry.toRoute<Profile>().id)
}

Te precyzyjne linki umożliwiają powiązanie określonego adresu URL, działania lub typu MIME z komponentem. Domyślnie te precyzyjne linki nie są udostępniane aplikacjom zewnętrznym. Aby udostępnić te precyzyjne linki na zewnątrz, musisz dodać odpowiednie elementy <intent-filter> do pliku manifest.xml aplikacji. Aby włączyć precyzyjny link w przykładzie powyżej, dodaj w elemencie <activity> pliku manifestu te elementy:

<activity …>
  <intent-filter>
    ...
    <data android:scheme="https" android:host="www.example.com" />
  </intent-filter>
</activity>

Gdy precyzyjny link zostanie wywołany przez inną aplikację, nawigacja automatycznie przekieruje do tego komponentu.

Tych samych precyzyjnych linków można też używać do tworzenia PendingIntent z odpowiednim precyzyjnym linkiem z elementu kompozycyjnego:

val id = "exampleId"
val context = LocalContext.current
val deepLinkIntent = Intent(
    Intent.ACTION_VIEW,
    "https://www.example.com/profile/$id".toUri(),
    context,
    MyActivity::class.java
)

val deepLinkPendingIntent: PendingIntent? = TaskStackBuilder.create(context).run {
    addNextIntentWithParentStack(deepLinkIntent)
    getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT)
}

Możesz użyć tego deepLinkPendingIntent tak jak każdego innego PendingIntent, aby otworzyć aplikację w miejscu docelowym precyzyjnego linku.

Zagnieżdżona nawigacja

Informacje o tworzeniu zagnieżdżonych wykresów nawigacji znajdziesz w artykule Zagnieżdżone wykresy.

Integracja z dolnym paskiem nawigacyjnym

Definiując NavController na wyższym poziomie w hierarchii komponentów, możesz połączyć nawigację z innymi komponentami, np. z nawigacją u dołu. Dzięki temu możesz poruszać się po aplikacji, wybierając ikony na pasku u dołu.

Aby używać komponentów BottomNavigationBottomNavigationItem, dodaj do aplikacji na Androida zależność androidx.compose.material.

Groovy

dependencies {
    implementation "androidx.compose.material:material:1.8.3"
}

android {
    buildFeatures {
        compose true
    }

    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.15"
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }
}

Kotlin

dependencies {
    implementation("androidx.compose.material:material:1.8.3")
}

android {
    buildFeatures {
        compose = true
    }

    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.15"
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }
}

Aby połączyć elementy na pasku nawigacyjnym u dołu z trasami na wykresie nawigacji, zalecamy zdefiniowanie klasy, takiej jak TopLevelRoute, która ma klasę trasy i ikonę.

data class TopLevelRoute<T : Any>(val name: String, val route: T, val icon: ImageVector)

Następnie umieść te trasy na liście, której może używać BottomNavigationItem:

val topLevelRoutes = listOf(
   TopLevelRoute("Profile", Profile, Icons.Profile),
   TopLevelRoute("Friends", Friends, Icons.Friends)
)

W funkcji kompozycyjnej BottomNavigation pobierz bieżący NavBackStackEntry za pomocą funkcji currentBackStackEntryAsState(). Ta pozycja zapewnia dostęp do bieżącej wersji NavDestination. Wybrany stan każdego elementu BottomNavigationItem można następnie określić, porównując ścieżkę elementu ze ścieżką bieżącego miejsca docelowego i jego nadrzędnych miejsc docelowych, aby obsługiwać przypadki, w których używasz zagnieżdżonej nawigacji za pomocą hierarchii NavDestination.

Ścieżka elementu jest też używana do połączenia funkcji Lambda onClick z wywołaniem funkcji navigate, dzięki czemu po kliknięciu elementu użytkownik przechodzi do niego. Dzięki flagom saveStaterestoreState stan i stos wsteczny tego elementu są prawidłowo zapisywane i przywracane podczas przełączania się między elementami nawigacji u dołu ekranu.

val navController = rememberNavController()
Scaffold(
  bottomBar = {
    BottomNavigation {
      val navBackStackEntry by navController.currentBackStackEntryAsState()
      val currentDestination = navBackStackEntry?.destination
      topLevelRoutes.forEach { topLevelRoute ->
        BottomNavigationItem(
          icon = { Icon(topLevelRoute.icon, contentDescription = topLevelRoute.name) },
          label = { Text(topLevelRoute.name) },
          selected = currentDestination?.hierarchy?.any { it.hasRoute(topLevelRoute.route::class) } == true,
          onClick = {
            navController.navigate(topLevelRoute.route) {
              // Pop up to the start destination of the graph to
              // avoid building up a large stack of destinations
              // on the back stack as users select items
              popUpTo(navController.graph.findStartDestination().id) {
                saveState = true
              }
              // Avoid multiple copies of the same destination when
              // reselecting the same item
              launchSingleTop = true
              // Restore state when reselecting a previously selected item
              restoreState = true
            }
          }
        )
      }
    }
  }
) { innerPadding ->
  NavHost(navController, startDestination = Profile, Modifier.padding(innerPadding)) {
    composable<Profile> { ProfileScreen(...) }
    composable<Friends> { FriendsScreen(...) }
  }
}

W tym przypadku korzystasz z metody NavController.currentBackStackEntryAsState(), aby przenieść stan navController z funkcji NavHost i udostępnić go komponentowi BottomNavigation. Oznacza to, że BottomNavigation automatycznie ma najbardziej aktualny stan.

Interoperacyjność

Jeśli chcesz używać komponentu Navigation z Compose, masz 2 możliwości:

  • Zdefiniuj graf nawigacji za pomocą komponentu Navigation dla fragmentów.
  • Zdefiniuj wykres nawigacji za pomocą NavHost w Compose, korzystając z miejsc docelowych Compose. Jest to możliwe tylko wtedy, gdy wszystkie ekrany w grafie nawigacji są funkcjami kompozycyjnymi.

Dlatego w przypadku aplikacji, które korzystają zarówno z Compose, jak i z widoków, zalecamy używanie komponentu nawigacji opartej na fragmentach. Fragmenty będą wtedy zawierać ekrany oparte na widokach, ekrany Compose i ekrany, które korzystają zarówno z widoków, jak i z Compose. Gdy zawartość każdego fragmentu znajdzie się w Compose, kolejnym krokiem będzie połączenie wszystkich ekranów za pomocą Navigation Compose i usunięcie wszystkich fragmentów.

Aby zmienić miejsca docelowe w kodzie Compose, udostępniasz zdarzenia, które mogą być przekazywane do dowolnego komponentu w hierarchii i przez niego wywoływane:

@Composable
fun MyScreen(onNavigate: (Int) -> Unit) {
    Button(onClick = { onNavigate(R.id.nav_profile) } { /* ... */ }
}

W fragmencie łączysz Compose z komponentem nawigacji opartym na fragmentach, wyszukując NavController i przechodząc do miejsca docelowego:

override fun onCreateView( /* ... */ ) {
    setContent {
        MyScreen(onNavigate = { dest -> findNavController().navigate(dest) })
    }
}

Możesz też przekazać NavController w dół hierarchii Compose. Udostępnianie prostych funkcji jest jednak bardziej przydatne i łatwiejsze do testowania.

Testowanie

Oddziel kod nawigacji od miejsc docelowych, które można komponować, aby umożliwić testowanie każdego z nich osobno, niezależnie od funkcji NavHost.

Oznacza to, że nie należy przekazywać navController bezpośrednio do żadnego komponentu, tylko przekazywać wywołania zwrotne nawigacji jako parametry. Dzięki temu wszystkie funkcje kompozycyjne można testować indywidualnie, ponieważ nie wymagają one w testach instancji navController.

Poziom pośredniości zapewniany przez funkcję lambda composable umożliwia oddzielenie kodu Navigation od samego komponentu. Działa to w 2 kierunkach:

  • Przekazuj do funkcji kompozycyjnej tylko przeanalizowane argumenty
  • Przekazuj lambdy, które powinny być wywoływane przez komponent kompozycyjny, aby nawigować, a nie samą funkcję NavController.

Na przykład funkcja kompozycyjna ProfileScreen, która przyjmuje jako dane wejściowe userId i umożliwia użytkownikom przejście na stronę profilu znajomego, może mieć następujący podpis:

@Composable
fun ProfileScreen(
    userId: String,
    navigateToFriendProfile: (friendUserId: String) -> Unit
) {
 
}

Dzięki temu komponent ProfileScreen działa niezależnie od Navigation, co umożliwia jego niezależne testowanie. Funkcja lambda composable zawierałaby minimalną logikę potrzebną do połączenia interfejsów API Navigation z kompozycją:

@Serializable data class Profile(id: String)

composable<Profile> { backStackEntry ->
    val profile = backStackEntry.toRoute<Profile>()
    ProfileScreen(userId = profile.id) { friendUserId ->
        navController.navigate(route = Profile(id = friendUserId))
    }
}

Zalecamy pisanie testów, które obejmują wymagania dotyczące nawigacji w aplikacji, poprzez testowanie NavHost, działań nawigacyjnych przekazywanych do komponentów kompozycyjnych, a także poszczególnych komponentów kompozycyjnych ekranu.

Testowanie NavHost

Aby rozpocząć testowanie NavHost , dodaj tę zależność testowania nawigacji:

dependencies {
// ...
  androidTestImplementation "androidx.navigation:navigation-testing:$navigationVersion"
  // ...
}

Umieść NavHost w aplikacji w funkcji kompozycyjnej, która przyjmuje NavHostController jako parametr.

@Composable
fun AppNavHost(navController: NavHostController){
  NavHost(navController = navController){ ... }
}

Teraz możesz przetestować AppNavHost i całą logikę nawigacji zdefiniowaną w NavHost, przekazując instancję artefaktu testowania nawigacji TestNavHostController. Test interfejsu, który weryfikuje miejsce docelowe uruchomienia aplikacji i NavHost, może wyglądać tak:

class NavigationTest {

    @get:Rule
    val composeTestRule = createComposeRule()
    lateinit var navController: TestNavHostController

    @Before
    fun setupAppNavHost() {
        composeTestRule.setContent {
            navController = TestNavHostController(LocalContext.current)
            navController.navigatorProvider.addNavigator(ComposeNavigator())
            AppNavHost(navController = navController)
        }
    }

    // Unit test
    @Test
    fun appNavHost_verifyStartDestination() {
        composeTestRule
            .onNodeWithContentDescription("Start Screen")
            .assertIsDisplayed()
    }
}

Testowanie działań związanych z nawigacją

Implementację nawigacji możesz przetestować na kilka sposobów, klikając elementy interfejsu, a następnie weryfikując wyświetlane miejsce docelowe lub porównując oczekiwaną trasę z bieżącą.

Ponieważ chcesz przetestować implementację konkretnej aplikacji, preferowane są kliknięcia interfejsu. Aby dowiedzieć się, jak testować poszczególne funkcje kompozycyjne w izolacji, zapoznaj się z samouczkiem dotyczącym testowania w Jetpack Compose.

Możesz też użyć navController, aby sprawdzić asercje, porównując bieżącą trasę z oczekiwaną za pomocą navController:currentBackStackEntry

@Test
fun appNavHost_clickAllProfiles_navigateToProfiles() {
    composeTestRule.onNodeWithContentDescription("All Profiles")
        .performScrollTo()
        .performClick()

    assertTrue(navController.currentBackStackEntry?.destination?.hasRoute<Profile>() ?: false)
}

Więcej informacji o podstawach testowania w Compose znajdziesz w artykule Testowanie układu Compose i w samouczku Testowanie w Jetpack Compose. Więcej informacji o zaawansowanym testowaniu kodu nawigacji znajdziesz w przewodniku Testowanie nawigacji.

Więcej informacji

Więcej informacji o nawigacji Jetpack znajdziesz w artykule Pierwsze kroki z komponentem Navigation lub w samouczku dotyczącym nawigacji w Jetpack Compose.

Aby dowiedzieć się, jak zaprojektować nawigację w aplikacji, która dostosowuje się do różnych rozmiarów ekranu, orientacji i rodzajów urządzeń, przeczytaj artykuł Nawigacja w elastycznych interfejsach.

Aby dowiedzieć się więcej o bardziej zaawansowanej implementacji nawigacji w Compose w aplikacji modułowej, w tym o koncepcjach takich jak zagnieżdżone wykresy i integracja paska nawigacyjnego u dołu, zapoznaj się z aplikacją Now in Android na GitHubie.

Próbki