פיתוח אפליקציות מותאמות באמצעות Jetpack Compose

1. מבוא

בשיעור Codelab הזה נסביר איך ליצור אפליקציות מותאמות לטלפונים, לטאבלטים ולמכשירים מתקפלים, ואיך לשפר את הנגישות באמצעות Jetpack Compose. בנוסף, תלמדו על שיטות מומלצות לשימוש ברכיבים ובערכות נושא של Material 3.

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

יכולת הסתגלות

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

מידע נוסף זמין במאמר בנושא עיצוב רספונסיבי.

בשיעור Codelab הזה נסביר איך להשתמש ב-Jetpack Compose כדי ליצור אפליקציות שניתנות להתאמה, ואיך לחשוב על התאמה כשמשתמשים ב-Jetpack Compose. אתם יוצרים אפליקציה בשם Reply, שמראה לכם איך להטמיע יכולת הסתגלות לכל סוגי המסכים, ואיך יכולת ההסתגלות והנגישות פועלות יחד כדי לספק למשתמשים חוויה אופטימלית.

מה תלמדו

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

הדרישות

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

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

אם אתם לא מכירים את Compose, מומלץ לעבור על ה-codelab בנושא היסודות של Jetpack Compose לפני שממשיכים ב-codelab הזה.

מה תפַתחו

  • אפליקציית תוכנת אימייל אינטראקטיבית בשם Reply, שמשתמשת בשיטות מומלצות לעיצובים שניתנים להתאמה, בניווטים שונים של Material ובשימוש אופטימלי בשטח המסך.

הדגמה של תמיכה בכמה מכשירים שתשיגו בסדנת הקוד הזו

2. להגדרה

כדי לקבל את הקוד של שיעור ה-Codelab הזה, משכפלים את מאגר GitHub משורת הפקודה:

git clone https://github.com/android/codelab-android-compose.git
cd codelab-android-compose/AdaptiveUiCodelab

אפשר גם להוריד את המאגר כקובץ ZIP:

מומלץ להתחיל עם הקוד בענף הראשי ולפעול לפי השלבים ב-codelab בקצב שלכם.

פתיחת הפרויקט ב-Android Studio

  1. בחלון Welcome to Android Studio (ברוכים הבאים ל-Android Studio), בוחרים באפשרות c01826594f360d94.pngOpen an Existing Project (פתיחת פרויקט קיים).
  2. בוחרים את התיקייה <Download Location>/AdaptiveUiCodelab (חשוב לבחור את הספרייה AdaptiveUiCodelab שמכילה את build.gradle).
  3. אחרי ש-Android Studio מייבא את הפרויקט, בודקים שאפשר להריץ את הענף main.

בדיקת קוד ההתחלה

קוד הענף הראשי מכיל את חבילת ui. תעבדו עם הקבצים הבאים בחבילה:

  • MainActivity.kt – פעילות של נקודת כניסה שדרכה מתחילים את האפליקציה.
  • ReplyApp.kt – מכיל רכיבי UI שניתנים להרכבה במסך הראשי.
  • ReplyHomeViewModel.kt – מספק את הנתונים ואת מצב ממשק המשתמש של תוכן האפליקציה.
  • ReplyListContent.kt – מכיל קומפוזיציות שמאפשרות לספק רשימות ומסכי פרטים.

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

המסך הראשוני בטלפוןתצוגה ראשונית מורחבת בטאבלט

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

3. התאמת אפליקציות

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

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

גדלים של חלונות

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

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

‫WindowWidthSizeClass לרוחב קומפקטי, בינוני ומורחב.‫WindowHeightSizeClass לגובה קומפקטי, בינוני ומורחב.

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

מצבי קיפול

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

מצבים של מכשירים מתקפלים: שטוח וחצי פתוח

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

מידע נוסף על מצבי קיפול ועל צירים

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

קבלת מידע מותאם

ספריית Material3‏ adaptive מספקת גישה נוחה למידע על החלון שבו האפליקציה פועלת.

  1. מוסיפים רשומות לארטיפקט הזה ולגירסה שלו לקובץ קטלוג הגרסאות:

gradle/libs.versions.toml

[versions]
material3Adaptive = "1.0.0"

[libraries]
androidx-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "material3Adaptive" }
  1. בקובץ ה-build של מודול האפליקציה, מוסיפים את התלות בספרייה החדשה ומבצעים סנכרון של Gradle:

app/build.gradle.kts

dependencies {

    implementation(libs.androidx.material3.adaptive)
}

עכשיו, בכל היקף שניתן להגדרה, אפשר להשתמש ב-currentWindowAdaptiveInfo() כדי לקבל אובייקט WindowAdaptiveInfo שמכיל מידע כמו סיווג הגודל הנוכחי של החלון והאם המכשיר במצב מקופל כמו מצב שולחן.

אפשר לנסות את זה עכשיו ב-MainActivity.

  1. ב-onCreate() בתוך הבלוק ReplyTheme, מקבלים את המידע על ההתאמה של החלון ומציגים את מחלקות הגודל ב-Text composable. אפשר להוסיף את זה אחרי הרכיב ReplyApp():

MainActivity.kt

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    setContent {
        ReplyTheme {
            val uiState by viewModel.uiState.collectAsStateWithLifecycle()
            ReplyApp(
                replyHomeUIState = uiState,
                onEmailClick = viewModel::setSelectedEmail
            )

            val adaptiveInfo = currentWindowAdaptiveInfo()
            val sizeClassText =
                "${adaptiveInfo.windowSizeClass.windowWidthSizeClass}\n" +
                "${adaptiveInfo.windowSizeClass.windowHeightSizeClass}"
            Text(
                text = sizeClassText,
                color = Color.Magenta,
                modifier = Modifier.padding(
                    WindowInsets.safeDrawing.asPaddingValues()
                )
            )
        }
    }
}

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

4. ניווט דינמי

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

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

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

  • באילו אזורים נוח להגיע כשמחזיקים את המכשיר?
  • באילו אזורים אפשר להגיע רק באמצעות מתיחת האצבעות, מה שעלול להיות לא נוח?
  • באילו אזורים קשה להגיע או שהם רחוקים מהמקום שבו המשתמש מחזיק את המכשיר?

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

ניווט בחלק התחתון

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

סרגל ניווט תחתון עם פריטים

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

סרגל ניווט עם פריטים

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

חלונית הזזה מודאלית לניווט

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

חלונית הזזה לניווט במודל עם פריטים

חלונית ניווט קבועה

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

חלונית הזזה לניווט עם פריטים

הטמעה של ניווט דינמי

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

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

  1. כדי להוסיף את הרכיב הזה, צריך לעדכן את קטלוג הגרסאות ואת סקריפט ה-build של האפליקציה, ואז לבצע סנכרון של Gradle:

gradle/libs.versions.toml

[versions]
material3AdaptiveNavSuite = "1.3.0"

[libraries]
androidx-material3-adaptive-navigation-suite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite", version.ref = "material3AdaptiveNavSuite" }

app/build.gradle.kts

dependencies {

    implementation(libs.androidx.material3.adaptive.navigation.suite)
}
  1. מחפשים את הפונקציה הקומפוזבילית ReplyNavigationWrapper() ב-ReplyApp.kt ומחליפים את Column ואת התוכן שלו ב-NavigationSuiteScaffold:

ReplyApp.kt

@Composable
private fun ReplyNavigationWrapperUI(
    content: @Composable () -> Unit = {}
) {
    var selectedDestination: ReplyDestination by remember {
        mutableStateOf(ReplyDestination.Inbox)
    }

    NavigationSuiteScaffold(
        navigationSuiteItems = {
            ReplyDestination.entries.forEach {
                item(
                    selected = it == selectedDestination,
                    onClick = { /*TODO update selection*/ },
                    icon = {
                        Icon(
                            imageVector = it.icon,
                            contentDescription = stringResource(it.labelRes)
                        )
                    },
                    label = {
                        Text(text = stringResource(it.labelRes))
                    },
                )
            }
        }
    ) {
        content()
    }
}

הארגומנט navigationSuiteItems הוא בלוק שמאפשר להוסיף פריטים באמצעות הפונקציה item(), בדומה להוספת פריטים ב-LazyColumn. בתוך הביטוי למדא שמופיע בסוף, הקוד הזה קורא ל-content() שמועבר כארגומנט ל-ReplyNavigationWrapperUI().

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

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

  1. מוסיפים את הקוד הבא ממש לפני הקריאה לפונקציה NavigationSuiteScaffold:

ReplyApp.kt

@Composable
private fun ReplyNavigationWrapperUI(
    content: @Composable () -> Unit = {}
) {
    var selectedDestination: ReplyDestination by remember {
        mutableStateOf(ReplyDestination.Inbox)
    }

    val windowSize = with(LocalDensity.current) {
        currentWindowSize().toSize().toDpSize()
    }
    val layoutType = if (windowSize.width >= 1200.dp) {
        NavigationSuiteType.NavigationDrawer
    } else {
        NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(
            currentWindowAdaptiveInfo()
        )
    }

    NavigationSuiteScaffold(
        layoutType = layoutType,
        ...
    ) {
        content()
    }
}

הקוד הזה קודם מקבל את גודל החלון וממיר אותו ליחידות DP באמצעות currentWindowSize() ו-LocalDensity.current, ואז משווה את רוחב החלון כדי להחליט על סוג הפריסה של ממשק המשתמש של הניווט. אם רוחב החלון הוא לפחות 1200.dp, נעשה שימוש ב-NavigationSuiteType.NavigationDrawer. אחרת, המערכת תחזור לחישוב ברירת המחדל.

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

הצגת שינויים בהתאמה למכשירים בגדלים שונים.

כל הכבוד, למדתם על סוגים שונים של ניווט שתומכים בסוגים שונים של גדלים ומצבים של חלונות.

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

5. שימוש בשטח המסך

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

ב-Material 3 מוגדרים שלושה פריסות קנוניות, שלכל אחת מהן יש הגדרות לגדלים קומפקטיים, בינוניים וגדולים של חלונות. פריסת פרטי הרשימה הקנונית מתאימה מאוד לתרחיש השימוש הזה, והיא זמינה בכתיבה כ-ListDetailPaneScaffold.

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

gradle/libs.versions.toml

[libraries]
androidx-material3-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout", version.ref = "material3Adaptive" }
androidx-material3-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation", version.ref = "material3Adaptive" }

app/build.gradle.kts

dependencies {

    implementation(libs.androidx.material3.adaptive.layout)
    implementation(libs.androidx.material3.adaptive.navigation)
}
  1. מחפשים את הפונקציה הקומפוזבילית ReplyAppContent() ב-ReplyApp.kt, שכרגע מציגה רק את חלונית הרשימה על ידי קריאה ל-ReplyListPane(). מחליפים את ההטמעה הזו ב-ListDetailPaneScaffold על ידי הוספת הקוד הבא. מכיוון שזהו API ניסיוני, מוסיפים גם את ההערה @OptIn לפונקציה ReplyAppContent():

ReplyApp.kt

@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun ReplyAppContent(
    replyHomeUIState: ReplyHomeUIState,
    onEmailClick: (Email) -> Unit,
) {
    val navigator = rememberListDetailPaneScaffoldNavigator<Long>()

    ListDetailPaneScaffold(
        directive = navigator.scaffoldDirective,
        value = navigator.scaffoldValue,
        listPane = {
            ReplyListPane(replyHomeUIState, onEmailClick)
        },
        detailPane = {
            ReplyDetailPane(replyHomeUIState.emails.first())
        }
    )
}

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

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

שאר הפרמטרים הנדרשים הם פונקציות למדא שניתנות להרכבה עבור החלוניות. הלחצנים ReplyListPane() ו-ReplyDetailPane() (שנמצאים ב-ReplyListContent.kt) משמשים למילוי התפקידים של חלוניות הרשימה והפרטים, בהתאמה. הפונקציה ReplyDetailPane() מצפה לארגומנט של כתובת אימייל, ולכן בשלב הזה הקוד הזה משתמש בכתובת האימייל הראשונה מתוך רשימת כתובות האימייל ב-ReplyHomeUIState.

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

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

  1. פותחים את ReplyHomeViewModel.kt ומחפשים את סיווג הנתונים ReplyHomeUIState. מוסיפים מאפיין לאימייל שנבחר, עם ערך ברירת מחדל של null:

ReplyHomeViewModel.kt

data class ReplyHomeUIState(
    val emails : List<Email> = emptyList(),
    val selectedEmail: Email? = null,
    val loading: Boolean = false,
    val error: String? = null
)
  1. באותו קובץ, לרכיב ReplyHomeViewModel יש פונקציה setSelectedEmail() שמופעלת כשהמשתמש מקיש על פריט ברשימה. משנים את הפונקציה כדי להעתיק את מצב ממשק המשתמש ולתעד את האימייל שנבחר:

ReplyHomeViewModel.kt

fun setSelectedEmail(email: Email) {
    _uiState.update {
        it.copy(selectedEmail = email)
    }
}

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

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

ReplyHomeViewModel.kt

private fun observeEmails() {
    viewModelScope.launch {
        emailsRepository.getAllEmails()
            .catch { ex ->
                _uiState.value = ReplyHomeUIState(error = ex.message)
            }
            .collect { emails ->
                val currentSelection = _uiState.value.selectedEmail
                _uiState.value = ReplyHomeUIState(
                    emails = emails,
                    selectedEmail = currentSelection ?: emails.first()
                )
            }
    }
}
  1. חוזרים אל ReplyApp.kt ומשתמשים באימייל שנבחר, אם הוא זמין, כדי לאכלס את התוכן בחלונית הפרטים:

ReplyApp.kt

ListDetailPaneScaffold(
    // ...
    detailPane = {
        if (replyHomeUIState.selectedEmail != null) {
            ReplyDetailPane(replyHomeUIState.selectedEmail)
        }
    }
)

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

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

  1. כדי לפתור את הבעיה, מוסיפים את הקוד הבא כ-lambda שמועבר אל ReplyListPane:

ReplyApp.kt

ListDetailPaneScaffold(
    // ...
    listPane = {
        ReplyListPane(
            replyHomeUIState = replyHomeUIState,
            onEmailClick = { email ->
                onEmailClick(email)
                navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, email.id)
            }
        )
    },
    // ...
)

פונקציית ה-Lambda הזו משתמשת ברכיב הניווט שנוצר קודם כדי להוסיף התנהגות נוספת כשלוחצים על פריט. הפונקציה תקרא ל-lambda המקורי שהועבר לפונקציה הזו, ואז תקרא גם ל-navigator.navigateTo() ותציין איזה חלונית צריכה להיות מוצגת. לכל חלונית בפיגום יש תפקיד שמשויך אליה, ובחלונית הפרטים התפקיד הוא ListDetailPaneScaffoldRole.Detail. בחלונות קטנים יותר, זה ייראה כאילו האפליקציה עברה קדימה.

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

  1. כדי להוסיף תמיכה בניווט אחורה, מוסיפים את הקוד הבא.

ReplyApp.kt

@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun ReplyAppContent(
    replyHomeUIState: ReplyHomeUIState,
    onEmailClick: (Email) -> Unit,
) {
    val navigator = rememberListDetailPaneScaffoldNavigator<Long>()

    BackHandler(navigator.canNavigateBack()) {
        navigator.navigateBack()
    }

    ListDetailPaneScaffold(
        directive = navigator.scaffoldDirective,
        value = navigator.scaffoldValue,
        listPane = {
            AnimatedPane {
                ReplyListPane(
                    replyHomeUIState = replyHomeUIState,
                    onEmailClick = { email ->
                        onEmailClick(email)
                        navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, email.id)
                    }
                )
            }
        },
        detailPane = {
            AnimatedPane {
                if (replyHomeUIState.selectedEmail != null) {
                    ReplyDetailPane(replyHomeUIState.selectedEmail)
                }
            }
        }
    )
}

רכיב הניווט יודע את המצב המלא של ListDetailPaneScaffold, אם אפשר לנווט אחורה ומה צריך לעשות בכל התרחישים האלה. הקוד הזה יוצר BackHandler שמופעל בכל פעם שאפשר לנווט אחורה, ובתוך קריאות ה-lambda מופיע navigateBack(). בנוסף, כדי שהמעבר בין החלוניות יהיה חלק יותר, כל חלונית עטופה ב-AnimatedPane() composable.

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

הצגת שינויים בהתאמה למכשירים בגדלים שונים.

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

6. מזל טוב

מעולה! סיימתם את שיעור ה-Codelab הזה ולמדתם איך ליצור אפליקציות מותאמות באמצעות Jetpack Compose.

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

מה השלב הבא?

כדאי לעיין במדריכי Codelab נוספים במסלול למידה בנושא Compose.

אפליקציות לדוגמה

מסמכים לדוגמה