גלילה בתצוגות עץ היא מערכת שבה כמה רכיבי גלילה שמוכלים זה בתוך זה פועלים יחד על ידי תגובה לתנועת גלילה אחת והעברת השינויים בגלילה.
מערכת הגלילה בתצוגות עץ מאפשרת תיאום בין רכיבים שניתן לגלול בהם ומקושרים באופן היררכי (לרוב על ידי שיתוף של אותו הורה). המערכת הזו מקשרת בין קונטיינרים של גלילה ומאפשרת אינטראקציה עם השינויים בגלילה שמופצים ומשותפים ביניהם.
ב-Compose יש כמה דרכים לטפל בגלילה בתצוגות עץ בין רכיבים קומפוזביליים. דוגמה אופיינית לגלילה בתצוגות עץ היא רשימה בתוך רשימה אחרת, ומקרה מורכב יותר הוא סרגל כלים שניתן לכיווץ.
גלילה אוטומטית של רכיבים מקוננים
גלילה פשוטה בתוך רכיב לא דורשת שום פעולה מצידכם. תנועות שמתחילות פעולת גלילה מועברות אוטומטית מצאצאים להורים, כך שכאשר הצאצא לא יכול לגלול יותר, התנועה מטופלת על ידי רכיב ההורה שלו.
חלק מהרכיבים ומהתכונות לשינוי של Compose תומכים בגלילה אוטומטית בתצוגות עץ ומספקים אותה באופן מובנה:
verticalScroll,
horizontalScroll,
scrollable,
ממשקי API של Lazy ו-TextField. כלומר, כשהמשתמש גולל רכיב צאצא פנימי של רכיבים בתצוגת עץ, משתני ה-modifier הקודמים מעבירים את השינויים בגלילה לרכיבי ההורה שתומכים בגלילה בתצוגת עץ.
בדוגמה הבאה מוצגים רכיבים שהוחל עליהם משתנה מסוג
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 או בשינויי גלילה) יש הזדמנות לעשות משהו עם הדלתא לפני שהצומת עצמו יכול לצרוך אותה.
בשלב צריכת הצומת, הצומת עצמו ישתמש בכל דלתא שלא נוצלה על ידי ההורים שלו. זה קורה כשתנועת הגלילה מתבצעת בפועל וגלויה לעין.
בשלב הזה, הילד יכול לבחור אם להשתמש בכל זמן הגלילה שנותר או רק בחלק ממנו. כל מה שנשאר יישלח חזרה למעלה כדי לעבור את שלב הגלילה.
לבסוף, בשלב שאחרי הגלילה, כל מה שהצומת עצמו לא צרך יישלח שוב לאבות הקדמונים שלו לצריכה.
השלב שאחרי הגלילה פועל באופן דומה לשלב שלפני הגלילה, שבו כל אחד מההורים יכול לבחור אם לצרוך את האירוע או לא.
בדומה לגלילה, כשמסיימים תנועת גרירה, יכול להיות שהכוונה של המשתמש תתורגם למהירות שמשמשת להטלה (גלילה באמצעות אנימציה) של מאגר הגלילה. ההטלה היא גם חלק ממחזור הגלילה בתצוגת עץ, והמהירויות שנוצרות על ידי אירוע הגרירה עוברות שלבים דומים: לפני ההטלה, צריכת צומת ואחרי ההטלה. חשוב לזכור שאנימציית ההנפה משויכת רק לתנועת מגע, ולא תופעל על ידי אירועים אחרים, כמו נגישות או גלילה באמצעות חומרה.
השתתפות במחזור הגלילה המקוננת
השתתפות במחזור פירושה יירוט, צריכה ודיווח על צריכת השינויים לאורך ההיררכיה. ב-Compose יש קבוצת כלים שמשפיעים על אופן הפעולה של מערכת הגלילה בתצוגת עץ ועל האינטראקציה הישירה איתה. לדוגמה, כשצריך לעשות משהו עם השינויים בגלילה לפני שרכיב שניתן לגלילה בכלל מתחיל לגלול.
אם מחזור הגלילה בתצוגה הפנימית הוא מערכת שפועלת על שרשרת של צמתים, המשתנה nestedScroll הוא דרך ליירט את השינויים האלה ולהוסיף אותם, ולהשפיע על הנתונים (הדלתות של הגלילה) שמופצים בשרשרת. אפשר למקם את המשתנה הזה בכל מקום בהיררכיה, והוא מתקשר עם מופעים של משתני גלילה בתצוגת עץ כדי לשתף מידע דרך הערוץ הזה. אבני הבניין של המאפיין הזה הן 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משתמש ב-modifier שלgraphicsLayerכדי להחיל את קנה המידה המחושב. - ה-
translationYבתוך ה-graphicsLayerמבטיח שהתמונה תישאר ממורכזת אנכית כשהיא משתנה בגודל.
תוצאה
הקטע הקודם יוצר אפקט של שינוי קנה מידה של התמונה בגלילה:
יכולת פעולה הדדית של גלילה בתצוגות מקוננות
כשמנסים להטמיע רכיבי View שניתן לגלול בתוך רכיבים קומפוזביליים שניתן לגלול, או להיפך, יכול להיות שיהיו בעיות. הבעיות הכי בולטות יקרו כשתגללו את הצאצא ותגיעו לגבולות ההתחלה או הסוף שלו, ותצפו שההורה ישתלט על הגלילה. עם זאת, יכול להיות שההתנהגות הצפויה הזו לא תתרחש או לא תפעל כצפוי.
הבעיה הזו נובעת מהציפיות שנוצרו ברכיבי קומפוזביליים שניתן לגלול בהם.
לרכיבים שאפשר לגלול יש כלל "גלילה בתצוגת עץ כברירת מחדל", כלומר כל קונטיינר שאפשר לגלול חייב להשתתף בשרשרת הגלילה בתצוגת עץ, גם כהורה דרך NestedScrollConnection וגם כצאצא דרך NestedScrollDispatcher.
כשהרכיב הצאצא מגיע לגבול, הוא מפעיל גלילה בתצוגה הפנימית של רכיב ההורה. לדוגמה, הכלל הזה מאפשר ל-Compose Pager ול-Compose LazyRow לפעול היטב ביחד. עם זאת, כשמבצעים גלילה עם יכולת פעולה הדדית באמצעות ViewPager2 או RecyclerView, מכיוון שהם לא מטמיעים את NestedScrollingParent3, אי אפשר לבצע גלילה רציפה מצאצא להורה.
כדי להפעיל את ה-API של יכולת הפעולה ההדדית של גלילה בתצוגות עץ בין רכיבי View שניתן לגלול בהם לבין רכיבים קומפוזביליים שניתן לגלול בהם, בתצוגת עץ בשני הכיוונים, אתם יכולים להשתמש ב-API של יכולת הפעולה ההדדית של גלילה בתצוגות עץ כדי לצמצם את הבעיות האלה בתרחישים הבאים.
הורה משתף פעולה View שמכיל צאצא ComposeView
רכיב הורה משתף פעולה View הוא רכיב שכבר מטמיע את
NestedScrollingParent3
ולכן יכול לקבל שינויי גלילה מרכיב צאצא משתף פעולה שמוטמע בתוכו. במקרה כזה, ComposeView יפעל כצאצא ויצטרך להטמיע (באופן עקיף) את NestedScrollingChild3.
דוגמה להורה משתף פעולה היא androidx.coordinatorlayout.widget.CoordinatorLayout.
אם אתם צריכים יכולת פעולה הדדית של גלילה בתצוגות עץ בין קונטיינרים הורים של View שניתנים לגלילה לבין רכיבי Composable צאצאים שניתנים לגלילה, אתם יכולים להשתמש ב-rememberNestedScrollInteropConnection().
rememberNestedScrollInteropConnection() מאפשר וזוכר את NestedScrollConnection שמאפשר יכולת פעולה הדדית של גלילה בתצוגת עץ בין הורה View שמטמיע את NestedScrollingParent3 לבין צאצא של Compose. צריך להשתמש בזה בשילוב עם משתנה מסוג nestedScroll. מכיוון שהגלילה בתצוגות עץ מופעלת כברירת מחדל בצד של Compose, אתם יכולים להשתמש בחיבור הזה כדי להפעיל את הגלילה בתצוגות עץ בצד של View ולהוסיף את הלוגיקה הדרושה לחיבור בין Views לבין רכיבים קומפוזביליים.
תרחיש שימוש נפוץ הוא שימוש ב-CoordinatorLayout, ב-CollapsingToolbarLayout וב-composable צאצא, כמו בדוגמה הזו:
<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 מטמיע את
NestedScrollDispatcher,
כי הוא פועל כצאצא של הורה לגלילה ב-Compose, וגם את
NestedScrollingParent3
, כי הוא פועל כהורה לצאצא לגלילה ב-View. כך, רכיב ההורה של Compose יוכל לקבל דלתות של גלילה בתצוגת עץ מרכיב צאצא שניתן לגלילה בתצוגת עץ 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( title = {}, 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(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 אחראי להעברת השינויים מרמת Compose לרמת View. כך הרכיב יכול להשתתף בגלילה בתצוגות עץ, אבל זה לא מפעיל גלילה של רכיבים באופן אוטומטי. לרכיבים שניתנים לשילוב שלא ניתנים לגלילה באופן אוטומטי, כמו Box או Column, דלתות הגלילה ברכיבים כאלה לא יופצו במערכת הגלילה בתצוגת עץ והדלתות לא יגיעו אל NestedScrollConnection שסופק על ידי rememberNestedScrollInteropConnection(), ולכן הדלתות האלה לא יגיעו לרכיב ההורה View. כדי לפתור את הבעיה, צריך להגדיר גם משתני שינוי שניתנים לגלילה לסוגים האלה של רכיבי composable בתצוגה עץ. מידע נוסף זמין בקטע הקודם בנושא גלילה בתצוגות עץ.
הורה לא משתף פעולה View שמכיל ילד ComposeView
תצוגה לא תואמת היא תצוגה שלא מטמיעה את הממשקים הנדרשים של NestedScrolling בצד של View. חשוב לדעת: המשמעות היא שאי אפשר להשתמש בתכונה "גלילה בתצוגות עץ" עם רכיבי Views האלה בלי לבצע פעולות נוספות. השותפים הלא משתפים פעולה Views הם RecyclerView ו-ViewPager2.
מקורות מידע נוספים
מומלץ בשבילכם
- הערה: טקסט הקישור מוצג כש-JavaScript מושבת
- הסבר על תנועות
- העברת
CoordinatorLayoutאל Compose - שימוש ב-Views בפיתוח נייטיב