גלילה מקוננת היא מערכת שבה כמה רכיבי גלילה שמוכלים זה בתוך זה פועלים יחד בתגובה למחוות גלילה אחת, ומעבירים אחד לשני את שינויי הגלילה (דלתאות).
מערכת הגלילה המקוננת מאפשרת תיאום בין רכיבים שאפשר לגלול אותם ומקושרים באופן היררכי (לרוב על ידי שיתוף אותו רכיב אב). המערכת הזו מקשרת בין מאגרי גלילה ומאפשרת אינטראקציה עם ערכי הדלתא של הגלילה שמועברים ומשותפים ביניהם.
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.
שלבים במחזור גלילה מקוננת
כשמזוהה אירוע טריגר (לדוגמה, תנועת אצבע) על ידי רכיב שאפשר לגלול בו, לפני שפעולת הגלילה עצמה מופעלת, הדלתות שנוצרו נשלחות למערכת הגלילה המקוננת ועוברות שלושה שלבים: לפני הגלילה, צריכת הצומת ואחרי הגלילה.
בשלב הראשון, לפני הגלילה, הרכיב שקיבל את אירועי הטריגר של deltas ישלח את האירועים האלה למעלה, דרך עץ ההיררכיה, אל ההורה העליון. לאחר מכן, אירועי הדלתא יועברו למטה, כלומר ערכי הדלתא יועברו מהרמה הבסיסית ביותר של ההורה כלפי מטה אל הצאצא שהתחיל את מחזור הגלילה המקונן.
כך יש להורים של רכיבי הגלילה (רכיבים שאפשר להרכיב באמצעות nestedScroll או משנים מסוג scrollable) הזדמנות לבצע פעולה כלשהי עם הדלתא לפני שהצומת עצמו יכול לצרוך אותה.
בשלב הצריכה של הצומת, הצומת עצמו ישתמש בכל דלתא שלא נעשה בה שימוש על ידי הצמתים ברמת ההורה. זה קורה כשתנועת הגלילה מתבצעת בפועל ומוצגת.
במהלך השלב הזה, הילדים יכולים לבחור לצפות בכל הסרטון או רק בחלק ממנו. כל מה שיישאר יוחזר למעלה כדי לעבור את השלב שאחרי הגלילה.
לבסוף, בשלב שלאחר הגלילה, כל מה שהצומת עצמו לא צרך יישלח שוב לצומתי האב שלו לצריכה.
השלב שאחרי הגלילה פועל באופן דומה לשלב שלפני הגלילה, שבו כל אחד מהרכיבים ברמת ההורה יכול לבחור אם להשתמש בנתונים או לא.
בדומה לגלילה, כשמחוות הגרירה מסתיימת, הכוונה של המשתמש יכולה להתורגם למהירות שמשמשת להטלה (גלילה באמצעות אנימציה) של המאגר שאפשר לגלול בו. ההטלה היא גם חלק ממחזור הגלילה המקונן, והמהירויות שנוצרות על ידי אירוע הגרירה עוברות שלבים דומים: לפני ההטלה, צריכת הצומת ואחרי ההטלה. שימו לב שאנימציית ההטלה משויכת רק למחוות מגע, ולא מופעלת על ידי אירועים אחרים, כמו גלילה בנגישות או גלילה בחומרה.
השתתפות במחזור גלילה מקונן
השתתפות במחזור פירושה יירוט, צריכה ודיווח על צריכת דלתאות לאורך ההיררכיה. Compose מספקת קבוצה של כלים שמאפשרים להשפיע על אופן הפעולה של מערכת הגלילה המקוננת ועל אופן האינטראקציה איתה ישירות. לדוגמה, אם אתם צריכים לבצע פעולה כלשהי עם דלתאות הגלילה לפני שרכיב ניתן לגלילה מתחיל לגלוש.
אם מחזור הגלילה המקונן הוא מערכת שפועלת על שרשרת של צמתים, ה-modifier 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 } }
כל קריאה חוזרת (callback) מספקת גם מידע על הדלתא שמועברת:
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מבטיח שהתמונה תישאר מיושרת למרכז באופן אנכי כשהגודל שלה משתנה.
תוצאה
הקטע הקודם יוצר אפקט של שינוי גודל התמונה בזמן גלילה:
יכולת פעולה הדדית של גלילה מקוננת
כשמנסים להוסיף רכיבי View עם אפשרות גלילה לרכיבי Composable עם אפשרות גלילה, או להיפך, יכולות להיות בעיות. הדוגמאות הבולטות ביותר הן כשגוללים את הילד ומגיעים לגבולות ההתחלה או הסיום שלו, ומצפים שההורה ימשיך את הגלילה. עם זאת, יכול להיות שההתנהגות הצפויה הזו לא תקרה או שהיא לא תפעל כצפוי.
הבעיה הזו נובעת מהציפיות שמוטמעות ברכיבים הניתנים להזזה.
לרכיבי Composables עם אפשרות גלילה יש כלל 'nested-scroll-by-default', כלומר כל מאגר עם אפשרות גלילה חייב להשתתף בשרשרת הגלילה המקוננת, גם כרכיב אב באמצעות NestedScrollConnection וגם כרכיב צאצא באמצעות NestedScrollDispatcher.
במקרה כזה, הילד יגרום להורה לגלול בתוך עצמו כשהילד יגיע לגבול. לדוגמה, הכלל הזה מאפשר ל-Compose Pager ול-Compose LazyRow לעבוד יחד בצורה טובה. עם זאת, כשמבצעים גלילה בין חלונות באמצעות ViewPager2 או RecyclerView, אי אפשר לבצע גלילה רציפה מצאצא להורה כי הפקדים האלה לא מיישמים את NestedScrollingParent3.
כדי להפעיל את nested scrolling interop API בין רכיבי View שניתן לגלול בהם לבין רכיבי composable שניתן לגלול בהם, שמוטמעים אחד בתוך השני בשני הכיוונים, אתם יכולים להשתמש ב-nested scrolling interop API כדי לפתור את הבעיות האלה בתרחישים הבאים.
הורה משתף פעולה View שמכיל ילד ComposeView
הורה משתף פעולה View הוא הורה שכבר מיישם את NestedScrollingParent3 ולכן יכול לקבל דלתאות גלילה ממרכיב צאצא משתף פעולה. ComposeView יפעל כרכיב צאצא במקרה הזה ויצטרך להטמיע (באופן עקיף) את NestedScrollingChild3.
דוגמה להורה משתף פעולה היא androidx.coordinatorlayout.widget.CoordinatorLayout.
אם אתם צריכים יכולת פעולה הדדית של גלילה מקוננת בין רכיבי parent View שניתן לגלול אותם לבין רכיבי 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()) } } } } } } }
רכיב Parent composable שמכיל רכיב Child 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(
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. ההגדרה הזו מאפשרת לאלמנט להשתתף בגלילה מקוננת, אבל היא לא מאפשרת גלילה של אלמנטים באופן אוטומטי. לרכיבי Composable שלא ניתן לגלול בהם באופן אוטומטי, כמו Box או Column, דלתאות הגלילה ברכיבים כאלה לא יועברו במערכת הגלילה המקוננת, והדלתאות לא יגיעו אל NestedScrollConnection שסופק על ידי rememberNestedScrollInteropConnection(), ולכן הדלתאות האלה לא יגיעו לרכיב ההורה View. כדי לפתור את הבעיה, צריך להגדיר גם משנים שניתן לגלול בהם לסוגים האלה של רכיבים מורכבים מוטמעים. מידע מפורט יותר זמין בקטע הקודם בנושא גלילה מקוננת.
הורה לא משתף פעולה View שמכיל ילד ComposeView
תצוגה שלא משתפת פעולה היא תצוגה שלא מטמיעה את הממשקים הדרושים בצד NestedScrolling View. הערה: המשמעות היא שאי אפשר להשתמש בViews האלה כדי להפעיל גלילה מקוננת. החברות שלא משתפות פעולה עם Views הן RecyclerView ו-ViewPager2.
מקורות מידע נוספים
מומלץ בשבילכם
- הערה: טקסט הקישור מוצג כש-JavaScript מושבת
- הסבר על תנועות
- העברה של
CoordinatorLayoutאל Compose - שימוש ב-Views ב-Compose