גלילה

שינויים בגלילה

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

@Composable
private fun ScrollBoxes() {
    Column(
        modifier = Modifier
            .background(Color.LightGray)
            .size(100.dp)
            .verticalScroll(rememberScrollState())
    ) {
        repeat(10) {
            Text("Item $it", modifier = Modifier.padding(2.dp))
        }
    }
}

רשימה אנכית פשוטה שמגיבה לתנועות גלילה

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

@Composable
private fun ScrollBoxesSmooth() {
    // Smoothly scroll 100px on first composition
    val state = rememberScrollState()
    LaunchedEffect(Unit) { state.animateScrollTo(100) }

    Column(
        modifier = Modifier
            .background(Color.LightGray)
            .size(100.dp)
            .padding(horizontal = 8.dp)
            .verticalScroll(state)
    ) {
        repeat(10) {
            Text("Item $it", modifier = Modifier.padding(2.dp))
        }
    }
}

מאפיין שאפשר לגלול

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

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

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

@Composable
private fun ScrollableSample() {
    // actual composable state
    var offset by remember { mutableStateOf(0f) }
    Box(
        Modifier
            .size(150.dp)
            .scrollable(
                orientation = Orientation.Vertical,
                // Scrollable state: describes how to consume
                // scrolling delta and update offset
                state = rememberScrollableState { delta ->
                    offset += delta
                    delta
                }
            )
            .background(Color.LightGray),
        contentAlignment = Alignment.Center
    ) {
        Text(offset.toString())
    }
}

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

גלילה בתוך רכיב

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

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

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

גלילה אוטומטית בתוך גלילה

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

גלילה אוטומטית בתוך גלילה נתמכת ומסופקת מחוץ לקופסה על ידי חלק מהרכיבים והמשנים של Compose: ‫verticalScroll,‏ ‫horizontalScroll,‏ ‫scrollable,‏ ממשקי API של Lazy ו-TextField. כלומר, כשמשתמש גולל רכיב צאצא פנימי של רכיבים מקוננים, המשנים הקודמים מעבירים את דלתאות הגלילה לרכיבי האב שתומכים בגלילה מקוננת.

בדוגמה הבאה מוצגים אלמנטים עם משנה verticalScroll בתוך מאגר תגים שגם לו מוחל משנה verticalScroll.

@Composable
private fun AutomaticNestedScroll() {
    val gradient = Brush.verticalGradient(0f to Color.Gray, 1000f to Color.White)
    Box(
        modifier = Modifier
            .background(Color.LightGray)
            .verticalScroll(rememberScrollState())
            .padding(32.dp)
    ) {
        Column {
            repeat(6) {
                Box(
                    modifier = Modifier
                        .height(128.dp)
                        .verticalScroll(rememberScrollState())
                ) {
                    Text(
                        "Scroll here",
                        modifier = Modifier
                            .border(12.dp, Color.DarkGray)
                            .background(brush = gradient)
                            .padding(24.dp)
                            .height(150.dp)
                    )
                }
            }
        }
    }
}

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

שימוש במקש הצירוף nestedScroll

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

מחזור גלילה מוטמע

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

שלבים במחזור של גלילה מקוננת

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

שלבים במחזור הגלילה המקוננת

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

שלב לפני הגלילה – שליחה למעלה

כך יש להורים של רכיבי הגלילה (רכיבים שאפשר להרכיב באמצעות nestedScroll או משנים של scrollable) הזדמנות לעשות משהו עם הדלתא לפני שהצומת עצמו יכול לצרוך אותה.

שלב לפני הגלילה – העברה כלפי מטה

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

שלב צריכת הצומת

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

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

השלב שאחרי הגלילה – שליחה

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

השלב שאחרי הגלילה – העברת האירוע במעלה העץ (bubbling)

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

השתתפות במחזור הגלילה המקוננת

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

אם מחזור הגלילה המקונן הוא מערכת שפועלת על שרשרת של צמתים, משנה המאפיין nestedScroll מאפשר ליירט את השינויים האלה ולהוסיף להם שינויים, ולהשפיע על הנתונים (הפרשי הגלילה) שמועברים בשרשרת. אפשר למקם את ה-modifier הזה בכל מקום בהיררכיה, והוא מתקשר עם מופעים של modifier של גלילה מוטמעת בעץ כדי לשתף מידע דרך הערוץ הזה. אבני הבניין של התוסף הזה הן NestedScrollConnection ו-NestedScrollDispatcher.

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

val nestedScrollConnection = object : NestedScrollConnection {
    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        println("Received onPreScroll callback.")
        return Offset.Zero
    }

    override fun onPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource
    ): Offset {
        println("Received onPostScroll callback.")
        return Offset.Zero
    }
}

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

val disabledNestedScrollConnection = remember {
    object : NestedScrollConnection {
        override fun onPostScroll(
            consumed: Offset,
            available: Offset,
            source: NestedScrollSource
        ): Offset {
            return if (source == NestedScrollSource.SideEffect) {
                available
            } else {
                Offset.Zero
            }
        }
    }
}

כל הקריאות החוזרות מספקות מידע על הסוג NestedScrollSource.

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

שינוי הגודל של תמונה בגלילה

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

שינוי גודל של תמונה על סמך מיקום הגלילה

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

@Composable
fun ImageResizeOnScrollExample(
    modifier: Modifier = Modifier,
    maxImageSize: Dp = 300.dp,
    minImageSize: Dp = 100.dp
) {
    var currentImageSize by remember { mutableStateOf(maxImageSize) }
    var imageScale by remember { mutableFloatStateOf(1f) }

    val nestedScrollConnection = remember {
        object : NestedScrollConnection {
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                // Calculate the change in image size based on scroll delta
                val delta = available.y
                val newImageSize = currentImageSize + delta.dp
                val previousImageSize = currentImageSize

                // Constrain the image size within the allowed bounds
                currentImageSize = newImageSize.coerceIn(minImageSize, maxImageSize)
                val consumed = currentImageSize - previousImageSize

                // Calculate the scale for the image
                imageScale = currentImageSize / maxImageSize

                // Return the consumed scroll amount
                return Offset(0f, consumed.value)
            }
        }
    }

    Box(Modifier.nestedScroll(nestedScrollConnection)) {
        LazyColumn(
            Modifier
                .fillMaxWidth()
                .padding(15.dp)
                .offset {
                    IntOffset(0, currentImageSize.roundToPx())
                }
        ) {
            // Placeholder list items
            items(100, key = { it }) {
                Text(
                    text = "Item: $it",
                    style = MaterialTheme.typography.bodyLarge
                )
            }
        }

        Image(
            painter = ColorPainter(Color.Red),
            contentDescription = "Red color image",
            Modifier
                .size(maxImageSize)
                .align(Alignment.TopCenter)
                .graphicsLayer {
                    scaleX = imageScale
                    scaleY = imageScale
                    // Center the image vertically as it scales
                    translationY = -(maxImageSize.toPx() - currentImageSize.toPx()) / 2f
                }
        )
    }
}

נקודות עיקריות לגבי הקוד

  • הקוד הזה משתמש ב-NestedScrollConnection כדי ליירט אירועי גלילה.
  • onPreScroll מחשב את השינוי בגודל התמונה על סמך דלתא הגלילה.
  • משתנה המצב currentImageSize מאחסן את הגודל הנוכחי של התמונה, שמוגבל בין minImageSize ל-maxImageSize. imageScale ונגזר מ-currentImageSize.
  • הקיזוזים של LazyColumn מבוססים על currentImageSize.
  • ה-Image משתמש במאפיין שינוי graphicsLayer כדי להחיל את קנה המידה המחושב.
  • השימוש ב-translationY בתוך graphicsLayer מבטיח שהתמונה תישאר מיושרת למרכז באופן אנכי כשהיא משנה את הגודל.

התוצאה

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

איור 1. אפקט של שינוי גודל התמונה בזמן גלילה.

יכולת פעולה הדדית של גלילה מקוננת

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

הבעיה הזו נובעת מהציפיות שמוטמעות ברכיבים הניתנים להזזה. לרכיבים הניתנים לגלילה יש כלל שנקרא 'nested-scroll-by-default', כלומר כל מאגר שניתן לגלילה חייב להשתתף בשרשרת הגלילה המקוננת, גם כרכיב אב באמצעות NestedScrollConnection וגם כרכיב צאצא באמצעות NestedScrollDispatcher. במקרה כזה, הילד יגרום להורה לגלול בתוך עצמו כשהילד יגיע לגבול. לדוגמה, הכלל הזה מאפשר ל-Compose Pager ול-Compose LazyRow לפעול יחד בצורה טובה. עם זאת, כשמבצעים גלילה עם יכולת פעולה הדדית באמצעות ViewPager2 או RecyclerView, אי אפשר לבצע גלילה רציפה מצאצא להורה כי הרכיבים האלה לא מיישמים את NestedScrollingParent3.

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

הורה משתף פעולה View שמכיל ילד ComposeView

הורה משתף פעולה View הוא הורה שכבר מיישם את NestedScrollingParent3 ולכן יכול לקבל דלתאות גלילה ממרכיב צאצא משתף פעולה שמוטמע בתוך ההורה. במקרה הזה, ComposeView יפעל כצאצא ויצטרך להטמיע (באופן עקיף) את NestedScrollingChild3. דוגמה להורה משתף פעולה היא androidx.coordinatorlayout.widget.CoordinatorLayout.

אם אתם צריכים יכולת פעולה הדדית של גלילה מקוננת בין רכיבי View parent scrollable לבין רכיבי child composable מקוננים שניתן לגלול אותם, אתם יכולים להשתמש ב-rememberNestedScrollInteropConnection().

rememberNestedScrollInteropConnection() מאפשרת לזכור את ‫NestedScrollConnection שמאפשרת אינטראקטיביות של גלילה מקוננת בין רכיב אב מסוג View שמטמיע את ‫NestedScrollingParent3 לבין רכיב צאצא מסוג Compose. צריך להשתמש בו בשילוב עם משנה של nestedScroll. הגלילה המקוננת מופעלת כברירת מחדל בצד של Compose, ולכן אפשר להשתמש בחיבור הזה כדי להפעיל את הגלילה המקוננת בצד של View ולהוסיף את הלוגיקה הדרושה בין Views לרכיבים הניתנים להרכבה.

תרחיש שימוש נפוץ הוא שימוש ב-CoordinatorLayout, ב-CollapsingToolbarLayout ובקומפוזיציה של ילד, כמו בדוגמה הזו:

<androidx.coordinatorlayout.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/app_bar"
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:fitsSystemWindows="true">

        <com.google.android.material.appbar.CollapsingToolbarLayout
            android:id="@+id/collapsing_toolbar_layout"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:fitsSystemWindows="true"
            app:layout_scrollFlags="scroll|exitUntilCollapsed">

            <!--...-->

        </com.google.android.material.appbar.CollapsingToolbarLayout>

    </com.google.android.material.appbar.AppBarLayout>

    <androidx.compose.ui.platform.ComposeView
        android:id="@+id/compose_view"
        app:layout_behavior="@string/appbar_scrolling_view_behavior"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</androidx.coordinatorlayout.widget.CoordinatorLayout>

ב-Activity או ב-Fragment, צריך להגדיר את ה-composable של הילד ואת NestedScrollConnection הנדרש:

open class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        findViewById<ComposeView>(R.id.compose_view).apply {
            setContent {
                val nestedScrollInterop = rememberNestedScrollInteropConnection()
                // Add the nested scroll connection to your top level @Composable element
                // using the nestedScroll modifier.
                LazyColumn(modifier = Modifier.nestedScroll(nestedScrollInterop)) {
                    items(20) { item ->
                        Box(
                            modifier = Modifier
                                .padding(16.dp)
                                .height(56.dp)
                                .fillMaxWidth()
                                .background(Color.Gray),
                            contentAlignment = Alignment.Center
                        ) {
                            Text(item.toString())
                        }
                    }
                }
            }
        }
    }
}

קומפוזיציה של הורה שמכילה צאצא AndroidView

התרחיש הזה מתייחס להטמעה של ממשק API של אינטראופרביליות לגלילה מקוננת בצד של Compose – כשמשתמשים בקומפוזבל הורה שמכיל קומפוזבל צאצא AndroidView. ‫AndroidView implements NestedScrollDispatcher, since it acts as a child to a Compose scrolling parent, as well as NestedScrollingParent3 , since it acts as a parent to a View scrolling child. הקומפוננטה Compose parent תוכל לקבל דלתאות של גלילה מקוננת מקומפוננטת צאצא עם גלילה מקוננת View.

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

@Composable
private fun NestedScrollInteropComposeParentWithAndroidChildExample() {
    val toolbarHeightPx = with(LocalDensity.current) { ToolbarHeight.roundToPx().toFloat() }
    val toolbarOffsetHeightPx = remember { mutableStateOf(0f) }

    // Sets up the nested scroll connection between the Box composable parent
    // and the child AndroidView containing the RecyclerView
    val nestedScrollConnection = remember {
        object : NestedScrollConnection {
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                // Updates the toolbar offset based on the scroll to enable
                // collapsible behaviour
                val delta = available.y
                val newOffset = toolbarOffsetHeightPx.value + delta
                toolbarOffsetHeightPx.value = newOffset.coerceIn(-toolbarHeightPx, 0f)
                return Offset.Zero
            }
        }
    }

    Box(
        Modifier
            .fillMaxSize()
            .nestedScroll(nestedScrollConnection)
    ) {
        TopAppBar(
            modifier = Modifier
                .height(ToolbarHeight)
                .offset { IntOffset(x = 0, y = toolbarOffsetHeightPx.value.roundToInt()) }
        )

        AndroidView(
            { context ->
                LayoutInflater.from(context)
                    .inflate(R.layout.view_in_compose_nested_scroll_interop, null).apply {
                        with(findViewById<RecyclerView>(R.id.main_list)) {
                            layoutManager = LinearLayoutManager(context, VERTICAL, false)
                            adapter = NestedScrollInteropAdapter()
                        }
                    }.also {
                        // Nested scrolling interop is enabled when
                        // nested scroll is enabled for the root View
                        ViewCompat.setNestedScrollingEnabled(it, true)
                    }
            },
            // ...
        )
    }
}

private class NestedScrollInteropAdapter :
    Adapter<NestedScrollInteropAdapter.NestedScrollInteropViewHolder>() {
    val items = (1..10).map { it.toString() }

    override fun onCreateViewHolder(
        parent: ViewGroup,
        viewType: Int
    ): NestedScrollInteropViewHolder {
        return NestedScrollInteropViewHolder(
            LayoutInflater.from(parent.context)
                .inflate(R.layout.list_item, parent, false)
        )
    }

    override fun onBindViewHolder(holder: NestedScrollInteropViewHolder, position: Int) {
        // ...
    }

    class NestedScrollInteropViewHolder(view: View) : ViewHolder(view) {
        fun bind(item: String) {
            // ...
        }
    }
    // ...
}

בדוגמה הזו אפשר לראות איך משתמשים ב-API עם משנה scrollable:

@Composable
fun ViewInComposeNestedScrollInteropExample() {
    Box(
        Modifier
            .fillMaxSize()
            .scrollable(rememberScrollableState {
                // View component deltas should be reflected in Compose
                // components that participate in nested scrolling
                it
            }, Orientation.Vertical)
    ) {
        AndroidView(
            { context ->
                LayoutInflater.from(context)
                    .inflate(android.R.layout.list_item, null)
                    .apply {
                        // Nested scrolling interop is enabled when
                        // nested scroll is enabled for the root View
                        ViewCompat.setNestedScrollingEnabled(this, true)
                    }
            }
        )
    }
}

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

class BottomSheetFragment : BottomSheetDialogFragment() {

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        val rootView: View = inflater.inflate(R.layout.fragment_bottom_sheet, container, false)

        rootView.findViewById<ComposeView>(R.id.compose_view).apply {
            setContent {
                val nestedScrollInterop = rememberNestedScrollInteropConnection()
                LazyColumn(
                    Modifier
                        .nestedScroll(nestedScrollInterop)
                        .fillMaxSize()
                ) {
                    item {
                        Text(text = "Bottom sheet title")
                    }
                    items(10) {
                        Text(
                            text = "List item number $it",
                            modifier = Modifier.fillMaxWidth()
                        )
                    }
                }
            }
            return rootView
        }
    }
}

שימו לב: הפקודה rememberNestedScrollInteropConnection() תתקין את NestedScrollConnection באלמנט שאליו מצורפת הפקודה. ‫NestedScrollConnection אחראי להעברת השינויים מרמת ההרכבה לרמה View. ההגדרה הזו מאפשרת לאלמנט להשתתף בגלילה מקוננת, אבל היא לא מאפשרת גלילה של אלמנטים באופן אוטומטי. לרכיבי קומפוזיציה שלא ניתן לגלול בהם באופן אוטומטי, כמו Box או Column, לא תהיה אפשרות להעביר את ההפרשים של הגלילה במערכת הגלילה המקוננת, וההפרשים לא יגיעו אל NestedScrollConnection שסופק על ידי rememberNestedScrollInteropConnection(). לכן, ההפרשים האלה לא יגיעו לרכיב ההורה View. כדי לפתור את הבעיה, צריך להגדיר גם משנים שניתן לגלול בהם לסוגים האלה של רכיבי UI מוטמעים. מידע מפורט יותר זמין בקטע הקודם בנושא גלילה מקוננת.

הורה שלא משתף פעולה View שמכיל ילד ComposeView

תצוגה שלא משתפת פעולה היא תצוגה שלא מטמיעה את הממשקים הדרושים בצד View.NestedScrolling הערה: המשמעות היא שאי אפשר להשתמש בViews עם גלילה מקוננת בלי לבצע שינויים. מדינות Views שלא משתפות פעולה הן RecyclerView ו-ViewPager2.

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