ניווט באמצעות 'כתיבה'

רכיב הניווט מספק תמיכה באפליקציות Jetpack פיתוח נייטיב. אתם יכולים לנווט בין קומפוזיציות תוך ניצול התשתית והתכונות של רכיב הניווט.

לספריית הניווט העדכנית בגרסת אלפא שנוצרה במיוחד ל-Compose, אפשר לעיין במסמכי התיעוד של Navigation 3.

הגדרה

כדי לתמוך ב-Compose, צריך להשתמש בתלות הבאה בקובץ build.gradle של מודול האפליקציה:

Groovy

dependencies {
    def nav_version = "2.9.3"

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

Kotlin

dependencies {
    val nav_version = "2.9.3"

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

שנתחיל?

כשמטמיעים ניווט באפליקציה, צריך להטמיע מארח ניווט, תרשים ובקר. מידע נוסף זמין במאמר בנושא ניווט.

בקטע Compose במאמר יצירת בקר ניווט מוסבר איך ליצור NavController ב-Compose.

יצירת NavHost

מידע על יצירת NavHost ב-Compose זמין בקטע Compose במאמר עיצוב גרף הניווט.

במאמר ניווט ליעד במסמכי הארכיטקטורה מוסבר איך מגיעים לרכיב שאפשר להרכיב.

מידע על העברת ארגומנטים בין יעדים שאפשר להרכיב מופיע בקטע Compose במאמר תכנון גרף הניווט.

אחזור נתונים מורכבים במהלך הניווט

מומלץ מאוד לא להעביר אובייקטים מורכבים של נתונים כשמנווטים, אלא להעביר את המידע המינימלי הנדרש, כמו מזהה ייחודי או סוג אחר של מזהה, כארגומנטים כשמבצעים פעולות ניווט:

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

אובייקטים מורכבים צריכים להיות מאוחסנים כנתונים במקור יחיד מהימן, כמו שכבת הנתונים. אחרי שמגיעים ליעד אחרי הניווט, אפשר לטעון את המידע הנדרש ממקור האמת היחיד באמצעות המזהה שהועבר. כדי לאחזר את הארגומנטים ב-ViewModel שאחראי לגישה לשכבת הנתונים, משתמשים ב-SavedStateHandle של 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)

// …

}

הגישה הזו עוזרת למנוע אובדן נתונים במהלך שינויים בהגדרות, וגם חוסר עקביות כשמעדכנים או משנים את האובייקט הרלוונטי.

הסבר מפורט יותר על הסיבות לכך שכדאי להימנע מהעברת נתונים מורכבים כארגומנטים, ורשימה של סוגי הארגומנטים הנתמכים, מופיעים במאמר העברת נתונים בין יעדים.

‫Navigation Compose תומך גם בקישורי עומק שאפשר להגדיר כחלק מהפונקציה composable(). הפרמטר deepLinks שלו מקבל רשימה של אובייקטים מסוג NavDeepLink שאפשר ליצור במהירות באמצעות ה-method ‏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)
}

קישורי העומק האלה מאפשרים לשייך כתובת URL, פעולה או סוג MIME ספציפיים לרכיב שאפשר להרכיב. כברירת מחדל, קישורי העומק האלה לא חשופים לאפליקציות חיצוניות. כדי שקישורי העומק האלה יהיו זמינים חיצונית, צריך להוסיף את רכיבי <intent-filter> המתאימים לקובץ manifest.xml של האפליקציה. כדי להפעיל את הקישור העמוק בדוגמה הקודמת, צריך להוסיף את הקוד הבא בתוך האלמנט <activity> של המניפסט:

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

הניווט יוצר אוטומטית קישור עומק לאותו רכיב שאפשר להרכיב כשקישור העומק מופעל על ידי אפליקציה אחרת.

אפשר גם להשתמש באותם קישורי עומק כדי ליצור PendingIntent עם קישור העומק המתאים מתוך רכיב שאפשר להרכיב:

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

אחר כך תוכלו להשתמש ב-deepLinkPendingIntent כמו בכל PendingIntent אחר כדי לפתוח את האפליקציה ביעד של קישור העומק.

ניווט ברמות שונות

מידע על יצירת תרשימי ניווט בתצוגת עץ זמין במאמר בנושא תרשימים בתצוגת עץ.

שילוב עם סרגל הניווט התחתון

אם מגדירים את NavController ברמה גבוהה יותר בהיררכיה של הרכיבים שאפשר להרכיב, אפשר לקשר את הרכיב Navigation לרכיבים אחרים, כמו הרכיב של סרגל הניווט התחתון. כך תוכלו לנווט באמצעות הסמלים בסרגל התחתון.

כדי להשתמש ברכיבים BottomNavigation ו-BottomNavigationItem, צריך להוסיף את יחסי התלות androidx.compose.material לאפליקציית Android.

Groovy

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

android {
    buildFeatures {
        compose true
    }

    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.15"
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }
}

Kotlin

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

android {
    buildFeatures {
        compose = true
    }

    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.15"
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }
}

כדי לקשר את הפריטים בסרגל ניווט תחתון למסלולים בתרשים הניווט, מומלץ להגדיר מחלקה, כמו TopLevelRoute שמוצגת כאן, שיש לה מחלקת מסלול וסמל.

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

אחר כך מציבים את המסלולים האלה ברשימה שאפשר להשתמש בה ב-BottomNavigationItem:

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

ב-composable‏ BottomNavigation, מקבלים את NavBackStackEntry הנוכחי באמצעות הפונקציה currentBackStackEntryAsState(). הרשומה הזו מאפשרת לכם גישה לNavDestination הנוכחי. אפשר לקבוע את המצב שנבחר של כל BottomNavigationItem על ידי השוואת הנתיב של הפריט לנתיב של היעד הנוכחי ושל יעדי האב שלו, כדי לטפל במקרים שבהם משתמשים בניווט מוטמע באמצעות ההיררכיה NavDestination.

הנתיב של הפריט משמש גם לחיבור של פונקציית ה-onClick lambda לקריאה ל-navigate, כך שהקשה על הפריט תעביר אתכם אליו. באמצעות הדגלים saveState ו-restoreState, המצב ומחסנית החזרה של הפריט נשמרים ומשוחזרים בצורה נכונה כשעוברים בין פריטים בתפריט הניווט התחתון.

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

כאן משתמשים בשיטה NavController.currentBackStackEntryAsState() כדי להעביר את מצב navController מחוץ לפונקציה NavHost ולשתף אותו עם הרכיב BottomNavigation. כלומר, המצב העדכני ביותר של BottomNavigation מתעדכן באופן אוטומטי.

יכולת פעולה הדדית

אם רוצים להשתמש ברכיב Navigation עם Compose, יש שתי אפשרויות:

  • הגדרת גרף ניווט באמצעות רכיב הניווט עבור קטעי קוד.
  • מגדירים גרף ניווט עם NavHost ב-Compose באמצעות יעדים של Compose. זה אפשרי רק אם כל המסכים בתרשים הניווט הם קומפוזיציות.

לכן, ההמלצה לאפליקציות עם תמהיל של Compose ו-Views היא להשתמש ברכיב Fragment-based Navigation. לאחר מכן, הרכיבים יכילו מסכים מבוססי-View, מסכי Compose ומסכים שמשתמשים גם ב-Views וגם ב-Compose. אחרי שכל התוכן של Fragment נמצא ב-Compose, השלב הבא הוא לקשר בין כל המסכים באמצעות Navigation Compose ולהסיר את כל ה-Fragments.

כדי לשנות יעדים בתוך קוד Compose, צריך לחשוף אירועים שאפשר להעביר ולהפעיל על ידי כל רכיב שאפשר להוסיף בהיררכיה:

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

בקטע הקוד, יוצרים את הגשר בין Compose לבין רכיב הניווט מבוסס-קטעי הקוד על ידי מציאת NavController וניווט ליעד:

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

אפשרות אחרת היא להעביר את NavController בהיררכיית ה-Compose. עם זאת, חשיפה של פונקציות פשוטות מאפשרת שימוש חוזר וקל יותר בבדיקות.

בדיקה

כדי לבדוק כל קומפוננטה בנפרד, בלי קשר לקומפוננטה NavHost, צריך להפריד את קוד הניווט מהיעדים שניתנים להרכבה.

כלומר, אסור להעביר את navController ישירות לכלcomposable, אלא להעביר קריאות חוזרות (callback) של ניווט כפרמטרים. כך אפשר לבדוק כל קומפוזיציה בנפרד, כי לא צריך מופע של navController בבדיקות.

רמת ההפניה העקיפה שמספקת פונקציית ה-lambda‏ composable מאפשרת להפריד את קוד הניווט מהרכיב עצמו. הפעולה הזו מתבצעת בשני כיוונים:

  • העברה רק של ארגומנטים מנותחים לרכיב הניתן להרכבה
  • צריך להעביר פונקציות למדא שמופעלות על ידי הפונקציה הניתנת להרכבה כדי לנווט, ולא את NavController עצמו.

לדוגמה, ProfileScreen קומפוזבל שמקבל userId כקלט ומאפשר למשתמשים לנווט לדף הפרופיל של חבר יכול להיות עם החתימה הבאה:

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

כך, רכיב ה-ProfileScreen composable פועל באופן עצמאי מ-Navigation, ולכן אפשר לבדוק אותו בנפרד. פונקציית ה-lambda‏ composable תכיל את הלוגיקה המינימלית שנדרשת כדי לגשר על הפער בין ממשקי ה-API של Navigation לבין הרכיב הניתן להרכבה:

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

מומלץ לכתוב בדיקות שמכסות את דרישות הניווט באפליקציה על ידי בדיקה של NavHost, פעולות הניווט שמועברות לרכיבי ה-Composable, וגם של רכיבי ה-Composable של המסכים השונים.

בדיקת NavHost

כדי להתחיל לבדוק את NavHost , מוסיפים את יחסי התלות הבאים של בדיקת הניווט:

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

עוטפים את NavHost של האפליקציה ברכיב שאפשר להרכיב ממנו רכיבים אחרים, שמקבל NavHostController כפרמטר.

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

עכשיו אפשר לבדוק את AppNavHost ואת כל לוגיקת הניווט שמוגדרת בתוך NavHost על ידי העברת מופע של ארטיפקט בדיקת הניווט TestNavHostController. בדיקת ממשק משתמש שמאמתת את יעד ההתחלה של האפליקציה שלכם ושל NavHost תיראה כך:

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

בדיקת פעולות ניווט

יש כמה דרכים לבדוק את ההטמעה של הניווט. אפשר ללחוץ על רכיבי ממשק המשתמש ואז לוודא שהיעד מוצג, או להשוות בין המסלול הצפוי למסלול הנוכחי.

מכיוון שאתם רוצים לבדוק את ההטמעה של האפליקציה הקונקרטית שלכם, עדיף להשתמש בקליקים בממשק המשתמש. כדי ללמוד איך לבדוק את זה לצד פונקציות הניתנות להרכבה בנפרד, כדאי לעיין ב-codelab בנושא בדיקות ב-Jetpack Compose.

אפשר גם להשתמש ב-navController כדי לבדוק את הטענות על ידי השוואת המסלול הנוכחי למסלול הצפוי, באמצעות navController של currentBackStackEntry:

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

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

לקבלת הנחיות נוספות בנושא יסודות הבדיקה ב-Compose, אפשר לעיין במאמר בדיקת פריסת Compose וב-codelab בנושא בדיקה ב-Jetpack Compose. מידע נוסף על בדיקה מתקדמת של קוד הניווט זמין במדריך בנושא בדיקת ניווט.

מידע נוסף

מידע נוסף על Jetpack Navigation זמין במאמר תחילת העבודה עם רכיב הניווט או ב-Jetpack Compose Navigation codelab.

כדי ללמוד איך לעצב את הניווט באפליקציה כך שיתאים לגדלים, לכיוונים ולגורמי צורה שונים של מסכים, אפשר לעיין במאמר ניווט בממשקי משתמש רספונסיביים.

כדי לקבל מידע על הטמעה מתקדמת יותר של ניווט ב-Compose באפליקציה מודולרית, כולל מושגים כמו גרפים מוטמעים ושילוב של סרגל ניווט בתחתית, אפשר לעיין באפליקציית Now in Android ב-GitHub.

טעימות