רשימות ורשתות

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

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

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

אפשר להפוך את Column לגלילה באמצעות המשתנה המשנה verticalScroll().

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

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

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

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

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

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.

רשתות טעינה מדורגת

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

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

קטע הקוד הבא הוא דוגמה בסיסית לשימוש ב-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()
)
רשת תמונות מדורגת ב-Compose
איור 2. דוגמה לרשת אנכית עם פריסת מודעות מדורגת ועצלנית עם עמודות קבועות

הוספת תוכן

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

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

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

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

דוגמה נוספת: אפשר להעביר את PaddingValues של Scaffold אל 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)
    }
}

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

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

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

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 מקצה מפתח ייחודי לכל פריט ברשימה. Compose משתמש במפתחות כדי לעקוב אחרי הפריטים ולזהות שינויים במיקומים שלהם.
  • 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 הם ביטויי למבדה (lambda) שמועברים אל AddRemoveButtons כדי להוסיף ולסמן פריטים מהרשימה.
    • resetOrder,‏ onSortAlphabetically ו-onSortByLength הם ביטויי lambda שמועברים ל-OrderButtons כדי לשנות את הסדר של הפריטים ברשימה.
  • AddRemoveButtons מציג את הלחצנים 'הוספה' ו'הסרה'. הוא מפעיל או משבית את הלחצנים ומטפל בקליקים על הלחצנים.
  • OrderButtons מוצגים הלחצנים לסידור מחדש של הרשימה. היא מקבלת את פונקציות הלמהדה (lambda) לאיפוס הסדר ולמיון הרשימה לפי אורך או לפי סדר אלפביתי.
  • ListAnimatedItems קורא ל-composable‏ ListAnimatedItems, מעביר את הרשימה data כדי להציג את הרשימה האנימציה של המחרוזות. הערך data מוגדר במקום אחר.

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

@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 הם פונקציות lambda שפועלות כשהמשתמש לוחץ על הלחצן המתאים.

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

@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 הן פונקציות lambda שמבוצעות כשבוחרים את הלחצן המתאים.
  • משתנה המצב selectedIndex עוקב אחרי האפשרות שנבחרה.

התוצאה

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

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

כותרות דביקות (ניסיוני)

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

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

כדי ליצור כותרת צפה עם LazyColumn, אפשר להשתמש בפונקציה הניסיונית stickyHeader(), ולספק את תוכן הכותרת:

@OptIn(ExperimentalFoundationApi::class)
@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] }

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

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

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

אפליקציות רבות צריכות להגיב לשינויים במיקום הגלילה ובפריסה של הפריטים. הרכיבים העצלנים תומכים בתרחיש לדוגמה הזה על ידי העלאת הערך של 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 3.0 ואילך יש תמיכה ב-Compose דרך הספרייה androidx.paging:paging-compose.

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

טיפים לשימוש בפריסות 'איט'

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

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

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

@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)
    ) {
        // ...
    }
}

חשוב לא להוסיף כמה רכיבים לפריט אחד

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

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

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

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

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

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

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

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

כדי לעשות זאת, אפשר להשתמש בקטגוריה בהתאמה אישית 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

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

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

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

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

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