רשימות עם 'כתיבה' ל-Wear OS


רשימות מאפשרות למשתמשים לבחור פריט מתוך קבוצת אפשרויות במכשירי Wear OS.

בהרבה מכשירי Wear OS יש מסכים עגולים, ולכן קשה יותר לראות פריטים ברשימה שמופיעים קרוב לחלק העליון והתחתון של המסך. לכן, ב-Compose for Wear OS יש גרסה של המחלקה LazyColumn שנקראת TransformingLazyColumn, שתומכת בהנפשות של שינוי גודל ושינוי צורה. כשהפריטים מגיעים לקצוות, הם קטנים יותר ודהויים.

כדי להחיל את אפקטי ההזזה והשינוי המומלצים:

  1. משתמשים ב-Modifier.transformedHeight כדי לאפשר ל-Compose לחשב את שינוי הגובה בזמן שהפריט עובר במסך.
  2. משתמשים ב-transformation = SurfaceTransformation(transformationSpec) כדי להחיל את האפקטים הוויזואליים, כולל הקטנת התוכן של הפריט.
  3. משתמשים בTransformationSpec מותאם אישית לרכיבים שלא מקבלים את transformation כפרמטר, כמו Text.

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

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

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

כדי להציג את אינדיקטור הגלילה, משווים את נתח columnState בין ScreenScaffold לבין TransformingLazyColumn:

val columnState = rememberTransformingLazyColumnState()
val transformationSpec = rememberTransformationSpec()
ScreenScaffold(
    scrollState = columnState
) { contentPadding ->
    TransformingLazyColumn(
        state = columnState,
        contentPadding = contentPadding
    ) {
        item {
            ListHeader(
                modifier = Modifier
                    .fillMaxWidth()
                    .transformedHeight(this, transformationSpec)
                    .minimumVerticalContentPadding(ListHeaderDefaults.minimumTopListContentPadding),
                transformation = SurfaceTransformation(transformationSpec)
            ) {
                Text(text = "Header")
            }
        }
        // ... other items
        item {
            Button(
                modifier = Modifier
                    .fillMaxWidth()
                    .transformedHeight(this, transformationSpec)
                    .minimumVerticalContentPadding(ButtonDefaults.minimumVerticalListContentPadding),
                transformation = SurfaceTransformation(transformationSpec),
                onClick = { /* ... */ },
                icon = {
                    Icon(
                        imageVector = Icons.Default.Build,
                        contentDescription = "build",
                    )
                },
            ) {
                Text(
                    text = "Build",
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis,
                )
            }
        }
    }
}

הוספת אפקט של הצמדה והטלה

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

כדי להוסיף התנהגות של הצמדה והטלה, מגדירים את הפרמטר flingBehavior לערך TransformingLazyColumnDefaults.snapFlingBehavior(columnState). כדי להגדיר את rotaryScrollableBehavior כך שיתאים, משתמשים ב-RotaryScrollableDefaults.snapBehavior(columnState) כדי לקבל חוויה עקבית כשמשתמשים בכתר או במסגרת הפיזיים.

val columnState = rememberTransformingLazyColumnState()
ScreenScaffold(scrollState = columnState) { contentPadding ->
    TransformingLazyColumn(
        state = columnState,
        flingBehavior = TransformingLazyColumnDefaults.snapFlingBehavior(columnState),
        rotaryScrollableBehavior = RotaryScrollableDefaults.snapBehavior(columnState)
    ) {
        // ...
        // ...
    }
}

היפוך הפריסה

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

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

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

כדי שיהיה לכם נוח, ההגדרה reverseLayout = true גם הופכת את הסדר החזותי של הפריטים ואת הכיוון של תנועות הגלילה:

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

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

val columnState = rememberTransformingLazyColumnState()
val transformationSpec = rememberTransformationSpec()
ScreenScaffold(scrollState = columnState) { contentPadding ->
    TransformingLazyColumn(
        state = columnState,
        contentPadding = contentPadding,
        reverseLayout = true,
        modifier = Modifier.fillMaxWidth()
    ) {
        items(10) { index ->
            Button(
                label = {
                    Text(
                        text = "Item ${index + 1}"
                    )
                },
                onClick = {},
                modifier = Modifier
                    .fillMaxWidth()
                    .transformedHeight(this, transformationSpec)
                    .minimumVerticalContentPadding(ButtonDefaults.minimumVerticalListContentPadding),
                transformation = SurfaceTransformation(transformationSpec)
            )
        }
        item {
            // With reverseLayout = true, the last item declared appears at the top.
            ListHeader(
                modifier = Modifier
                    .fillMaxWidth()
                    .transformedHeight(this, transformationSpec)
                    .minimumVerticalContentPadding(ListHeaderDefaults.minimumTopListContentPadding),
                transformation = SurfaceTransformation(transformationSpec)
            ) {
                Text("Header")
            }
        }
    }
}

בתמונות הבאות אפשר לראות את ההבדל בין רשימה רגילה לרשימה הפוכה:

‫TransformingLazyColumn עם פריסה רגילה, שבו מוצג Item 1 בחלק העליון ופריטים בסדר עולה.
איור 1. פריסת רשימה רגילה שבה התוכן מוצג מלמעלה למטה.
רכיב TransformingLazyColumn עם פריסה הפוכה, שבו הפריט Item 1 מופיע בתחתית והפריטים מופיעים בסדר יורד כלפי החלק העליון.
איור 2. פריסת רשימה הפוכה שבה התוכן מתמלא מלמטה למעלה.

כפתורי קצה ברשימות

ב-Material 3, אפשר להוסיף EdgeButton, שהוא לחצן שצמוד לקצה התחתון של הרשימות. עם זאת, חשוב להקפיד לא להוסיף את זה כפריט בתוך TransformingLazyColumn, אלא להשתמש במשבצת edgeButton ב-ScreenScaffold.

שימוש במשבצת edgeButton מבטיח שהלחצן ימוקם בצורה נכונה בתחתית המסך ויתנהג בצורה מתאימה כשמגללים את הרשימה.

בקטע הקוד הבא אפשר לראות איך מטמיעים את EdgeButton:

val columnState = rememberTransformingLazyColumnState()
ScreenScaffold(
    scrollState = columnState,
    edgeButton = {
        EdgeButton(
            onClick = { /* TODO */ },
            modifier = Modifier.scrollable(
                columnState,
                orientation = Orientation.Vertical,
                reverseDirection = true,
                // Apply overscroll to the EdgeButton for proper scrolling behavior.
                overscrollEffect = rememberOverscrollEffect(),
            )
        ) {
            Text("More")
        }
    }
) { contentPadding ->
    TransformingLazyColumn(
        contentPadding = contentPadding,
        state = columnState,
    ) {
        // ...
        // ...
    }
}

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

הרכיב SwipeToReveal מאפשר לכם לגשת לפעולות של פריט ברשימה, כמו Card או Chip, באמצעות החלקה. בדרך כלל, בהחלקה מופיעים מהצד לחצן פעולה אחד או שניים (למשל 'מחיקה' או 'עוד').

כשמשתמשים ב-SwipeToReveal בתוך TransformingLazyColumn, חשוב לפעול לפי ההנחיות הבאות:

  • איפוס בגלילה: כשמשתמש גולל ברשימה, כל הפריטים שנפתחו בהחלקה יאופסו למצב המכוסה שלהם.
  • גבהים עקביים: מגדירים את הגובה של כפתורי הפעולה כך שיתאים לגובה של הפריט הפנימי שמופיע אחרי ההחלקה (בין אם מדובר בButton או בCard) כדי להבטיח מראה עקבי.
  • שינוי מאגר התגים: מוסיפים את המשנה transformedHeight ואת transformationSpec לרכיב SwipeToReveal עצמו.
  • אל תבצעו טרנספורמציה כפולה: אל תחיל את המשנים transformedHeight או transformation על הפריט הפנימי שניתן להחליק (הכרטיס או הלחצן בתוך מאגר התגים SwipeToReveal).

רכיבי Composable מותאמים אישית ברשימות

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

  • Expose SurfaceTransformation: מקבל פרמטר אופציונלי של SurfaceTransformation (ברירת המחדל היא null), שתואם לרכיבים רגילים של Wear Compose Material 3 כמו Card ו-Button. כך, מי שמתקשר ל-TransformingLazyColumn יכול לעבור SurfaceTransformation(transformationSpec), אבל הרכיב יפעל כרגיל מחוץ לרשימה.
  • החלת Modifier.transformedHeight קודם בקוד הקריאה: כשמציבים את הרכיב המורכב המותאם אישית ב-TransformingLazyColumn, מעבירים את Modifier.transformedHeight(this, transformationSpec) כמשנה הראשון בשרשרת המשנים של קוד הקריאה. הפונקציה SurfaceTransformation מחילה את אפקטי ההתכווצות וההתפוגגות החזותיים, אבל הפונקציה transformedHeight חיונית כדי להורות לפריסת הרשימה לחשב מחדש את גובה הפריט כשהוא מתכווץ.
  • החלת שכבות של טרנספורמציה, קריאה ל-modifier וצביעה לפי הסדר:
    1. שכבת טרנספורמציה של מאגר התגים: מתחילים את שרשרת המשנים של מאגר התגים הבסיסי עם Modifier.graphicsLayer ו-applyContainerTransformation(), כך שגם הרקע וגם התוכן מצוירים במרחב הקואורדינטות המוקטן והמוטה.
    2. Caller modifier: הפרמטר modifier שמועבר על ידי המתקשר (כולל Modifier.transformedHeight) יוחל בשלב הבא, לפני כל שינוי גודל או ריווח פנימי.
    3. Shape clip when there is no transformation: If transformation is null, apply Modifier.clip(shape) before drawing the background. הצייר שמוחזר על ידי createContainerPainter() חותך את עצמו לצורה, אבל צייר רגיל לא עושה את זה, ולכן בלי זה הרקע מצויר עם פינות מרובעות מחוץ לרשימה.
    4. Morphing background painter: ציור הרקע בתוך שכבת הקונטיינר באמצעות Modifier.drawBehind וצייר שנוצר מ-createContainerPainter().
    5. שכבת שינוי תוכן: אפשר להחיל Modifier.graphicsLayer שני עם applyContentTransformation() ולחתוך את הצורה של מאגר התוכן כדי שהתוכן הפנימי ידהה מוקדם יותר כשהוא מתקרב למסגרת.

בקטע הקוד הבא אפשר לראות איך מטמיעים BoardingPassCardComposable מותאם אישית שמחיל את השינויים האלה לפי הסדר:

@Composable
fun BoardingPassCard(
    flightNumber: String,
    origin: String,
    destination: String,
    gate: String,
    seat: String,
    departureTime: String,
    modifier: Modifier = Modifier,
    transformation: SurfaceTransformation? = null,
    shape: Shape = RoundedCornerShape(18.dp),
    statusBadge: @Composable () -> Unit = {}
) {
    // 1. Create morphing container painter
    val backgroundPainter = ColorPainter(MaterialTheme.colorScheme.surfaceContainer)
    val finalPainter = if (transformation != null) {
        remember(transformation, backgroundPainter, shape) {
            transformation.createContainerPainter(backgroundPainter, shape, border = null)
        }
    } else {
        backgroundPainter
    }

    Column(
        modifier = Modifier
            // 2a. Container layer: Scales, fades, and tilts the whole card surface
            .then(
                if (transformation != null) {
                    Modifier.graphicsLayer {
                        transformation.run { applyContainerTransformation() }
                    }
                } else Modifier
            )
            // 2b. Caller modifier: Includes Modifier.transformedHeight in a list
            .then(modifier)
            .fillMaxWidth()
            // 2c. Shape clip: Only needed without a transformation, because the
            // painter from createContainerPainter clips itself to the shape
            .then(if (transformation == null) Modifier.clip(shape) else Modifier)
            // 2d. Morphing background: Drawn inside the transformed container layer
            .drawBehind {
                with(finalPainter) {
                    draw(size)
                }
            }
            // 2e. Content layer: Fades content earlier and clips children to shape
            .then(
                if (transformation != null) {
                    Modifier.graphicsLayer {
                        this.shape = shape
                        this.clip = true
                        transformation.run { applyContentTransformation() }
                    }
                } else Modifier
            )
            .padding(horizontal = 14.dp, vertical = 10.dp)
    ) {
        // Card content goes here
    }
}
איור 3. כרטיס עלייה למטוס בהתאמה אישית שמשתנה בזמן הגלילה ברשימה.

אחר כך אפשר להשתמש ב-BoardingPassCard בתוך TransformingLazyColumn על ידי העברת Modifier.transformedHeight כמשנה הראשון, יחד עם SurfaceTransformation(transformationSpec):

@Composable
fun BoardingPassListSample(flights: List<FlightInfo>) {
    val listState = rememberTransformingLazyColumnState()
    val transformationSpec = rememberTransformationSpec()


    ScreenScaffold(scrollState = listState) { contentPadding ->
        TransformingLazyColumn(
            state = listState,
            contentPadding = contentPadding,
            modifier = Modifier.fillMaxSize()
        ) {
            items(flights.size) { index ->
                val flight = flights[index]
                BoardingPassCard(
                    flightNumber = flight.number,
                    origin = flight.origin,
                    destination = flight.destination,
                    gate = flight.gate,
                    seat = flight.seat,
                    departureTime = flight.time,
                    modifier = Modifier
                        .transformedHeight(this, transformationSpec)
                        .minimumVerticalContentPadding(
                            CardDefaults.minimumVerticalListContentPadding
                        ),
                    transformation = SurfaceTransformation(transformationSpec)
                )
            }
        }
    }
}