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

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

הגדרה

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

מגניב

dependencies {
    def nav_version = "2.8.0"

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

Kotlin

dependencies {
    val nav_version = "2.8.0"

    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)

// …

}

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

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

התכונה 'כתיבה בזמן ניווט' תומכת גם בקישורי עומק שאפשר להגדיר כחלק מהפונקציה composable(). הפרמטר deepLinks מקבל רשימה של אובייקטים מסוג NavDeepLink, שאפשר ליצור במהירות באמצעות השיטה 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.7.5"
}

android {
    buildFeatures {
        compose true
    }

    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.15"
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }
}

Kotlin

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

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

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

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

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

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

לכן, ההמלצה לאפליקציות מעורבות של Compose ו-Views היא להשתמש ברכיב הניווט שמבוסס על קטעים. לאחר מכן, קטעי הקוד יכללו מסכים שמבוססים על תצוגות, מסכים של Compose ומסכים שמשתמשים גם בתצוגות וגם ב-Compose. אחרי שהתוכן של כל ה-Fragments נמצא ב-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 ישירות לרכיב מורכב כלשהו, אלא להעביר קריאות חזרה (callbacks) של ניווט כפרמטרים. כך תוכלו לבדוק כל רכיב בנפרד, כי לא נדרש מופע של navController בבדיקות.

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

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

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

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

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

@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 של האפליקציה ב-composable שמקבל 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()
    }
}

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

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

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

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

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

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

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

מידע נוסף

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

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

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

דוגמיות