הסביבה העסקית של Android היא עצומה, והמשתמשים עוברים בצורה חלקה בין טלפונים, טאבלטים, מכשירים מתקפלים וסביבות של מחשבים. עכשיו, כשאנחנו מתמקדים ב-Compose, בסשן הזה נבחן איך לתכנן את האפליקציה לחלונות שאפשר לשנות את הגודל שלהם וליחסי גובה-רוחב שונים – שבהם הנחות מסורתיות לגבי פריסה לא עובדות, מה שמוביל לממשקי משתמש מתוחים ולאובדן מצב.
אנחנו מזמינים אתכם לגלות איך היכולות החדשות ב-Jetpack Compose, ב-Navigation 3 וב-WindowManager יכולות לעזור לכם ליצור פריסות דינמיות שמתאימות באופן טבעי למסכים קומפקטיים וגם לתצוגות עם שתי חלוניות. בסשן נציג אסטרטגיות שיעזרו לכם להאיץ את תהליך הפיתוח באמצעות Gemini והמיומנויות שלנו, כמו המיומנות האדפטיבית, כדי לשכתב באופן אוטומטי בסיסי קוד מדור קודם למסכים גדולים יותר.
MediaQuery עוקב באופן הצהרתי אחרי יכולות פעילות של ממשק המשתמש, כמו גודל החלון ותנוחות הקיפול. הוא מחליף את החיבור של כמה מנהלי פלטפורמות מדור קודם למצב Compose.Grid ו-FlexBox מספקים אבני בניין לפריסה מותאמת. Grid מאפשר סידורים רב-ממדיים עם פרמטרים בתוך רכיב Composable אחד, ו-FlexBox מספק חלוקת שטח חד-ממדית וגלישת טקסט.תמליל
פרנצ'סקו רומנו
שלום לכולם,
היום נתעמק בהיבטים הטכניים של התאמת האפליקציה לכל מסך באמצעות Jetpack Compose.
למה כדאי להתאים את המודעות לכל מסך?
כי הסביבה העסקית של Android התרחבה וכוללת הרבה סוגי מכשירים שונים.
קובץ ה-APK יכול לפעול בטלפונים, במכשירים מתקפלים, בטאבלטים, במחשבי Chromebook ו-Googlebook, בטלוויזיות, במערכות מולטימדיה לרכב, במכשירי XR ועוד.
אז הדבר הראשון הוא לפגוש את המשתמשים במקום שבו הם נמצאים.
השינוי השני הוא המעבר של פלטפורמת Android לאפליקציות מותאמות, וזה לא שינוי זמני.
זה לא שאנחנו חוזרים לעולם שבו יש רק טלפונים, זה כאן כדי להישאר.
ויש לי כאן ציר זמן קצר.
בשנת 2022 התחלנו להניח את היסודות.
השקנו את Android 12L, הגרסה הראשונה של Jetpack WindowManager, נתנו לכם ממשקי API כמו קטגוריות של גודל חלון ופרסמנו את הגרסה הראשונה של הנחיות האיכות לאפליקציות אדפטיביות.
בנוסף, עדכנו את Google Play כך שהדירוגים מחולקים לפי סוג המכשיר, והתחלנו לכלול את הנחיות האיכות לאפליקציות מותאמות בהחלטות לגבי הדירוג.
ב-Android 15, הוספנו גם את ממשק המחשב ל-AOSP, ואת Compose Material 3 Adaptive שהגיע לגרסה יציבה.
החל מגרסת Android 16, SDK 36, המערכת פשוט מתעלמת מנעילות כיוון, נעילות של יחס גובה-רוחב ודגלים של שינוי גודל במסכים גדולים יותר.
יחד עם ההשקה של מסכים מחוברים, עכשיו אפשר להפעיל את ה-APK כמעט בכל גודל חלון.
ב-SDK 36 היה דגל מניפסט זמני אחד לביטול ההתאמה של הכיוון ויחס הגובה-רוחב.
ב-Android 17, האפשרות הזו לא קיימת.
לכן, פיתוח רספונסיבי הוא דרישה בסיסית של הפלטפורמה.
בעשור האחרון עבדנו בשיתוף פעולה הדוק עם צוותים בכל הסביבה העסקית, ולמדנו ממקור ראשון למה פיתוח של פריסה דינמית יכול להיות קשה.
יש לכם אובדן מצב, ממשקי API של חלונות מקוטעים, אותות לא ברורים לגבי מתי להחליף פריסה, ופשוט כמה הנחות ישנות שמוטמעות עמוק בבסיס הקוד.
אז לקחנו את כל מה שלמדנו מהאתגרים של המפתחים בעשור הזה, והטמענו את זה ישירות בשני מקומות: קודם כל, בערכת הכלים של ממשק המשתמש עצמו.
היום נבחן כמה יכולות חדשות וחשובות ב-Jetpack Compose: שאילתת מדיה, רשת, FlexBox ו-Navigation 3.
אבל גם בכלים אג'נטיים שכותבים את הקוד יחד איתכם, באמצעות המיומנויות הרשמיות של Android ל-Gemini ב-Android Studio וב-Android CLI.
לדוגמה, נראה את המיומנויות המותאמות של Compose שהושקו ב-Google I/O מוקדם יותר השנה.
כבר הייתה לו יכולת להעביר פריסות של טלפונים לגדלים גדולים יותר של חלונות.
עכשיו הוא יכול גם לתקן את אובדן ערך דינמי, להטמיע חוויות ייחודיות למכשירים מתקפלים כמו על משטח, מסך למעלה, ולשפר את התמיכה בקלט על ידי גילוי והטמעה של מקשי קיצור, סמנים בהתאמה אישית, הסברים קצרים ועוד.
אוקיי, נתחיל עם Jetpack Compose, שהוא הבסיס לכל זה.
הגרסה היציבה של Jetpack Compose הושקה ב-2021, אז השנה אנחנו חוגגים את יום ההולדת החמישי שלה.
במהלך חמש השנים האלה, ראינו באלפי אפליקציות בסביבת הייצור עד כמה קל ואמין יותר ליצור פריסות דינמיות באמצעות Compose.
פיתוח ל-Android מתבצע עכשיו באופן רשמי קודם כל ב-Compose.
כלומר, כל הפריסות, העיצובים ורכיבי הניווט החדשים והמודרניים שמותאמים למכשיר נוצרו בשביל Compose.
מנקודת המבט של ארכיטקטורת המערכת, היתרון הכי גדול של פיתוח נייטיב לאפליקציות רספונסיביות על פני מערכת התצוגה הקודמת הוא האינטראקציה בין המצב לבין ממשק המשתמש.
כדי להסביר את זה, אני רוצה לעשות השוואה מהירה עם מערכת התצוגה.
אז בואו נחשוב איך מערכת התצוגה עבדה.
ממשק המשתמש היה עץ של אובייקטים ניתנים לשינוי, שהרחבתם פעם אחת מקובצי XML סטטיים.
היו לך מגדירי משאבים שונים, ובואו נתחיל עם הבעיה העיקרית כאן: כפילות בין מגדירי משאבים שונים.
כשיוצרים ממשק משתמש מותאם ב-XML, אין לוגיקה מותנית בתוך קובץ הפריסה.
במקום זאת, פשוט משכפלים את כל ההיררכיה בתיקיות המשאבים, כמו פריסה, פריסה של החלון הקטן ביותר של 600dp, פריסה לרוחב וכן הלאה.
אוקיי, תסתכלו על הדוגמה של 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, הרכיבים של ממשק המשתמש הם בעלי מצב (stateful).
EditText הוא הבעלים של מאגר הנתונים הפנימי שניתן לעריכה, מיקום הסמן בהתאמה אישית, מצב המיקוד ומצבים נוספים.
לכן, כשמשתמש, נניח, פותח את המכשיר או משנה את גודל החלון מעבר לסף מסוים, Android הורסת את כל היררכיית התצוגות ומנפחת את קובצי ה-XML החדשים מאפס.
כדי לשמור את הקלט של המשתמש, ה-framework מסתמך על onSaveInstanceState, שיארוז את המצב שמקושר למזהה של התצוגה הזו.
אבל זה יוצר שלושה מצבי כשל אפשריים.
קודם כול, אם מישהו נתן בטעות למזהה את השם text_input במקום text_msg בקובץ ה-XML האחר, שחזור המצב מבטל את טקסט הקלט של המשתמש בלי להציג אזהרות.
שנית, onRestoreInstanceState פועל אחרי onViewCreated, מה שעלול להתנגש לעיתים קרובות עם משקיפי ViewModel ולגרום ללולאות של קריאה חוזרת (callback).
שלישית, אם מחליפים את סוג הווידג'ט בין מגדירים, נניח שבמסך קומפקטי רוצים ספינר ובמסך רחב יותר רוצים RadioGroup, אם יש להם אותו מזהה, זה קורס בזמן ריצה עם ClassCastException.
לעומת זאת, ב-Jetpack Compose, ממשק המשתמש הוא לא אובייקט שיוצרים פעם אחת.
ממשק המשתמש הוא פונקציה טהורה של מצב.
וזה משנה לגמרי את המשמעות של שינוי בהגדרות.
מהו שינוי בהגדרות האישיות ב-Compose?
יש לי רמז.
פרנצ'סקו רומנו
כן.
ב-Compose, שינוי בהגדרות הוא רק עוד קומפוזיציה מחדש, וקומפוזיציות מחדש מתרחשות כל הזמן.
כשהחלון מתרחב, למשל כי המשתמש פתח את המכשיר, הפונקציות הניתנות לקישור קוראות את הגודל החדש, והפונקציות שהפלט שלהן תלוי בגודל פשוט פועלות שוב.
זה כל האירוע.
אבל עכשיו, בואו נראה מה המשמעות של כתיבת אפליקציות מודרניות ורספונסיביות באמצעות הכלים והרכיבים העדכניים ביותר של פיתוח נייטיב.
לכן, בעולם ה-XML הייתם צריכים לשאול שאלות כמו "האם אני פועל בטלפון, בטאבלט או במחשב?", ואז להסתעף בממשק המשתמש על סמך קטגוריית המכשיר.
אבל יש סיבה בסיסית לכך שלא כדאי לעשות את זה.
גם כשהאפליקציה פועלת באותו מכשיר פיזי, ההקשר של האפליקציה והיכולות של ממשק המשתמש יכולים להשתנות באופן דינמי בזמן הריצה.
אז המסר שאני רוצה להעביר הוא שהחלטות לגבי ממשק המשתמש צריכות להתבסס על היכולות הפעילות הנוכחיות: גודל החלון הנוכחי, המנח, מאפייני חומרה מסוימים.
בואו נראה דוגמה קלאסית: ממשק מחשב במסך חיצוני.
אם מחברים את טלפון Pixel לצג חיצוני על השולחן, אותו תהליך של האפליקציה שפעל במסך לאורך בגודל 6 אינץ' פועל עכשיו בצג רחב בגודל 32 אינץ' עם מקלדת פיזית ועכבר.
לכן, בדיקה של סוג המכשיר שהאפליקציה פועלת בו לא מבטיחה שום דבר לגבי המסך שבו היא מוצגת בפועל, או לגבי אופני הקלט.
בנוסף, Android תומכת בריבוי חלונות, כך שאפשר לשנות את הגודל של החלונות.
גם אם המסך של Googlebook גדול יותר מהטלפון, יכול להיות שהאפליקציה פועלת בחלון ברוחב 400dp, יחד עם אפליקציות אחרות.
לכן, גם שאילתות לגבי המדדים הסטטיים של המסך הפיזי יכולות להיות חסרות משמעות.
לכן, כדי לתכנן אפליקציות ל-Compose, קודם כל צריך להבין איך לשלוח שאילתות לסביבה.
ואני רוצה להתחיל בניסיון להגדיר את הקונספט של יכולות ממשק המשתמש.
היכולות של ממשק המשתמש כוללות לא רק את הרוחב והגובה של החלון או את המצב של המכשיר המתקפל, אלא גם את אופני הקלט והפלט שפעילים כרגע.
ראינו שבסביבת המחשב, המשתמשים לא תמיד משתמשים במסך מגע.
הם יכולים להשתמש במקלדת ובעכבר.
במערכת של הרכב, בחלק מהמכוניות יש כפתור סיבובי או קלט אחר, דרך אחרת להזין אותות.
בנוסף, מעבר לגודל המסך ולקלט, המערכת בודקת את המרחק של המשתמש מהמסך, כמו מרחק הצפייה, או אם מצלמה או מיקרופון מחוברים כרגע.
אלה כל היכולות של ממשק המשתמש.
פרנצ'סקו רומנו
עד עכשיו, כדי לאחזר את כל האותות, הייתם צריכים לחבר יחד 5 או 6 ממשקי API שונים ממקורות שונים.
הייתם צריכים את אובייקט ההגדרות האישיות, את WindowMetricsCalculator, את WindowInfoTracker, את InputManager, את PackageManager, ואז להמיר כל אחד מהם באופן ידני למצב, מצב של פיתוח נייטיב.
נהנית לעשות את זה?
לא.
כדי לפתור את הבעיה, הוספנו את 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, שכרגע מיועד למכשירים מתקפלים, ויכול להיות שטוח, על משטח עם מסך למעלה או כמו ספר.
הערך pointerPrecision מציין את רמת הדיוק של אמצעי ההצבעה הראשי: Fine (עכבר, סטיילוס או משטח מגע), Coarse (מסך מגע), Blunt (ג'ויסטיק, בקרים) או None.
keyboardKind יכול להיות פיזי, וירטואלי או ללא.
המאפיינים hasCamera ו-hasMicrophone עוקבים באופן דינמי אחרי הזמינות של החומרה הזו.
אפשר להשתמש במצלמה חיצונית שאפשר לחבר למחשב.
והערך viewingDistance מדווח על קרוב, בינוני או רחוק.
אבל אני חושב שיהיה קל יותר אם נסתכל על דוגמה אמיתית, שהיא זיהוי אם המכשיר נמצא במצב שולחני.
מצב שולחני הוא מצב שבו המשתמשים מקפלים את המכשיר לחצי ובדרך כלל מניחים אותו על משטח.
הוא גם מעולה להפעלת סרטונים ארוכים, לשיחות וידאו ולצילום ב-Time Lapse.
הוא גם שימושי מאוד, למשל, כדי לעקוב אחרי מתכון או לצפות במצגת.
בעבר, כדי לזהות את מצב השולחן לפני 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, שהוא זרימה, מצב קומפוזיציה שמודע למחזור החיים, ואז לעבור על displayFeatures כדי לבדוק אם יש אחד שהוא FoldingFeature, פתוח למחצה ואופקי.
וזה רק כדי לזהות את התנוחה.
זה הרבה קוד סטנדרטי.
עם MediaQuery API החדש, כל זה מצטמצם לכמה שורות קוד בלבד.
@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
return mediaQuery {
when(windowPosture) {
UiMediaScope.Posture.Tabletop -> VideoPlayerLayout.Tabletop
else -> VideoPlayerLayout.Default
}
}
}הפונקציה mediaQuery מקבלת פרמטר למבדה יחיד, ובתוך היקף המקלט של הלמבדה הזו, היא חושפת את קבוצת המאפיינים שראינו עכשיו.
בדוגמה הזו, פשוט על ידי בדיקת windowPosture בביטוי when, אנחנו יכולים להחזיר מיד את ה-composable הנכון.
הנה שתי דוגמאות נוספות לשימוש ב-API.
@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 Dp ל-2 Dp, כדי להציג יותר תוכן בצורה נוחה. מכיוון שהמשתמשים לא משתמשים באצבעות, יעדי המגע יכולים להיות קטנים יותר.
וגם פעולות שמבוססות על יכולות.
נניח שיש לנו סרגל קלט של צ'אט, אנחנו יכולים לבדוק את 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...
}רשת מאפשרת לכם להטמיע פריסה דו-ממדית על ידי הצגת פריטים בעמודות ובשורות שמתאימות לגודל המאגר הזמין.
בואו נראה כמה דברים שאפשר לעשות בבלוק config.
אתם יכולים להוסיף רכיבים או טראקים חדשים על ידי קריאה לעמודה או לשורה, ואתם יכולים להשתמש בלולאות הרגילות של Kotlin כמו repeat.
אפשר להגדיר מרווחים בין המסלולים.
אפשר לשלוט בכיוון של המיקום האוטומטי באמצעות 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 היא התא בפינה הימנית העליונה.
הוא תומך בהוספה לאינדקס שלילי, כך ששורה -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.
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.
וכאן אנחנו מגדירים את שתי פונקציות הלמבדה של ההגדרות ב-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)
}
}בפריסה של נגן הווידאו שמוגדרת כברירת מחדל, אנחנו מגדירים רשת של שורה אחת ועמודה אחת, אבל שימו לב שגם אם יש רק תא אחד, אנחנו עדיין יכולים להקצות שני שמות של אזורים לאותו תא בדיוק.
במקרה הזה, כמו שראינו קודם, הבקר מופיע בשכבת-על מעל הנגן.
במצב שולחני, אנחנו פשוט מגדירים שתי שורות ועמודה אחת, וממקמים את הנגן בחצי העליון ואת הבקר בחצי התחתון, בערך ככה.
אחרי שנגדיר את פונקציות הלמבדה האלה, נוכל להשתמש ב-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 הרגיל.
למה?
כי הרוחב והגובה של החלון הם ערכים שמשתנים לעיתים קרובות.
כשמשתמשים משנים את גודל האפליקציה, למשל במחשב, הערכים האלה יכולים להשתנות בכל פריים.
סביר להניח שאתם לא רוצים ליצור מחדש את ה-scaffold ברמת האפליקציה בכל פריים, אלא רק כשהחלון חוצה נקודת עצירה (breakpoint).
ואחרי שיש לנו את מחלקות הגודל של החלון, איך אנחנו מעבירים את האפליקציה שלנו מתהליך של חלונית אחת לפריסות של כמה חלוניות כשיש לנו יותר מקום?
כאן Jetpack Navigation 3 נכנס לתמונה, כי Nav 3 תוכנן מהיסוד סביב המצב והסצנות של Compose.
קודם כול, מקבץ הפעילויות הקודמות (back stack) הוא בבעלותכם. זו פשוט רשימה של NavKeys, אז היא מאוד גמישה וקל לשמור אותה כמצב.
כל מפתח במחסנית נפתר ל-NavEntry שמכיל את ה-composable והמטא-נתונים שלו.
בנוסף, NavDisplay לא מקודד בתוך הקוד את אופן השרטוט של הרשומות, אלא מעריך רשימה של אסטרטגיות סצנה.
במסגרת האסטרטגיה, אתם יכולים לבדוק את היכולות של ממשק המשתמש, למשל, את סוגי הגודל של החלון.
if (isExpandedWidth) {
/* return a multi-pane scene */
}
else {
/* return a single pane scene */
}אתם יכולים לבדוק את מקבץ הפעילויות הקודמות (back stack) ואז להחליט אם להציג NavEntry אחד בחלונית אחת, או כמה פריטים בו-זמנית, או אחד מעל השני.
היא מאוד גמישה.
בנוסף לאסטרטגיית החלוניות המרובות, קטגוריות גודל החלון גם קובעות באילו רכיבי ניווט כדאי להשתמש כדי למקסם את הארגונומיה ואת השימוש בשטח המסך.
לדוגמה, ברוחב קומפקטי, כדאי להשתמש בסרגל ניווט תחתון, ברוחב בינוני, ב-NavRail, וברוחב מורחב, בחלונית הזזה מלאה לניווט.
כשמשלבים את Nav3 להחלטות לגבי פריסה ברמת האפליקציה ורכיבים רספונסיביים להגדרה ברמת המסך, מקבלים אפליקציות שנראות ככה ומתאימות כמעט לכל גודל חלון וסביבה.
אם אתם רוצים לקבל מידע נוסף על העדכונים האחרונים ב-Nav 3, כולל התמיכה בקישורי עומק, כדאי לצפות בהרצאה Building Type-Safe Deep Links with Jetpack Navigation 3.
פרנצ'סקו רומנו
אוקיי, סקרנו היום הרבה ממשקי API חדשים: MediaQuery, Grid, FlexBox, Nav3.
זה הכול לגבי האופן שבו Jetpack Compose עוזר לכם לפתח אפליקציות מותאמות אישית.
אבל בעידן של פיתוח מבוסס-סוכנים, יש עוד יתרון חשוב מאוד לשימוש ב-Jetpack Compose: הוא הרבה יותר יעיל.
בטח שמעתם את זה בנאום המרכזי, ראינו אפליקציות שמשתמשות ב-30% פחות טוקנים אחרי המעבר ל-Compose, ואנחנו פועלים כדי לשפר עוד יותר את Agentic Workflows באפליקציות Compose.
היום אני רוצה להדגיש את העדכונים העיקריים במיומנות Compose Adaptive.
android skills list
android skills add adaptiveכמו שאמרתי, בנוסף להעברת הפריסות ל-Grid, ל-Flexbox ול-MediaQuery, עכשיו הוא מטפל באופן מפורש בניהול המצב מקצה לקצה באפליקציה, ואנחנו בודקים את המיומנות מול באגים של אובדן מצב בעולם האמיתי: שמירה על הניווט, מיקום הגלילה, הפעלת מדיה פעילה, קלט טקסט שהוקלד חלקית וכן הלאה.
היכולת הזו כוללת עכשיו גם אופטימיזציה של תנוחת המכשיר המתקפל, כמו שינוי אוטומטי של מבנה המסכים של המדיה והמצלמה למצב שולחני. אבל גם כשהאפליקציה פועלת ב-Googlebook או במסך חיצוני עם עכבר ומקלדת, הפריסה היא רק חלק מהסיפור.
היכולת "התאמה" יכולה עכשיו לבדוק את ממשק המשתמש כדי למצוא ולהטמיע מקשי קיצור, סמני עכבר בהתאמה אישית, מצבי העברת עכבר, הסברים על כלים ועוד.
בואו נראה איך זה עובד.
הקלטתי הדגמה של זה.
אז יש לנו את האפליקציה.
אנחנו יכולים להסתכל על חיות המחמד שלנו.
אנחנו יכולים להתכתב איתם.
אנחנו יכולים לראות סרטון שלהם.
כן, אנחנו יכולים לראות את חיות המחמד המובילות.
אבל כבר אפשר לראות שזה לא ממש טוב.
עכשיו נראה, אני רוצה לחפש תמונה עם קצת דרמה וחתולים.
אוקיי, אני רוצה לראות אותן בגודל גדול יותר.
אוקיי, זה מחזיר אותי לדף הבית.
אוקיי, אני אספר על זה לחבר שלי.
כן, אז, "באדי", הממ.
האפליקציה לא טובה.
הוא גם רחב מדי.
אתם רואים שהיא רחבה מדי.
אפשר לנסות לשנות את הגודל שלה.
פריט הגרפיקה
אה, כן, בסדר.
והטקסט שהקלדתי אבד.
עכשיו אפשר לבקש מ-Gemini לתקן את זה.
בזכות הסקיל "התאמה", אפשר להשתמש בהנחיה פשוטה כמו "התאם את האפליקציה".
כמו שאתם רואים, אני משתמש ב-Gemini 3.0 Flash, שיכול לעשות את זה יחד עם המיומנות.
הוא קלט את המיומנות, ועכשיו הוא מתחיל להריץ את השינויים.
הוא ישאל כמה שאלות, כמו "אוי, MediaQuery הוא ניסיוני.
בטוח שברצונך להשתמש בו?"
וממשיכים.
הייתי צריך להאיץ את הסרטון כדי לחתוך דברים, אבל אם תגיעו לאזור ההדגמה, תוכלו לנסות את המיומנויות בעצמכם.
וזו רק אחת מהמיומנויות שהשקנו.
לדוגמה, יש לנו גם מיומנות חדשה שתעזור לכם ליצור חוויות מדהימות עם סטיילוס.
אז בואו לאזור כדי לנסות.
זהו, סיימנו.
הוא שינה 11 קבצים.
בואו נראה איך זה עובד.
אוקיי.
Grid כבר עושה את העבודה.
תפריט הניווט עבר לצד.
אפשר לנסות את המסננים.
אני חושב שהם יעבדו עכשיו.
כן, הם עושים את זה.
אוקיי, בוא נשלח הודעה לחבר שלי ונגיד לו שהאפליקציה מעולה עכשיו.
בואו נראה איך זה נראה עם יותר מקום.
כן, הוא הועבר לפריסה של רשימה ופרטים.
ההרצה הזו נמשכה כ-20 דקות וכללה כ-200,000 טוקנים.
אוקיי.
פרנצ'סקו רומנו
בדרך כלל אני מתחיל הרצאה עם מדדים עסקיים וסיפורי הצלחה, אבל לגבי ADS, רציתי לקפוץ ישר לקוד.
אבל אני רוצה להקדיש כמה דקות כדי לשתף את ההחזר על ההשקעה בהנדסה.
היום, אפליקציית Android שלכם יכולה לפעול ביותר מ-580 מיליון מכשירים פעילים עם מסך גדול, ואנחנו גם יודעים שהמשתמשים במכשירים מתקפלים ובמכשירים עם התאמה רחבה הם בין הפלחים הכי מעורבים ב-Android.
בואו נבחן שני מקרים לדוגמה שמוכיחים מה קורה כשמשיקים גרסה מותאמת.
הראשונה היא Gratitude, אפליקציה פופולרית לטיפול עצמי ולכתיבת יומן יומי.
הודות לעיצובים מותאמים למסכים, אפליקציית Gratitude הפכה את הטאבלטים והמכשירים המתקפלים לערוץ צירוף משתמשים עם צמיחה גבוהה.
קודם כול, הם הכפילו את קצב צירוף המשתמשים במסכים גדולים.
מספר ההתקנות היומיות שמקורן בטאבלטים ובמכשירים מתקפלים גדל ב-100%.
הם הגדילו את נתח בסיס המשתמשים במסכים גדולים מ-7% ל-10% מכלל ההתקנות.
השנייה היא BoxBoxClub, אפליקציה נלווית לפורמולה 1 ולספורט מוטורי. אחרי שהם עדכנו את ה-build שלהם למכשירים מתקפלים ולמסכים גדולים יותר, נתוני הטלמטריה שלהם הראו רווחים עצומים: עלייה של 7.6% במספר המשתמשים הפעילים הכולל ועלייה של 54% בזמן ההתעניינות.
אוקיי.
הגענו לסוף השיחה, ואני רוצה לתת לכם שלוש מסקנות חשובות.
פיתוח של אפליקציה רספונסיבית מאפשר להציג אותה ביותר מכשירים ולמשתמשים רבים יותר, וגם להכין אותה לעתיד בסביבה המתרחבת הזו.
פיתוח ל-Android מתבצע קודם כל ב-Compose.
כל הכלים, הספריות והמיומנויות החדשים מיועדים ל-Compose.
Compose הוא כלי מעולה לפריסות רספונסיביות ולפיתוח מבוסס-סוכנים.
בסיסי הקוד קטנים יותר וקל יותר להבין אותם, והסוכנים משתמשים בפחות אסימונים כשהם עובדים עם קוד Compose.
אז קדימה, נסו את כל הכלים, המיומנויות וממשקי ה-API החדשים, ותיהנו.
תודה.
<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