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

רכיב הניווט מספק תמיכה באפליקציות של 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.1"
}

android {
    buildFeatures {
        compose true
    }

    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.15"
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }
}

Kotlin

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

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.

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

רמת העקיפה שמסופקת על ידי lambda של 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()
    }
}

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

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

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

אפשר גם להשתמש ב-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.

דוגמיות