רשימות ורשתות עצלות

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

אם אתם יודעים שתרחיש השימוש שלכם לא דורש גלילה, אתם יכולים להשתמש ב-Column או ב-Row (בהתאם לכיוון) ולשדר את התוכן של כל פריט על ידי איטרציה על רשימה באופן הבא:

@Composable
fun MessageList(messages: List<Message>) {
    Column {
        messages.forEach { message ->
            MessageRow(message)
        }
    }
}

אפשר להוסיף את התג verticalScroll() כדי לאפשר גלילה ב-Column.

רשימות עצלניות

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

‫Compose מספקת קבוצה של רכיבים שמרכיבים ומסדרים רק פריטים שגלויים באזור התצוגה של הרכיב. הרכיבים האלה כוללים את LazyColumn ואת LazyRow.

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

הרכיבים Lazy שונים מרוב הפריסות ב-Compose. במקום לקבל פרמטר של @Composable חסימת תוכן, שמאפשר לאפליקציות להפיק ישירות רכיבים שניתנים להרכבה, רכיבי Lazy מספקים חסימה של LazyListScope.(). בלוק LazyListScope הזה מוצעת שפת תיאור תחום (DSL) שמאפשרת לאפליקציות לתאר את תוכן הפריט. לאחר מכן, רכיב ה-Lazy אחראי להוספת התוכן של כל פריט לפי הדרישות של הפריסה ומיקום הגלילה.

LazyListScope DSL

שפת התחום הספציפית (DSL) של LazyListScope מספקת מספר פונקציות לתיאור פריטים בפריסה. בצורה הכי בסיסית, ‫item() מוסיף פריט אחד, ו- ‫items(Int) מוסיף כמה פריטים:

LazyColumn {
    // Add a single item
    item {
        Text(text = "First item")
    }

    // Add 5 items
    items(5) { index ->
        Text(text = "Item: $index")
    }

    // Add another single item
    item {
        Text(text = "Last item")
    }
}

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

/**
 * import androidx.compose.foundation.lazy.items
 */
LazyColumn {
    items(messages) { message ->
        MessageRow(message)
    }
}

יש גם גרסה של פונקציית ההרחבה items() שנקראת itemsIndexed(), שמספקת את האינדקס. פרטים נוספים זמינים במפרט של השיטה ב-LazyListScope.

רשתות מדורגות

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

לרשתות יש את אותן יכולות API עוצמתיות כמו לרשימות, והן גם משתמשות ב-DSL דומה מאוד – LazyGridScope.() – כדי לתאר את התוכן.

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

הפרמטר columns ב-LazyVerticalGrid והפרמטר rows ב-LazyHorizontalGrid קובעים איך התאים מסודרים בעמודות או בשורות. בדוגמה הבאה מוצגים פריטים בפריסה של רשת, באמצעות GridCells.Adaptive כדי להגדיר שכל עמודה תהיה ברוחב של לפחות 128.dp:

LazyVerticalGrid(
    columns = GridCells.Adaptive(minSize = 128.dp)
) {
    items(photos) { photo ->
        PhotoItem(photo)
    }
}

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

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

אם העיצוב שלכם דורש שרק לפריטים מסוימים יהיו מידות לא סטנדרטיות, אתם יכולים להשתמש בתמיכה בפריסת רשת כדי לספק טווחי עמודות מותאמים אישית לפריטים. מציינים את מספר העמודות שהתא צריך לתפוס באמצעות הפרמטר span של השיטות LazyGridScope DSL item ו-items. ‫maxLineSpan, אחד מהערכים של היקף הטווח, שימושי במיוחד כשמשתמשים בשינוי גודל דינמי, כי מספר העמודות לא קבוע. בדוגמה הזו מוסבר איך להגדיר שהתא יתפרס על פני שורה שלמה:

LazyVerticalGrid(
    columns = GridCells.Adaptive(minSize = 30.dp)
) {
    item(span = {
        // LazyGridItemSpanScope:
        // maxLineSpan
        GridItemSpan(maxLineSpan)
    }) {
        CategoryCard("Fruits")
    }
    // ...
}

רשת מדורגת עצלה

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

קטע הקוד הבא הוא דוגמה בסיסית לשימוש ב-LazyVerticalStaggeredGrid עם רוחב של 200.dp לכל פריט:

LazyVerticalStaggeredGrid(
    columns = StaggeredGridCells.Adaptive(200.dp),
    verticalItemSpacing = 4.dp,
    horizontalArrangement = Arrangement.spacedBy(4.dp),
    content = {
        items(randomSizedPhotos) { photo ->
            AsyncImage(
                model = photo,
                contentScale = ContentScale.Crop,
                contentDescription = null,
                modifier = Modifier
                    .fillMaxWidth()
                    .wrapContentHeight()
            )
        }
    },
    modifier = Modifier.fillMaxSize()
)

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

כדי להגדיר מספר קבוע של עמודות, אפשר להשתמש בתג StaggeredGridCells.Fixed(columns) במקום בתג StaggeredGridCells.Adaptive. הרוחב הזמין מחולק במספר העמודות (או השורות ברשת אופקית), וכל פריט תופס את הרוחב הזה (או הגובה ברשת אופקית):

LazyVerticalStaggeredGrid(
    columns = StaggeredGridCells.Fixed(3),
    verticalItemSpacing = 4.dp,
    horizontalArrangement = Arrangement.spacedBy(4.dp),
    content = {
        items(randomSizedPhotos) { photo ->
            AsyncImage(
                model = photo,
                contentScale = ContentScale.Crop,
                contentDescription = null,
                modifier = Modifier
                    .fillMaxWidth()
                    .wrapContentHeight()
            )
        }
    },
    modifier = Modifier.fillMaxSize()
)
רשת מדורגת של תמונות בטעינה מדורגת בכלי הכתיבה
איור 2. דוגמה לרשת אנכית מדורגת עם טעינה עצלה ועמודות קבועות

מרווח פנימי של תוכן

לפעמים תצטרכו להוסיף שוליים מסביב לקצוות של התוכן. רכיבי lazy מאפשרים להעביר חלק מ-PaddingValues לפרמטר contentPadding כדי לתמוך בכך:

LazyColumn(
    contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
) {
    // ...
}

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

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

דוגמה נוספת: אפשר להעביר את Scaffold של PaddingValues אל contentPadding של LazyColumn. למידע נוסף, אפשר לעיין במדריך בנושא תצוגה מקצה לקצה.

מרווחים בין פריטי תוכן

כדי להוסיף רווח בין הפריטים, אפשר להשתמש ב-Arrangement.spacedBy(). בדוגמה הבאה נוסף רווח של 4.dp בין כל פריט:

LazyColumn(
    verticalArrangement = Arrangement.spacedBy(4.dp),
) {
    // ...
}

באופן דומה, לגבי LazyRow:

LazyRow(
    horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
    // ...
}

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

LazyVerticalGrid(
    columns = GridCells.Fixed(2),
    verticalArrangement = Arrangement.spacedBy(16.dp),
    horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
    items(photos) { item ->
        PhotoItem(item)
    }
}

מפתחות פריטים

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

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

LazyColumn {
    items(
        items = messages,
        key = { message ->
            // Return a stable + unique key for the item
            message.id
        }
    ) { message ->
        MessageRow(message)
    }
}

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

LazyColumn {
    items(books, key = { it.id }) {
        val rememberedValue = remember {
            Random.nextInt()
        }
    }
}

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

LazyColumn {
    items(books, key = {
        // primitives, enums, Parcelable, etc.
    }) {
        // ...
    }
}

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

LazyColumn {
    items(books, key = { it.id }) {
        val rememberedValue = rememberSaveable {
            Random.nextInt()
        }
    }
}

אנימציות של פריטים

אם השתמשתם בווידג'ט RecyclerView, אתם יודעים שהוא מנפיש שינויים בפריטים באופן אוטומטי. פריסות עצלות מספקות את אותה פונקציונליות לשינוי הסדר של פריטים. ה-API פשוט – צריך רק להגדיר את משנה המאפיין animateItem לתוכן הפריט:

LazyColumn {
    // It is important to provide a key to each item to ensure animateItem() works as expected.
    items(books, key = { it.id }) {
        Row(Modifier.animateItem()) {
            // ...
        }
    }
}

אם צריך, אפשר גם לספק הגדרות מותאמות אישית לאנימציה:

LazyColumn {
    items(books, key = { it.id }) {
        Row(
            Modifier.animateItem(
                fadeInSpec = tween(durationMillis = 250),
                fadeOutSpec = tween(durationMillis = 100),
                placementSpec = spring(stiffness = Spring.StiffnessLow, dampingRatio = Spring.DampingRatioMediumBouncy)
            )
        ) {
            // ...
        }
    }
}

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

דוגמה: אנימציה של פריטים ברשימות עצלות

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

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

@Composable
fun ListAnimatedItems(
    items: List<String>,
    modifier: Modifier = Modifier
) {
    LazyColumn(modifier) {
        // Use a unique key per item, so that animations work as expected.
        items(items, key = { it }) {
            ListItem(
                headlineContent = { Text(it) },
                modifier = Modifier
                    .animateItem(
                        // Optionally add custom animation specs
                    )
                    .fillParentMaxWidth()
                    .padding(horizontal = 8.dp, vertical = 0.dp),
            )
        }
    }
}

מידע חשוב על הקוד

  • ‫ListAnimatedItems מציג רשימה של מחרוזות ב-LazyColumn עם מעברים מונפשים כשפריטים משתנים.
  • הפונקציה items מקצה מפתח ייחודי לכל פריט ברשימה. הכלי 'פיתוח נייטיב' משתמש במפתחות כדי לעקוב אחרי הפריטים ולזהות שינויים במיקומים שלהם.
  • ‫ListItem מגדיר את הפריסה של כל פריט ברשימה. התג הזה מקבל headlineContent פרמטר שמגדיר את התוכן העיקרי של הפריט.
  • ההרחבה animateItem מוסיפה אנימציות כברירת מחדל לפריטים שנוספו, הוסרו או הועברו.

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

@Composable
private fun ListAnimatedItemsExample(
    data: List<String>,
    modifier: Modifier = Modifier,
    onAddItem: () -> Unit = {},
    onRemoveItem: () -> Unit = {},
    resetOrder: () -> Unit = {},
    onSortAlphabetically: () -> Unit = {},
    onSortByLength: () -> Unit = {},
) {
    val canAddItem = data.size < 10
    val canRemoveItem = data.isNotEmpty()

    Scaffold(modifier) { paddingValues ->
        Column(
            modifier = Modifier
                .padding(paddingValues)
                .fillMaxSize()
        ) {
            // Buttons that change the value of displayedItems.
            AddRemoveButtons(canAddItem, canRemoveItem, onAddItem, onRemoveItem)
            OrderButtons(resetOrder, onSortAlphabetically, onSortByLength)

            // List that displays the values of displayedItems.
            ListAnimatedItems(data)
        }
    }
}

מידע חשוב על הקוד

  • ListAnimatedItemsExample מוצג מסך עם אמצעי בקרה להוספה, להסרה ולמיון של פריטים.
    • ‫onAddItem ו-onRemoveItem הם ביטויי למבדא שמועברים אל AddRemoveButtons כדי להוסיף פריטים לרשימה ולהסיר פריטים ממנה.
    • ‫resetOrder,‏ onSortAlphabetically ו-onSortByLength הם ביטויי lambda שמועברים אל OrderButtons כדי לשנות את הסדר של הפריטים ברשימה.
  • AddRemoveButtons מציג את הלחצנים 'הוספה' ו'הסרה'. הוא מאפשר להפעיל או להשבית את הכפתורים ומטפל בקליקים על הכפתורים.
  • OrderButtons מציג את הלחצנים לסידור מחדש של הרשימה. היא מקבלת את פונקציות ה-lambda לאיפוס הסדר ולמיון הרשימה לפי אורך או לפי סדר אלפביתי.
  • ‫ListAnimatedItems שולח קריאה לרכיב הקומפוזבילי ListAnimatedItems, ומעביר את רשימת data כדי להציג את רשימת המחרוזות המונפשת. המשתנה data מוגדר במקום אחר.

קטע הקוד הזה יוצר ממשק משתמש עם הלחצנים Add Item (הוספת פריט) ו-Delete Item (מחיקת פריט):

@Composable
private fun AddRemoveButtons(
    canAddItem: Boolean,
    canRemoveItem: Boolean,
    onAddItem: () -> Unit,
    onRemoveItem: () -> Unit
) {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.Center
    ) {
        Button(enabled = canAddItem, onClick = onAddItem) {
            Text("Add Item")
        }
        Spacer(modifier = Modifier.padding(25.dp))
        Button(enabled = canRemoveItem, onClick = onRemoveItem) {
            Text("Delete Item")
        }
    }
}

מידע חשוב על הקוד

  • AddRemoveButtons מוצגת שורה של לחצנים לביצוע פעולות של הוספה והסרה ברשימה.
  • הפרמטרים canAddItem ו-canRemoveItem קובעים את מצב ההפעלה של הלחצנים. אם הערך של canAddItem או canRemoveItem הוא False, הלחצן המתאים מושבת.
  • הפרמטרים onAddItem ו-onRemoveItem הם פונקציות למדא שמופעלות כשהמשתמש לוחץ על הכפתור המתאים.

לבסוף, בקטע הקוד הזה מוצגים שלושה לחצנים למיון הרשימה (Reset, Alphabetical ו-Length):

@Composable
private fun OrderButtons(
    resetOrder: () -> Unit,
    orderAlphabetically: () -> Unit,
    orderByLength: () -> Unit
) {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.Center
    ) {
        var selectedIndex by remember { mutableIntStateOf(0) }
        val options = listOf("Reset", "Alphabetical", "Length")

        SingleChoiceSegmentedButtonRow {
            options.forEachIndexed { index, label ->
                SegmentedButton(
                    shape = SegmentedButtonDefaults.itemShape(
                        index = index,
                        count = options.size
                    ),
                    onClick = {
                        Log.d("AnimatedOrderedList", "selectedIndex: $selectedIndex")
                        selectedIndex = index
                        when (options[selectedIndex]) {
                            "Reset" -> resetOrder()
                            "Alphabetical" -> orderAlphabetically()
                            "Length" -> orderByLength()
                        }
                    },
                    selected = index == selectedIndex
                ) {
                    Text(label)
                }
            }
        }
    }
}

מידע חשוב על הקוד

  • ב-OrderButtons מוצג SingleChoiceSegmentedButtonRow שמאפשר למשתמשים לבחור שיטת מיון ברשימה או לאפס את סדר הרשימה. רכיב SegmentedButton מאפשר לבחור אפשרות אחת מתוך רשימת אפשרויות.
  • ‫resetOrder,‏ orderAlphabetically ו-orderByLength הן פונקציות למבדה שמופעלות כשבוחרים את הכפתור המתאים.
  • משתנה המצב selectedIndex עוקב אחרי האפשרות שנבחרה.

תוצאה

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

איור 1. רשימה שבה יש אנימציה למעברים בין פריטים כשמוסיפים, מסירים או ממיינים פריטים.

כותרות במיקום קבוע

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

סרטון של טלפון שגוללים בו למעלה ולמטה ברשימת אנשי הקשר

כדי ליצור כותרת קבועה באמצעות LazyColumn, אפשר להשתמש ב-stickyHeader()

הפונקציה, ומספקים את תוכן הכותרת:

@Composable
fun ListWithHeader(items: List<Item>) {
    LazyColumn {
        stickyHeader {
            Header()
        }

        items(items) { item ->
            ItemRow(item)
        }
    }
}

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

// This ideally would be done in the ViewModel
val grouped = contacts.groupBy { it.firstName[0] }

@Composable
fun ContactsList(grouped: Map<Char, List<Contact>>) {
    LazyColumn {
        grouped.forEach { (initial, contactsForInitial) ->
            stickyHeader {
                CharacterHeader(initial)
            }

            items(contactsForInitial) { contact ->
                ContactListItem(contact)
            }
        }
    }
}

תגובה למיקום הגלילה

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

@Composable
fun MessageList(messages: List<Message>) {
    // Remember our own LazyListState
    val listState = rememberLazyListState()

    // Provide it to LazyColumn
    LazyColumn(state = listState) {
        // ...
    }
}

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

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

@Composable
fun MessageList(messages: List<Message>) {
    Box {
        val listState = rememberLazyListState()

        LazyColumn(state = listState) {
            // ...
        }

        // Show the button if the first visible item is past
        // the first item. We use a remembered derived state to
        // minimize unnecessary compositions
        val showButton by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex > 0
            }
        }

        AnimatedVisibility(visible = showButton) {
            ScrollToTopButton()
        }
    }
}

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

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .map { index -> index > 0 }
        .distinctUntilChanged()
        .filter { it }
        .collect {
            MyAnalyticsService.sendScrolledPastFirstItemEvent()
        }
}

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

שליטה במיקום הגלילה

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

@Composable
fun MessageList(messages: List<Message>) {
    val listState = rememberLazyListState()
    // Remember a CoroutineScope to be able to launch
    val coroutineScope = rememberCoroutineScope()

    LazyColumn(state = listState) {
        // ...
    }

    ScrollToTopButton(
        onClick = {
            coroutineScope.launch {
                // Animate scroll to the first item
                listState.animateScrollToItem(index = 0)
            }
        }
    )
}

קבוצות גדולות של נתונים (חלוקה לדפים)

ספריית Paging מאפשרת לאפליקציות לתמוך ברשימות גדולות של פריטים, לטעון ולהציג חלקים קטנים מהרשימה לפי הצורך. הספרייה androidx.paging:paging-compose מספקת תמיכה ב-Compose ב-Paging 3.0 ואילך.

כדי להציג רשימה של תוכן עם מספור עמודים, אפשר להשתמש בפונקציית ההרחבה collectAsLazyPagingItems() ואז להעביר את הערך המוחזר LazyPagingItems אל items() ב-LazyColumn. בדומה לתמיכה בהחלפת דפים בתצוגות, אתם יכולים להציג מצייני מיקום בזמן שהנתונים נטענים. לשם כך, צריך לבדוק אם item הוא null:

@Composable
fun MessageList(pager: Pager<Int, Message>) {
    val lazyPagingItems = pager.flow.collectAsLazyPagingItems()

    LazyColumn {
        items(
            lazyPagingItems.itemCount,
            key = lazyPagingItems.itemKey { it.id }
        ) { index ->
            val message = lazyPagingItems[index]
            if (message != null) {
                MessageRow(message)
            } else {
                MessagePlaceholder()
            }
        }
    }
}

טיפים לשימוש בפריסות עצלניות

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

הימנעו משימוש בפריטים בגודל 0 פיקסלים

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

@Composable
fun Item(imageUrl: String) {
    AsyncImage(
        model = rememberAsyncImagePainter(model = imageUrl),
        modifier = Modifier.size(30.dp),
        contentDescription = null
        // ...
    )
}

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

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

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

// throws IllegalStateException
Column(
    modifier = Modifier.verticalScroll(state)
) {
    LazyColumn {
        // ...
    }
}

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

LazyColumn {
    item {
        Header()
    }
    items(data) { item ->
        PhotoItem(item)
    }
    item {
        Footer()
    }
}

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

Row(
    modifier = Modifier.horizontalScroll(scrollState)
) {
    LazyColumn {
        // ...
    }
}

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

Column(
    modifier = Modifier.verticalScroll(scrollState)
) {
    LazyColumn(
        modifier = Modifier.height(200.dp)
    ) {
        // ...
    }
}

זהירות: לא מומלץ להוסיף כמה רכיבים לפריט אחד

בדוגמה הזו, פונקציית ה-lambda של הפריט השני פולטת 2 פריטים בבלוק אחד:

LazyVerticalGrid(
    columns = GridCells.Adaptive(100.dp)
) {
    item { Item(0) }
    item {
        Item(1)
        Item(2)
    }
    item { Item(3) }
    // ...
}

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

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

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

LazyVerticalGrid(
    columns = GridCells.Adaptive(100.dp)
) {
    item { Item(0) }
    item {
        Item(1)
        Divider()
    }
    item { Item(2) }
    // ...
}

כדאי להשתמש בפריסות מותאמות אישית

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

כדי לעשות את זה, אפשר להשתמש בענף בהתאמה אישית Arrangement ולהעביר אותו אל LazyColumn. בדוגמה הבאה, האובייקט TopWithFooter צריך להטמיע רק את המתודה arrange. קודם כול, הפריטים יוצבו אחד אחרי השני. שנית, אם הגובה הכולל של האזור שבו נעשה שימוש נמוך מגובה אזור התצוגה, הכותרת התחתונה תמוקם בתחתית:

object TopWithFooter : Arrangement.Vertical {
    override fun Density.arrange(
        totalSize: Int,
        sizes: IntArray,
        outPositions: IntArray
    ) {
        var y = 0
        sizes.forEachIndexed { index, size ->
            outPositions[index] = y
            y += size
        }
        if (y < totalSize) {
            val lastIndex = outPositions.lastIndex
            outPositions[lastIndex] = totalSize - sizes.last()
        }
    }
}

כדאי להוסיף contentType

החל מ-Compose 1.2, כדי למקסם את הביצועים של Lazy layout, מומלץ להוסיף contentType לרשימות או לרשתות. כך תוכלו לציין את סוג התוכן של כל פריט בפריסה, במקרים שבהם אתם יוצרים רשימה או רשת שמורכבות מכמה סוגים שונים של פריטים:

LazyColumn {
    items(elements, contentType = { it.type }) {
        // ...
    }
}

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

מדידת הביצועים

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

מקורות מידע נוספים