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

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

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

הגדרה

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

Groovy

dependencies {
    def nav_version = "2.9.7"

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

Kotlin

dependencies {
    val nav_version = "2.9.7"

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

שנתחיל?

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

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

יצירת NavHost

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

מידע על ניווט אל Composable מופיע במאמר ניווט אל יעד במסמכי הארכיטקטורה.

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

ניווט מקונן

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

איך בונים סרגל ניווט ופס ניווט דינמיים

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

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

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

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

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

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

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

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

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

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

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

בדיקה

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

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

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

  • העברת ארגומנטים מנותחים בלבד לרכיב הניתן להרכבה
  • מעבירים ביטויי למדה (lambda) שצריכים להפעיל את הרכיב הניתן להגדרה כדי לנווט, ולא את 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)
}

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

מידע נוסף

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

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

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

דוגמיות