القوائم التي تتضمن ميزة "الكتابة" على نظام التشغيل 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 بتصميم عادي، يعرض العنصر 1 في الأعلى والعناصر بترتيب تصاعدي
الشكل 1. تنسيق قائمة عادي يملأ المحتوى من الأعلى إلى الأسفل.
‫TransformingLazyColumn مع تخطيط معكوس، يعرض العنصر 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 في القوائم

يتيح لك المكوّن SwipeToReveal الوصول إلى إجراءات خاصة بعناصر القائمة، مثل Card أو Chip، من خلال التمرير سريعًا. يؤدي التمرير سريعًا عادةً إلى إظهار زرَّي إجراء أو زر واحد (مثل "حذف" أو "المزيد") من الجانب.

عند استخدام SwipeToReveal ضمن TransformingLazyColumn، اتّبِع الإرشادات التالية:

  • إعادة الضبط عند التمرير: عندما يمرّر المستخدم القائمة، أعِد ضبط أي عناصر تم فتحها بالتمرير إلى حالتها المغطاة.
  • ارتفاعات متّسقة: اضبط ارتفاعات أزرار الإجراءات لتتطابق مع العنصر الداخلي الذي يتم تمريره سريعًا (سواء كان Button أو Card) لضمان مظهر متّسق.
  • تحويل الحاوية: طبِّق المعدِّل transformedHeight وtransformationSpec على المكوّن SwipeToReveal نفسه.
  • عدم إجراء تحويل مزدوج: لا تطبِّق المعدِّلين transformedHeight أو transformation على العنصر الداخلي الذي يتم تمريره سريعًا (البطاقة أو الزر داخل الحاوية SwipeToReveal).

عناصر قابلة للإنشاء مخصّصة في القوائم

عند إنشاء مكوّنات مخصّصة للسطح في 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. المتصل modifier: طبِّق المَعلمة modifier التي يمرّرها المتصل (والتي تتضمّن Modifier.transformedHeight) بعد ذلك، قبل أي عملية تغيير حجم أو إضافة مساحة داخلية.
    3. قصاصة الشكل عندما لا يكون هناك تحويل: إذا كانت قيمة transformation هي null، طبِّق Modifier.clip(shape) قبل رسم الخلفية. يتم قصّ الرسام الذي تعرضه createContainerPainter() ليناسب الشكل، ولكن لا يتم قصّ الرسام العادي، لذا بدون هذا الإعداد، يتم رسم الخلفية بزوايا مربّعة خارج القائمة.
    4. أداة رسم الخلفية المتحركة: ارسم الخلفية داخل طبقة الحاوية باستخدام Modifier.drawBehind وأداة رسم تم إنشاؤها من createContainerPainter().
    5. طبقة تحويل المحتوى: طبِّق Modifier.graphicsLayer ثانية مع applyContentTransformation() واقتصاص على شكل الحاوية، ما يؤدي إلى تلاشي المحتوى الداخلي في وقت أبكر عند اقترابه من الحافة.

يوضّح المقتطف التالي كيفية تنفيذ دالة BoardingPassCard مركّبة مخصّصة تطبّق عمليات التحويل هذه بالترتيب:

@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)
                )
            }
        }
    }
}