تتميز منظومة Android المتكاملة بتنوّعها، ما يتيح للمستخدمين التنقّل بسلاسة بين الهواتف والأجهزة اللوحية والأجهزة القابلة للطي وأجهزة الكمبيوتر المكتبية. بعد أن أصبحنا نعتمد على Compose First، تستكشف هذه الجلسة كيفية تصميم تطبيقك ليتوافق مع النوافذ القابلة لتغيير الحجم ونِسب العرض إلى الارتفاع المتنوعة، حيث تنهار افتراضات التنسيق التقليدية، ما يؤدي إلى تمديد واجهات المستخدم وفقدان الحالة.
استكشِف كيف يمكن أن تساعدك أحدث الإمكانات في Jetpack Compose وNavigation 3 وWindowManager في إنشاء تصاميم ديناميكية تتوافق تلقائيًا مع أحجام الشاشات المختلفة، بدءًا من الشاشات الصغيرة وحتى شاشات العرض المزدوج. ستتعرّف على استراتيجيات لتسريع سير عملك في مجال التطوير باستخدام Gemini ومهاراتنا، مثل المهارة التكيُّفية، لإعادة تصميم قواعد الرموز البرمجية القديمة تلقائيًا لتناسب الشاشات الأكبر حجمًا.
MediaQuery بشكل تعريفي إمكانات واجهة المستخدم النشطة، مثل حجم النافذة وأوضاع الطي. ويحلّ محلّ دمج العديد من مدراء المنصات القديمة في حالة Compose.Grid وFlexBox وحدات أساسية للتنسيق التكيُّفي. تتيح دالة Grid ترتيبات متعددة الأبعاد مستندة إلى مَعلمات ضمن دالة مركّبة واحدة، بينما توفّر دالة FlexBox توزيعًا أحادي الأبعاد للمساحة والتفافًا.النص
فرانشيسكو رومانو
مرحبًا،
سنتعمّق اليوم في التفاصيل الفنية حول كيفية توسيع نطاق تطبيقك ليتوافق مع كل الشاشات باستخدام Jetpack Compose.
لماذا يجب توسيع نطاق المحتوى ليلائم كل الشاشات؟
لأنّ منظومة Android المتكاملة توسّعت لتشمل العديد من أشكال الأجهزة المختلفة.
يمكن تشغيل حزمة APK على الهواتف والأجهزة القابلة للطي والأجهزة اللوحية وأجهزة Chromebook وGooglebook وأجهزة التلفزيون وأنظمة الترفيه والمعلومات في السيارات وأجهزة XR وما إلى ذلك.
أولاً، يجب أن تتواصل مع المستخدمين بالطريقة التي يفضّلونها.
ثانيًا، إنّ تحوّل نظام Android الأساسي نحو التطبيقات التكيُّفية ليس مؤقتًا.
لن نعود إلى عالم الهواتف فقط، بل سيظلّ الذكاء الاصطناعي موجودًا.
ولديّ مخطط زمني سريع هنا.
في عام 2022، بدأنا في وضع الأساس.
أطلقنا نظام التشغيل Android 12L، وهو الإصدار الأول من Jetpack WindowManager، وقدّمنا لك واجهات برمجة تطبيقات مثل فئات حجم النافذة، ونشرنا الإصدار الأول من إرشادات جودة التطبيقات التكيُّفية.
بعد ذلك، عدّلنا Google Play أيضًا لتقسيم التقييمات حسب نوع الجهاز، وبدأنا في تضمين إرشادات جودة التطبيقات التكيُّفية في قرارات الترتيب.
في Android 15، أضفنا أيضًا ميزة "العرض في نافذة" إلى مشروع Android المفتوح المصدر (AOSP)، وأطلقنا الإصدار الثابت من Compose Material 3 Adaptive.
وبدءًا من الإصدار 16 من Android، والإصدار 36 من حزمة تطوير البرامج (SDK)، يتجاهل النظام ببساطة عمليات قفل الاتجاه، وعمليات قفل نسبة العرض إلى الارتفاع، وعلامات إمكانية تغيير الحجم على الشاشات الأكبر حجمًا.
مع طرح ميزة الشاشات المتصلة، أصبح بإمكان حزمة APK العمل بأي حجم نافذة تقريبًا.
وقد وفّرت حزمة SDK 36 علامة بيان مؤقتة لإيقاف إلغاء الاتجاه ونسبة العرض إلى الارتفاع.
في Android 17، لن يتوفّر خيار الإيقاف هذا.
لذا، فإنّ إنشاء تطبيقات تكيُّفية هو أحد المتطلبات الأساسية للمنصة.
على مدار العقد الماضي، عملنا عن كثب مع فِرق من مختلف أنحاء المنظومة المتكاملة، وتعرّفنا بشكل مباشر على سبب صعوبة إنشاء تطبيقات تتيح تجربة تكيُّفية.
قد تفقد بعض البيانات، وتواجه مشاكل في واجهات برمجة التطبيقات الخاصة بالنوافذ المجزأة، وإشارات غير واضحة حول وقت تبديل التنسيق، وبعض الافتراضات القديمة المضمنة في قاعدة الرموز البرمجية.
لذلك، أخذنا كل ما تعلمناه من هذا العقد من تحديات المطورين، وقمنا ببنائه مباشرة في مكانين: أولاً، في مجموعة أدوات واجهة المستخدم نفسها.
سنستكشف اليوم بعض الإمكانات الجديدة الرئيسية في Jetpack Compose: الاستعلام عن الوسائط، والشبكة، وFlexBox، وNavigation 3.
بل أيضًا في الأدوات المستندة إلى الذكاء الاصطناعي الوكيل التي تكتب الرمز معك من خلال مهارات Android الرسمية لـ "Gemini في استوديو Android" وAndroid CLI.
على سبيل المثال، سنرى مهارات Compose التكيُّفية التي تم إصدارها في مؤتمر Google I/O في وقت سابق من هذا العام.
وكانت قادرة على نقل تنسيقات الهاتف إلى أحجام نوافذ أكبر.
والآن، يمكنه أيضًا إصلاح فقدان الحالة، وتنفيذ تجارب فريدة للأجهزة القابلة للطي مثل وضعية سطح الطاولة، وتحسين دعم الإدخال من خلال اكتشاف وتنفيذ اختصارات لوحة المفاتيح والمؤشرات المخصّصة وتلميحات الأدوات وما إلى ذلك.
حسنًا، لنبدأ بـ Jetpack Compose، وهو الأساس لكل هذا.
أصبح Jetpack Compose مستقرًا في عام 2021، ما يمثّل عيد ميلاده الخامس.
وعلى مدار هذه السنوات الخمس، رأينا في آلاف التطبيقات الإنتاجية مدى سهولة وموثوقية إنشاء تطبيقات تكيُّفية باستخدام Compose.
أصبح تطوير تطبيقات Android الآن يعتمد رسميًا على Compose أولاً.
وهذا يعني أنّ جميع التنسيقات التكيُّفية الحديثة والأنماط ومكوّنات التنقّل الجديدة تم إنشاؤها من أجل Compose.
ومن منظور بنية النظام، فإنّ أكبر ميزة في Compose للتطبيقات التكيُّفية مقارنةً بنظام العرض القديم هي كيفية تفاعل الحالة وواجهة المستخدم.
ولشرح ذلك، أريد إجراء مقارنة سريعة مع نظام العرض.
لذا، فكّر في كيفية عمل نظام العرض.
كانت واجهة المستخدم عبارة عن شجرة من العناصر القابلة للتغيير التي تم تضخيمها مرة واحدة من ملفات XML الثابتة.
كان لديك مؤهّلات موارد مختلفة، ولنبدأ بالمشكلة الرئيسية هنا: التكرار في مؤهّلات الموارد المختلفة.
عند إنشاء واجهة مستخدم تكيُّفية في XML، لن يكون لديك أي منطق شرطي داخل ملف التنسيق.
بدلاً من ذلك، يمكنك تكرار التسلسل الهرمي بالكامل عبر مجلدات الموارد، مثل التخطيط، والتخطيط لأصغر نافذة 600 بكسل مستقل الكثافة، والتخطيط الأفقي، وما إلى ذلك.
حسنًا، لنلقِ نظرة على مثال EditText على يسار الشاشة.
<LinearLayout android:orientation="vertical">
<EditText
android:id="@+id/text_msg"
android:inputType="textCapCharacters"
android:maxLength="12" />
</LinearLayout><ConstraintLayout>
<TextInputEditText
android:id="@+id/text_input"
android:inputType="text" />
</ConstraintLayout>هذا هو المكوّن المنطقي نفسه الذي يتعامل مع إدخال النص، وهو مبعثر في ملفات XML مختلفة.
إذا عدّلت سمة مثل inputType أو maxLength في تنسيق الهاتف، لن يفرض عليك برنامج التجميع تعديلها أيضًا في تنسيق الجهاز اللوحي.
وبما أنّ المكوّنات منفصلة في ملفات مختلفة، من المؤكد أنّ الانحراف الهيكلي سيحدث بمرور الوقت.
ويرتبط هذا التكرار أيضًا بمشكلة أخرى، وهي فقدان الحالة أثناء تغييرات الإعدادات.
يُرجى العِلم أنّ مكوّنات واجهة المستخدم في نظام View تكون ذات حالة.
يملك EditText مخزنًا مؤقتًا داخليًا قابلاً للتعديل وموضع مؤشر مخصّص وحالة تركيز وبعض الحالات الأخرى.
لذلك، عندما يفتح المستخدم الجهاز أو يغيّر حجم النافذة إلى حدّ معيّن، يتجاهل Android هيكلية طرق العرض بالكامل ويُنشئ ملفات XML الجديدة من البداية.
لحفظ البيانات التي أدخلها المستخدم، يعتمد إطار العمل على onSaveInstanceState، الذي سيحزم الحالة المرتبطة بمعرّف طريقة العرض هذه.
ولكن هذا يؤدي إلى ثلاثة أوضاع فشل محتملة.
أولاً، إذا أطلق أحد المستخدمين عن طريق الخطأ اسم text_input على المعرّف بدلاً من text_msg في ملف XML الآخر، ستتجاهل ميزة استعادة الحالة نص الإدخال الخاص بالمستخدم بدون إرسال أي تحذيرات.
ثانيًا، يتم تشغيل onRestoreInstanceState بعد onViewCreated، ما قد يتعارض بشكل متكرر مع مراقبي ViewModel، ويمكن أن يتسبب في حدوث حلقات رد اتصال.
ثالثًا، إذا بدّلت نوع التطبيق المصغّر بين المحدّدات، لنفترض أنّك تريد استخدام قائمة منسدلة على شاشة صغيرة، ومجموعة أزرار اختيار على شاشة أكبر، إذا كانا يتشاركان رقم التعريف نفسه، سيؤدي ذلك إلى تعطُّل التطبيق أثناء التشغيل بسبب استثناء ClassCastException.
في Jetpack Compose، لا تكون واجهة المستخدم عنصرًا يتم إنشاؤه مرة واحدة.
واجهة المستخدم هي دالة خالصة للحالة.
وهذا يغيّر تمامًا مفهوم تغيير الإعدادات.
ما هو تغيير الإعدادات في Compose؟
لديّ تلميح.
فرانشيسكو رومانو
أجل
في Compose، يُعدّ تغيير الإعدادات مجرد إعادة تركيب أخرى، وتحدث عمليات إعادة التركيب طوال الوقت.
عندما تصبح النافذة أوسع، ربما لأنّ المستخدم فتح الجهاز، تقرأ الدوال المركّبة الحجم الجديد، وتتم إعادة تشغيل الدوال التي يعتمد ناتجها على الحجم.
هذا كل ما في الأمر.
والآن، لنتعرّف على كيفية إنشاء تطبيقات تكيُّفية حديثة باستخدام أحدث أدوات ومكوّنات Compose.
لذلك، جعلتك لغة XML تطرح أسئلة مثل، "هل أستخدم هاتفًا أو جهازًا لوحيًا أو كمبيوتر مكتبيًا؟"، ثم تنشئ واجهة المستخدم استنادًا إلى فئة الجهاز.
ولكن هناك سبب أساسي لتجنّب ذلك.
حتى عند تشغيل التطبيق على الجهاز الفعلي نفسه، يمكن أن يتغيّر سياق التطبيق وإمكانات واجهة المستخدم ديناميكيًا أثناء التشغيل.
لذا، الرسالة التي أريد تركها هي أن قرارات واجهة المستخدم يجب أن تكون مدفوعة بالقدرات النشطة الحالية: حجم النافذة الحالي، والوضعية، وبعض خصائص الأجهزة.
لنلقِ نظرة على مثال كلاسيكي، وهو ميزة "العرض في نافذة" على شاشة خارجية.
إذا أخذت هاتف Pixel ووصّلته بشاشة خارجية على مكتبك، فإنّ عملية التطبيق نفسها التي كانت تعمل على شاشة عمودية مقاس 6 بوصات تعمل الآن على شاشة عريضة مقاس 32 بوصة مع لوحة مفاتيح وماوس متصلَين.
لذلك، فإنّ التحقّق من نوع الجهاز الذي يعمل عليه التطبيق لا يضمن أي شيء بشأن الشاشة التي يتم عرضه عليها فعليًا أو طرق الإدخال.
بالإضافة إلى ذلك، يتيح Android استخدام نوافذ متعددة، لذا يمكن تغيير حجم النوافذ.
حتى إذا كانت شاشة Googlebook أكبر من شاشة الهاتف، قد يعمل تطبيقك على نافذة بعرض 400 وحدة بكسل مستقلة الكثافة، إلى جانب تطبيقات أخرى.
وبالتالي، قد لا يكون من المفيد أيضًا الاستعلام عن مقاييس الشاشة المادية الثابتة.
لذا، لتصميم تطبيقات تعتمد على Compose أولاً، نحتاج إلى فهم كيفية الاستعلام عن البيئة.
وأريد أن أبدأ بمحاولة صياغة مفهوم إمكانات واجهة المستخدم.
لا تشمل إمكانات واجهة المستخدم عرض النافذة وارتفاعها أو وضعية الجهاز القابل للطي فحسب، بل تشمل أيضًا طرق الإدخال والإخراج النشطة حاليًا.
لقد لاحظنا أنّ مستخدمي أجهزة الكمبيوتر المكتبي لا يستخدمون شاشة اللمس دائمًا.
ويمكنهم استخدام لوحة المفاتيح والماوس.
في نظام السيارات، تحتوي بعض السيارات على مقبض دوار أو مدخلات مختلفة، وطريقة مختلفة لإدخال الإشارات.
بالإضافة إلى حجم الشاشة وطريقة الإدخال، هناك عوامل أخرى مثل المسافة بين المستخدم والشاشة، أو ما إذا كانت الكاميرا أو الميكروفون متصلَين حاليًا بالجهاز.
هذه هي جميع إمكانات واجهة المستخدم.
فرانشيسكو رومانو
الآن، إذا أردت استرداد جميع الإشارات، عليك دمج 5 أو 6 واجهات برمجة تطبيقات مختلفة من مصادر مختلفة.
كنت بحاجة إلى عنصر الإعدادات، وWindowMetricsCalculator، وWindowInfoTracker، وInputManager، وPackageManager، ثم تحويل كل منها يدويًا إلى حالة، وهي حالة Compose.
هل أعجبك ذلك؟
لا، لم أتعرف على صوتي
لحلّ هذه المشكلة، قدّمنا واجهة MediaQuery API في androidx.compose.ui.
mediaQuery { ... }
derivedMediaQuery { ... }وهي مستوحاة من الاستعلامات عن الوسائط في CSS.
ولا تعني كلمة "وسائط" تشغيل الصوت أو الفيديو.
يشير إلى الوسيط، البيئة التي يتم عرض واجهة المستخدم عليها.
وتوفّر لك MediaQuery طريقة تعريفية واحدة مستندة إلى Compose لطلب البحث عن إمكانات واجهة المستخدم.
لنلقِ نظرة على السمات المعروضة في UiMediaScope.
sealed interface UiMediaScope {
@get:FrequentlyChangingValue val windowWidth: Dp
@get:FrequentlyChangingValue val windowHeight: Dp
val windowPosture: Posture // Flat, Tabletop, Book
val pointerPrecision: PointerPrecision // Fine, Coarse, Blunt, None
val keyboardKind: KeyboardKind // Physical, Virtual, None
val hasCamera: Boolean
val hasMicrophone: Boolean
val viewingDistance: ViewingDistance // Near, Medium, Far
}windowWidth وwindowHeight في Dp.
windowPosture، وهي مخصّصة للأجهزة القابلة للطي في الوقت الحالي، ويمكن أن تكون "مسطّحة" (Flat) أو "شاشة مسطّحة" (Tabletop) أو "كتاب" (Book).
يخبرك pointerPrecision بدقة جهاز التأشير الأساسي: دقيق للماوس أو قلم الشاشة أو لوحة اللمس، وتقريبي للمس، وغير دقيق لعصا التحكّم أو وحدات التحكّم أو لا شيء.
يمكن أن تكون "keyboardKind" بطاقة فعلية أو افتراضية أو لا شيء.
يتتبّع hasCamera وhasMicrophone بشكل ديناميكي ما إذا كان هذا الجهاز متاحًا.
فكِّر في كاميرا خارجية يمكن توصيلها.
ويشير viewingDistance إلى "قريب" أو "متوسط" أو "بعيد".
ولكن أعتقد أنّ الأمر سيكون أسهل إذا نظرنا إلى مثال حقيقي، وهو رصد ما إذا كان الجهاز في وضع "التثبيت على سطح مستوٍ".
وضع "التثبيت على سطح مستوٍ" هو وضع يتيح للمستخدمين طيّ الجهاز من المنتصف ووضعه عادةً على سطح مستوٍ.
وهو رائع لتشغيل الفيديوهات لفترات طويلة، أو لإجراء مكالمات الفيديو، أو لالتقاط صور بتقنية التسريع الزمني.
وهي مفيدة أيضًا، كما تعلم، لاتباع وصفة أو مشاهدة عرض شرائح.
الآن، إذا أردت رصد وضعية "سطح الطاولة" قبل MediaQuery، عليك كتابة كل هذا الرمز.
@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
val context = LocalContext.current
val windowLayoutInfo by WindowInfoTracker.getOrCreate(context).windowLayoutInfo(context)
.collectAsStateWithLifecycle(null)
val isTabletopPosture = windowLayoutInfo?.displayFeatures?.any { displayFeature ->
displayFeature is FoldingFeature &&
displayFeature.state == FoldingFeature.State.HALF_OPENED &&
displayFeature.orientation == FoldingFeature.Orientation.HORIZONTAL
} ?: false
return if (isTabletopPosture) {
VideoPlayerLayout.Tabletop
} else {
VideoPlayerLayout.Default
}
}كان عليك الحصول على السياق، والحصول على مثيل من WindowInfoTracker، وجمع windowLayoutInfo، وهو تدفق، وحالة Compose مدركة لمراحل النشاط، ثم التكرار من خلال displayFeatures للتحقق مما إذا كان هناك واحد هو FoldingFeature، نصف مفتوح، وأفقي.
وهذا فقط لاكتشاف الوضعية.
هذا كثير من النصوص المعيارية.
باستخدام واجهة MediaQuery API الجديدة، يتم تقليص كل ذلك إلى بضعة أسطر من الرموز البرمجية.
@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
return mediaQuery {
when(windowPosture) {
UiMediaScope.Posture.Tabletop -> VideoPlayerLayout.Tabletop
else -> VideoPlayerLayout.Default
}
}
}تستخدم الدالة mediaQuery معلَمة lambda واحدة، وضمن نطاق مستلِم lambda، تعرض مجموعة السمات التي رأيناها للتو.
في هذا المثال، من خلال التحقّق من windowPosture في تعبير when، يمكننا عرض الدالة المركّبة الصحيحة على الفور.
في ما يلي مثالان آخران على كيفية استخدام واجهة برمجة التطبيقات.
@Composable
fun AdaptiveChatControls(
onStartVideoCall: () -> Unit,
onSendMessage: () -> Unit,
) {
// 1. Return a Dp value directly based on pointer precision
val chipPadding = mediaQuery {
if (pointerPrecision == PointerPrecision.Fine) 2.dp else 8.dp
}
// 2. Return a Boolean combining hardware capabilities
val canVideoCall = mediaQuery { hasCamera && hasMicrophone }
val hasPhysicalKeyboard = mediaQuery {
keyboardKind == KeyboardKind.Physical
}
Row(modifier = Modifier.padding(chipPadding)) {
if (canVideoCall) {
IconButton(onClick = onStartVideoCall) {
Icon(Icons.Default.Videocam, contentDescription = "Video call")
}
}
Button(onClick = onSendMessage) {
Text(if (hasPhysicalKeyboard) "Send (Ctrl+Enter)" else "Send")
}
}
}أولاً، كثافة المعلومات.
لذلك عندما يكون المستخدم على كمبيوتر محمول، أو شاشة كمبيوتر مكتبي مع ماوس، وتكون دقة المؤشر دقيقة، يمكننا تضييق المساحة المتروكة على مكونات واجهة المستخدم لدينا من، في هذا المثال، 8 بكسل مستقل الكثافة إلى 2 بكسل مستقل الكثافة لإظهار المزيد من المحتوى بشكل مريح، نظرًا لأن المستخدمين لا يستخدمون أصابعهم، يمكن أن تكون أهداف اللمس أصغر.
وأيضًا، الإجراءات المستندة إلى الإمكانات.
لنفترض أنّ لدينا شريط إدخال في المحادثة، يمكننا التحقّق من hasCamera وhasMicrophone لإظهار زر إجراء مكالمة الفيديو أو إخفائه ديناميكيًا، أو يمكننا الجمع بين نوع لوحة المفاتيح الخارجية لإظهار بعض تلميحات اختصارات لوحة المفاتيح أو عدم إظهارها، مثل الضغط على Ctrl+Enter للإرسال.
يتم استرداد كل هذه العناصر في وقت التشغيل.
وتُعدّ MediaQuery أيضًا رائعة للاختبار وإنشاء المعاينات.
@Preview(name = "Tabletop + Fine Pointer", widthDp = 840, heightDp = 700)
@Composable
fun TabletopPlayerPreview() {
val simulatedMediaScope = object : UiMediaScope {
override val windowWidth = 840.dp
override val windowHeight = 700.dp
override val windowPosture = UiMediaScope.Posture.Tabletop
override val pointerPrecision = UiMediaScope.PointerPrecision.Fine
override val keyboardKind = UiMediaScope.KeyboardKind.Physical
override val hasCamera = true
override val hasMicrophone = true
override val viewingDistance = UiMediaScope.ViewingDistance.Medium
}
CompositionLocalProvider(LocalUiMediaScope provides simulatedMediaScope) {
VideoPlayerScreen()
}
}لذا، يحلّ MediaQuery مستقبِله من خلال تركيبة محلية تُسمى LocalUiMediaScope.
لذا، يمكنك ببساطة تقديم UiMediaScope مزيّف من خلال CompositionLocalProvider في المعاينة، ولن تحتاج إلى تشغيل الأجهزة القابلة للطي وضبطها على زاوية 90 درجة أو توصيل ماوس لتشغيل اختبار واجهة مستخدم Compose.
فرانشيسكو رومانو
حسنًا، نعرف الآن كيفية الاستعلام عن إمكانات واجهة المستخدم.
والآن، لنتعرّف على كيفية إنشاء التنسيق الفعلي.
لذلك، أودّ تقديم Grid، وهو عنصر أساسي جديد للتنسيق في androidx.compose.foundation.layout، وهو الآن مستقر.
Grid(
modifier = Modifier.fillMaxSize().padding(16.dp),
config = {
val maxWidthDp = constraints.maxWidth.toDp()
val columnCount = if (maxWidthDp < 600.dp) 2 else 4
repeat(columnCount) {
column(1.fr)
}
row(GridTrackSize.Auto)
row(1.fr)
gap(row = 16.dp, column = 8.dp)
flow = GridFlow.Row
}
) {
// GridScope children...
}تتيح لك ميزة "الشبكة" تنفيذ تنسيق ثنائي الأبعاد من خلال عرض العناصر في أعمدة وصفوف تتكيّف مع حجم الحاوية المتاح.
لنتعرّف على بعض الإجراءات التي يمكنك تنفيذها في مجموعة الإعدادات.
يمكنك إلحاق عناصر أو مسارات جديدة عن طريق استدعاء العمود أو الصف، ويمكنك استخدام حلقات Kotlin العادية مثل التكرار.
يمكنك ضبط الهوامش بين المسارات.
يمكنك التحكّم في اتجاه الموضع التلقائي من خلال السمة flow-- GridFlow.Row أو GridFlow.Column.
إليك تفاصيل أساسية حول بنية النموذج.
يتم تشغيل مجموعة الإعدادات أثناء مرحلة القياس، وينفّذ نطاق إعدادات الشبكة الكثافة ويعرض القيود.
يعني ذلك أنّه يمكنك فحص constraints.maxWidth.toDp() مباشرةً داخل الإعدادات لاحتساب المسارات بدون أي عمليات إعادة تركيب إضافية.
في Grid، عندما تضيف عنصرًا فرعيًا - عندما تضيف عنصرًا فرعيًا بدون مُعدِّل، فسوف يتدفق تلقائيًا إلى الخلية التالية.
Grid(
config = {
repeat(3) { column(1.fr) }
repeat(3) { row(100.dp) }
gap(8.dp)
}
) {
// Spans 2x2 area (rows 1..2, columns 1..2)
HeroCard(modifier = Modifier.gridItem(rows = 1..2, columns = 1..2))
// Overlaps on top of HeroCard in cell (1, 1) aligned to TopStart!
LiveBadge(
modifier = Modifier.gridItem(
row = 1,
column = 1,
alignment = Alignment.TopStart
)
)
// Negative index: placed in last row (-1), spanning all 3 columns
FooterBar(modifier = Modifier.gridItem(row = -1, column = 1, columnSpan = 3))
}عندما تريد التحكّم بشكل صريح، يمكنك استخدام Modifier.gridItem، وستحصل على إحداثيات تستند إلى الرقم واحد.
لذا، الصف 1، العمود 1 هو الخلية في أعلى اليمين.
وهي تتيح الفهرسة السلبية، لذا فإنّ الصف -1 هو الصف الأخير، وهكذا.
يمكنك استخدام rowSpan لتوسيع عنصر عبر منطقة ثنائية الأبعاد، ثم هناك أيضًا تداخل.
لذلك، إذا تم وضع عنصرَين في خلايا متداخلة، يكدّسهما Grid فوق بعضهما ضمن هذه الحدود، ويتم تحديد ترتيب Z حسب ترتيب التركيب.
لذلك، سيتم رسم العناصر اللاحقة فوق العناصر الأخرى تمامًا مثل Box.
الآن، نعرف أساسيات Grid.
لنلقِ نظرة على مثال مشغّل الفيديو.
استنادًا إلى ما إذا كان الجهاز مسطّحًا أو نصف مفتوح، علينا التبديل بين التنسيق التلقائي أو تنسيق "التثبيت على سطح مستوٍ".
قبل Grid، كان علينا إنشاء دالتَين مركّبتَين مختلفتَين، هما DefaultLayout وTabletopLayout، ثم تفريع الرمز في تعبير when.
@Composable
fun VideoPlayerLayout(
player: @Composable () -> Unit,
controller: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
when(currentVideoPlayerLayout()) {
VideoPlayerLayout.Default -> DefaultLayout(player, controller, modifier)
VideoPlayerLayout.Tabletop -> TabletopLayout(player, controller, modifier)
}
}قد تتساءلون: "ما المشكلة في ذلك؟"
حسنًا، إذا نظرت إلى هذه التخطيطات، فستجد أن محتواها متطابق.
يعرض كلاهما المشغّل نفسه ووحدة التحكّم نفسها بالضبط.
والفرق الوحيد بينهما هو كيفية ترتيب هذين العنصرين على الشاشة.
حتى الآن، لم تكن لدينا طريقة لتمرير ترتيب التنسيق نفسه كمَعلمة، لذا اضطررنا إلى تحديد دالتَين مركّبتَين منفصلتَين.
الآن، يمكننا تحديد معلَمات إعدادات التنسيق والتعامل مع كل شيء داخل دالة مركّبة واحدة.
وسنرى كيف.
لذا، باستخدام Grid API، يمكننا تحديد المناطق المحدّدة كقيم تعداد.
enum class VideoPlayerArea { Player, Controller }
Grid(
config = { ... },
modifier = modifier
) {
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Player)) {
player()
}
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Controller)) {
controller()
}
}بعد ذلك، يمكننا تحديد الدوال المركّبة الفرعية، أي المشغّل ووحدة التحكّم، لوضعها داخل الشبكة، ويمكننا ربط كل دالة فرعية بمنطقة مُسمّاة من خلال استدعاء Modifier.gridItem، ونمرّر areaId.
وهنا نطلب من Grid وضع المشغّل في المنطقة المسماة VideoPlayerArea.Player ووحدة التحكّم في VideoPlayerArea.Controller.
وهنا نحدّد دالّتَي lambda للإعداد في VideoPlayerGridConfiguration.
object VideoPlayerGridConfiguration {
val Default: GridConfigurationScope.() -> Unit = {
row(1f)
column(1f)
area(areaId = VideoPlayerArea.Player, row = 1, column = 1)
area(areaId = VideoPlayerArea.Controller, row = 1, column = 1)
}
val Tabletop: GridConfigurationScope.() -> Unit = {
row(0.5f)
row(0.5f)
column(1f)
area(areaId = VideoPlayerArea.Player, row = 1, column = 1)
area(areaId = VideoPlayerArea.Controller, row = 2, column = 1)
}
}بالنسبة إلى تنسيق مشغّل الفيديو التلقائي، نضبط شبكة من صف واحد وعمود واحد، ولكن حتى إذا كانت هناك خلية واحدة فقط، يمكننا تعيين اسمَين للمنطقة نفسها.
في هذه الحالة، كما رأينا من قبل، يتم وضع وحدة التحكم فوق المشغل.
بالنسبة إلى وضعية "سطح الطاولة"، نحدّد صفَّين وعمودًا واحدًا، ونضع المشغّل في النصف العلوي ووحدة التحكّم في النصف السفلي، على النحو التالي.
بعد تحديد دوال lambda للإعدادات، يمكننا استخدام MediaQuery مباشرةً في إعدادات الشبكة.
@Composable
fun VideoPlayerLayout(
player: @Composable () -> Unit,
controller: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
Grid(
config = { mediaQuery {
if (isTabletopPosture) { VideoPlayerGridConfiguration.Tabletop }
else { VideoPlayerGridConfiguration.Default }
}
}
modifier = modifier
) {
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Player)) {
player()
}
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Controller)) {
controller()
}
}
}لذا، يمكننا الآن استخدام دالة مركّبة واحدة لتنسيق مشغّل الفيديو مع واجهة المستخدم التي تبدّل ديناميكيًا بين الإعدادَين.
النوع الأساسي التالي الذي أريد التحدث عنه هو FlexBox.
@Composable
fun AdaptiveActionHeader(tags: List<String>) {
FlexBox(
config = {
direction( if (isCompact)
FlexDirection.Column else FlexDirection.Row )
wrap(FlexWrap.Wrap)
alignItems(FlexAlignItems.Center)
gap(row = 8.dp, column = 12.dp)
},
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Home",
modifier = Modifier.flex {
basis(FlexBasis.Auto)
grow(1f)
shrink(1f)
}
)
tags.forEach { tag ->
FilterChipItem(
label = tag,
modifier = Modifier.flex { shrink(0f) }
)
}
}
}لذا، بينما يتعامل Grid مع تنسيقات الصفوف والأعمدة ثنائية الأبعاد، فإنّ FlexBox، الذي أصبح الآن مستقرًا أيضًا، يتعامل مع التنسيقات أحادية البُعد التي لا تزال بحاجة إلى توزيع ذكي للمساحة، والتقليص، والالتفاف متعدد الأسطر.
وهو تنسيق مريح ومتجاوب يمكنك استخدامه بدلاً من دالتَين مركّبتَين منفصلتَين، مثل FlowRow وFlowColumn، لأنّه حاوية واحدة يمكن تغيير اتجاهها ديناميكيًا داخل مجموعة الإعدادات بدون إعادة إنشاء الدوال المركّبة الفرعية.
ويطبّق خوارزمية CSS FlexBox الكاملة، لذا يمكنك استخدام wrap وjustify-content وalign-items وgap.
وفي كل عنصر فرعي، لديك Modifier.flex الذي يمنحك الأساس، وعوامل النمو النسبي، والانكماش، وalignSelf، وما إلى ذلك.
فرانشيسكو رومانو
والآن، لننتقل من تعريف مستوى الشاشة إلى تنسيقات مستوى التطبيق، وكيفية تنظيم التنقّل في المستوى الأعلى وترتيبات الشاشات المتعددة الأجزاء في التطبيق بأكمله، وليس فقط في الشاشة الواحدة.
على الرغم من أنّ MediaQuery يمنحك عرض النافذة وارتفاعها بدقة بوحدة بكسل مستقلة الكثافة (Dp)، فإنّك تحتاج إلى نقاط توقّف موحّدة ومحدّدة لإطار العرض، وهي فئات حجم النافذة، وذلك من أجل بنية التطبيق.
بشكل أساسي، تريد أن تكون كل شاشة متجاوبة ضمن كل من هذه المجموعات، بينما يمكنك إجراء المزيد من التغييرات الهيكلية والتحديثات والاختلافات بين المجموعات المختلفة.
على سبيل المثال، من خلال عرض قائمة فقط على شاشة مصغّرة أو كل من القائمة والتفاصيل في نافذة موسّعة.
يمكنك إنشاء فئات حجم النافذة مباشرةً فوق UiMediaScope بطريقة واضحة جدًا، وهي تحديد سمة إضافة في UiMediaScope.
val UiMediaScope.windowSizeClass: WindowSizeClass
@Composable get() = derivedMediaQuery {
WindowSizeClass.BREAKPOINTS_V2.computeWindowSizeClass(
widthDp = windowWidth.value,
heightDp = windowHeight.value
)
}
@Composable
fun AppRoot() {
val sizeClass = mediaQuery { windowSizeClass }
val isExpandedWidth = sizeClass.isWidthAtLeastBreakpoint(
WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND // 600dp
)
// Drive app-level navigation and scene strategies...
}يُرجى ملاحظة أنّه يتم استخدام derivedMediaQuery، وليس MediaQuery العادي.
لماذا؟
لأنّ عرض النافذة وارتفاعها هما قيمتان تتغيّران بشكل متكرّر.
عندما يغيّر المستخدمون حجم تطبيقك، كما هو الحال على الكمبيوتر المكتبي، يمكن أن تتغيّر هذه القيم في كل إطار.
ومن المحتمل أنّك لا تريد إعادة إنشاء هيكل التطبيق في كل إطار، بل فقط عندما تتجاوز النافذة نقطة توقّف معيّنة.
وبعد تحديد فئات حجم النافذة، كيف يمكننا نقل تطبيقنا من تدفق النافذة الواحدة إلى تنسيقات النوافذ المتعددة عندما تتوفر مساحة أكبر؟
وهنا تبرز أهمية Jetpack Navigation 3، لأنّ Nav 3 تم تصميمه من الأساس حول حالة Compose والمشاهد.
أولاً، أنت تملك الأنشطة السابقة، وهي ببساطة قائمة من NavKeys، لذا فهي مرنة للغاية، ومن السهل حفظها كحالة.
يتم تحليل كل مفتاح في الحزمة إلى NavEntry يحتوي على الدالة المركّبة والبيانات الوصفية الخاصة به.
ولا يحدّد NavDisplay كيفية رسم الإدخالات، بل يقيّم قائمة باستراتيجيات المشاهد.
ضمن الاستراتيجية، يمكنك فحص إمكانات واجهة المستخدم، مثل فئات حجم النافذة.
if (isExpandedWidth) {
/* return a multi-pane scene */
}
else {
/* return a single pane scene */
}يمكنك فحص الأنشطة السابقة، ثم تحديد ما إذا كنت تريد عرض NavEntry واحد في لوحة واحدة أو عناصر متعددة في الوقت نفسه أو عنصر فوق الآخر.
إنّها مرنة للغاية.
وإلى جانب استراتيجية النوافذ المتعددة، تحدد فئات حجم النافذة أيضًا مكونات التنقل التي تريد استخدامها لزيادة سهولة الاستخدام واستخدام مساحة الشاشة إلى أقصى حد.
على سبيل المثال، في العرض المصغّر، قد تحتاج إلى شريط تنقّل سفلي، وفي العرض المتوسط، إلى شريط تنقّل جانبي، وفي العرض الموسّع، إلى لائحة تنقّل كاملة.
عند الجمع بين Nav3 لاتخاذ قرارات التنسيق على مستوى التطبيق والمكوّنات المتجاوبة للتعريف على مستوى الشاشة، ستحصل على تطبيقات تبدو بهذا الشكل وتتألق على كل حجم نافذة وبيئة تقريبًا.
بالنسبة إلى Nav 3 تحديدًا، إذا أردت معرفة المزيد عن آخر التحديثات، بما في ذلك إتاحة الروابط العميقة، يمكنك الاطّلاع على المحاضرة "إنشاء روابط عميقة آمنة من حيث النوع باستخدام Jetpack Navigation 3".
فرانشيسكو رومانو
حسنًا، لقد تناولنا الكثير من واجهات برمجة التطبيقات الجديدة اليوم: MediaQuery وGrid وFlexBox وNav3.
بهذا نكون قد شرحنا كيف يساعدك Jetpack Compose في إنشاء تطبيقات تكيُّفية.
ولكن في عصر التطوير المستند إلى الوكلاء، هناك ميزة أخرى مهمة للغاية لاستخدام Jetpack Compose: إنّها أكثر فعالية بكثير.
كما ذكرنا في الكلمة الرئيسية، لاحظنا أنّ التطبيقات تستخدم رموزًا مميزة أقل بنسبة تزيد عن %30 بعد نقلها إلى Compose، ونعمل على تحسين سير العمل المستند إلى الوكلاء لتطبيقات Compose بشكل أكبر.
اليوم، أريد تسليط الضوء على التحديثات الرئيسية لمهارة Compose Adaptive.
android skills list
android skills add adaptiveكما ذكرتُ، بالإضافة إلى نقل التنسيقات إلى Grid وFlexbox وMediaQuery، يتعامل النموذج الآن بشكل صريح مع إدارة الحالة من البداية إلى النهاية في جميع أنحاء التطبيق، ونختبر المهارة في مواجهة أخطاء فقدان الحالة في العالم الحقيقي: الحفاظ على التنقّل وموضع الانتقال وتشغيل الوسائط النشطة وإدخالات النصوص المكتوبة جزئيًا وما إلى ذلك.
تتضمّن المهارة الآن أيضًا تحسين وضعية الأجهزة القابلة للطي، مثل إعادة تصميم شاشات الوسائط والكاميرا تلقائيًا في وضع "التثبيت على سطح مستوٍ"، ولكن عندما يعمل تطبيقك على جهاز Googlebook أو شاشة خارجية باستخدام ماوس ولوحة مفاتيح، فإنّ التنسيق ليس سوى نصف القصة.
يمكن لمهارة Adaptive الآن مراجعة واجهة المستخدم للعثور على اختصارات لوحة المفاتيح ومؤشرات الماوس المخصّصة وحالات التمرير وتلميحات الأدوات وما إلى ذلك وتنفيذها.
لنطّلِع على ذلك عمليًا.
لقد سجّلتُ نسخة تجريبية من هذا.
لدينا تطبيقنا.
يمكننا النظر إلى حيواناتنا الأليفة.
ويمكننا مراسلتهم.
يمكننا مشاهدة فيديو عنها.
نعم، يمكننا رؤية أفضل الحيوانات الأليفة.
ولكن كما ترون، هذه الطريقة ليست رائعة.
لنبحث الآن عن صورة فيها بعض الدراما والقطط.
حسنًا، أريد رؤيتها أكبر.
هذا يعيدنا إلى الصفحة الرئيسية.
حسنًا، سأخبر صديقي بذلك.
نعم، "بادي"، همم.
التطبيق ليس جيدًا.
وهو واسع جدًا أيضًا.
ستلاحظ أنّه عريض جدًا.
لنجرّب تغيير حجمها.
العنصر
حسنًا، نعم.
ويتم فقدان النص الذي كتبته.
حسنًا، حان الوقت لـ اسأل Gemini لحلّ هذه المشكلة.
بفضل مهارة "التكيُّف"، يمكن أن يكون الطلب بسيطًا مثل "جعل التطبيق متكيّفًا".
كما ترون، أستخدم Gemini 3.0 Flash الذي يمكنه تنفيذ هذه المهمة مع المهارة.
لقد اكتسب المهارة، وبدأ الآن في تنفيذ التغييرات.
سيطرح بعض الأسئلة، مثل، "أوه، MediaQuery تجريبي.
هل تريد فعلاً استخدامها؟"
والمتابعة.
اضطررت إلى تسريع الفيديو لاختصار بعض اللقطات، ولكن إذا زرت منطقة العرض التوضيحي، يمكنك تجربة المهارات بنفسك.
وهذه مجرد واحدة من المهارات التي أطلقناها.
على سبيل المثال، لدينا أيضًا مهارة جديدة ستساعدك في إنشاء تجارب رائعة باستخدام قلم الشاشة.
لذا، تفضَّل بزيارة المنطقة لتجربتها.
إذًا، تم الأمر.
وعدّل 11 ملفًا.
لنطّلِع على ذلك عمليًا.
حسنًا
تعمل الشبكة حاليًا على تنفيذ مهامها.
تم نقل قائمة التنقّل إلى الجانب.
لنجرّب الفلاتر.
أعتقد أنّها ستعمل الآن.
نعم، هذا صحيح.
حسنًا، لنرسل رسالة إلى صديقي ونخبره بأنّ التطبيق رائع الآن.
لنرى كيف يبدو ذلك مع مساحة أكبر.
نعم، تم نقله إلى عرض على شكل قائمة مع تفاصيل.
استغرقت هذه العملية حوالي 20 دقيقة وحوالي 200,000 رمز مميز.
حسنًا
فرانشيسكو رومانو
عادةً ما أبدأ حديثي بمقاييس النشاط التجاري وحالات النجاح، ولكن بالنسبة إلى ADS، أردت الانتقال مباشرةً إلى الرمز البرمجي.
ومع ذلك، أريد أن أقضي بضع دقائق لمشاركة العائد الهندسي للاستثمار.
اليوم، يمكن تشغيل تطبيق Android على أكثر من 580 مليون جهاز نشط بشاشة كبيرة، ونعلم أيضًا أنّ مستخدمي الأجهزة المتعددة والأجهزة القابلة للطي هم من بين أكثر الفئات تفاعلاً على Android.
لنلقِ نظرة على دراستَي حالة ملموستَين توضّحان ما يحدث عند طرح إصدار تكيُّفي.
الأول هو Gratitude، وهو تطبيق رائج للرعاية الذاتية وتدوين اليوميات.
من خلال اعتماد تصاميم الشاشات التكيُّفية، نجح تطبيق Gratitude في جعل الأجهزة اللوحية والأجهزة القابلة للطي قناة اكتساب عالية النمو.
أولاً، ضاعفت الشركة سرعة اكتساب المستخدمين على الشاشات الكبيرة.
وزادت عمليات التثبيت اليومية التي تتم تحديدًا من الأجهزة اللوحية والأجهزة القابلة للطي بنسبة %100.
وقد وسّعوا حصة الشاشات الكبيرة من إجمالي قواعد المستخدمين من %7 إلى %10 من إجمالي عمليات التثبيت.
التطبيق الثاني هو BoxBoxClub، وهو تطبيق مصاحب لسباقات الفورمولا 1 ورياضة السيارات، وبعد تحديث إصداره للأجهزة القابلة للطي والشاشات الأكبر حجمًا، أظهرت بيانات القياس عن بُعد مكاسب هائلة: زيادة بنسبة %7.6 في إجمالي المستخدمين النشطين وزيادة بنسبة %54 في وقت التفاعل.
حسنًا
لقد وصلنا إلى نهاية المحاضرة، وأريد أن أقدّم لكم ثلاث نقاط رئيسية مهمة.
يسمح إنشاء تطبيقات تكيُّفية لتطبيقك بخدمة المزيد من الأجهزة والمستخدمين، مع ضمان استمرارية تطبيقك في هذه المنظومة المتكاملة المتوسّعة.
تطوير تطبيقات Android يعتمد على Compose أولاً.
تم تصميم جميع الأدوات والمكتبات والمهارات الجديدة لـ Compose.
تُعدّ Compose رائعة للتنسيقات التكيُّفية والتطوير المستند إلى الوكلاء.
تكون قواعد الرموز البرمجية أصغر حجمًا وأسهل في الفهم، ويستخدم الوكلاء عددًا أقل من الرموز المميّزة عند التعامل مع رمز Compose.
لذا، جرِّبوا جميع الأدوات والمهارات وواجهات برمجة التطبيقات الجديدة واستمتِعوا بها.
شكرًا لك.
<LinearLayout android:orientation="vertical">
<EditText
android:id="@+id/text_msg"
android:inputType="textCapCharacters"
android:maxLength="12" />
</LinearLayout><ConstraintLayout>
<TextInputEditText
android:id="@+id/text_input"
android:inputType="text" />
</ConstraintLayout>mediaQuery { ... }
derivedMediaQuery { ... }sealed interface UiMediaScope {
@get:FrequentlyChangingValue val windowWidth: Dp
@get:FrequentlyChangingValue val windowHeight: Dp
val windowPosture: Posture // Flat, Tabletop, Book
val pointerPrecision: PointerPrecision // Fine, Coarse, Blunt, None
val keyboardKind: KeyboardKind // Physical, Virtual, None
val hasCamera: Boolean
val hasMicrophone: Boolean
val viewingDistance: ViewingDistance // Near, Medium, Far
}@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
val context = LocalContext.current
val windowLayoutInfo by WindowInfoTracker.getOrCreate(context).windowLayoutInfo(context)
.collectAsStateWithLifecycle(null)
val isTabletopPosture = windowLayoutInfo?.displayFeatures?.any { displayFeature ->
displayFeature is FoldingFeature &&
displayFeature.state == FoldingFeature.State.HALF_OPENED &&
displayFeature.orientation == FoldingFeature.Orientation.HORIZONTAL
} ?: false
return if (isTabletopPosture) {
VideoPlayerLayout.Tabletop
} else {
VideoPlayerLayout.Default
}
}@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
return mediaQuery {
when(windowPosture) {
UiMediaScope.Posture.Tabletop -> VideoPlayerLayout.Tabletop
else -> VideoPlayerLayout.Default
}
}
}@Composable
fun AdaptiveChatControls(
onStartVideoCall: () -> Unit,
onSendMessage: () -> Unit,
) {
// 1. Return a Dp value directly based on pointer precision
val chipPadding = mediaQuery {
if (pointerPrecision == PointerPrecision.Fine) 2.dp else 8.dp
}
// 2. Return a Boolean combining hardware capabilities
val canVideoCall = mediaQuery { hasCamera && hasMicrophone }
val hasPhysicalKeyboard = mediaQuery {
keyboardKind == KeyboardKind.Physical
}
Row(modifier = Modifier.padding(chipPadding)) {
if (canVideoCall) {
IconButton(onClick = onStartVideoCall) {
Icon(Icons.Default.Videocam, contentDescription = "Video call")
}
}
Button(onClick = onSendMessage) {
Text(if (hasPhysicalKeyboard) "Send (Ctrl+Enter)" else "Send")
}
}
}@Preview(name = "Tabletop + Fine Pointer", widthDp = 840, heightDp = 700)
@Composable
fun TabletopPlayerPreview() {
val simulatedMediaScope = object : UiMediaScope {
override val windowWidth = 840.dp
override val windowHeight = 700.dp
override val windowPosture = UiMediaScope.Posture.Tabletop
override val pointerPrecision = UiMediaScope.PointerPrecision.Fine
override val keyboardKind = UiMediaScope.KeyboardKind.Physical
override val hasCamera = true
override val hasMicrophone = true
override val viewingDistance = UiMediaScope.ViewingDistance.Medium
}
CompositionLocalProvider(LocalUiMediaScope provides simulatedMediaScope) {
VideoPlayerScreen()
}
}Grid(
modifier = Modifier.fillMaxSize().padding(16.dp),
config = {
val maxWidthDp = constraints.maxWidth.toDp()
val columnCount = if (maxWidthDp < 600.dp) 2 else 4
repeat(columnCount) {
column(1.fr)
}
row(GridTrackSize.Auto)
row(1.fr)
gap(row = 16.dp, column = 8.dp)
flow = GridFlow.Row
}
) {
// GridScope children...
}Grid(
config = {
repeat(3) { column(1.fr) }
repeat(3) { row(100.dp) }
gap(8.dp)
}
) {
// Spans 2x2 area (rows 1..2, columns 1..2)
HeroCard(modifier = Modifier.gridItem(rows = 1..2, columns = 1..2))
// Overlaps on top of HeroCard in cell (1, 1) aligned to TopStart!
LiveBadge(
modifier = Modifier.gridItem(
row = 1,
column = 1,
alignment = Alignment.TopStart
)
)
// Negative index: placed in last row (-1), spanning all 3 columns
FooterBar(modifier = Modifier.gridItem(row = -1, column = 1, columnSpan = 3))
}@Composable
fun VideoPlayerLayout(
player: @Composable () -> Unit,
controller: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
when(currentVideoPlayerLayout()) {
VideoPlayerLayout.Default -> DefaultLayout(player, controller, modifier)
VideoPlayerLayout.Tabletop -> TabletopLayout(player, controller, modifier)
}
}enum class VideoPlayerArea { Player, Controller }
Grid(
config = { ... },
modifier = modifier
) {
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Player)) {
player()
}
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Controller)) {
controller()
}
}object VideoPlayerGridConfiguration {
val Default: GridConfigurationScope.() -> Unit = {
row(1f)
column(1f)
area(areaId = VideoPlayerArea.Player, row = 1, column = 1)
area(areaId = VideoPlayerArea.Controller, row = 1, column = 1)
}
val Tabletop: GridConfigurationScope.() -> Unit = {
row(0.5f)
row(0.5f)
column(1f)
area(areaId = VideoPlayerArea.Player, row = 1, column = 1)
area(areaId = VideoPlayerArea.Controller, row = 2, column = 1)
}
}@Composable
fun VideoPlayerLayout(
player: @Composable () -> Unit,
controller: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
Grid(
config = { mediaQuery {
if (isTabletopPosture) { VideoPlayerGridConfiguration.Tabletop }
else { VideoPlayerGridConfiguration.Default }
}
}
modifier = modifier
) {
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Player)) {
player()
}
Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Controller)) {
controller()
}
}
}@Composable
fun AdaptiveActionHeader(tags: List<String>) {
FlexBox(
config = {
direction( if (isCompact)
FlexDirection.Column else FlexDirection.Row )
wrap(FlexWrap.Wrap)
alignItems(FlexAlignItems.Center)
gap(row = 8.dp, column = 12.dp)
},
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Home",
modifier = Modifier.flex {
basis(FlexBasis.Auto)
grow(1f)
shrink(1f)
}
)
tags.forEach { tag ->
FilterChipItem(
label = tag,
modifier = Modifier.flex { shrink(0f) }
)
}
}
}val UiMediaScope.windowSizeClass: WindowSizeClass
@Composable get() = derivedMediaQuery {
WindowSizeClass.BREAKPOINTS_V2.computeWindowSizeClass(
widthDp = windowWidth.value,
heightDp = windowHeight.value
)
}
@Composable
fun AppRoot() {
val sizeClass = mediaQuery { windowSizeClass }
val isExpandedWidth = sizeClass.isWidthAtLeastBreakpoint(
WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND // 600dp
)
// Drive app-level navigation and scene strategies...
}if (isExpandedWidth) {
/* return a multi-pane scene */
}
else {
/* return a single pane scene */
}android skills list
android skills add adaptive