Ekosistem Android sangat luas, dengan pengguna yang beralih dengan lancar di berbagai ponsel, tablet, perangkat foldable, dan lingkungan desktop. Sekarang setelah kita menggunakan Compose First, sesi ini akan membahas cara merancang aplikasi untuk jendela yang dapat diubah ukurannya dan beragam rasio aspek—tempat asumsi tata letak tradisional dipecah, sehingga menghasilkan UI yang direntangkan dan status yang hilang.
Pelajari cara kemampuan terbaru di Jetpack Compose, Navigation 3, dan WindowManager dapat membantu Anda membangun tata letak dinamis yang diskalakan secara alami dari layar kecil ke layar panel ganda. Dapatkan strategi untuk mempercepat alur kerja pengembangan Anda menggunakan Gemini dan kemampuan kami, seperti kemampuan adaptif, untuk otomatis melakukan refaktorisasi codebase lama untuk layar yang lebih besar.
MediaQuery API eksperimental melacak kemampuan UI aktif secara deklaratif, seperti ukuran jendela dan postur lipat. API ini menggantikan penggabungan beberapa pengelola platform lama ke dalam status Compose.Grid dan FlexBox yang stabil menyediakan elemen penyusun tata letak adaptif. Grid memungkinkan pengaturan multi-dimensi berparameter dalam satu composable, sedangkan FlexBox menyediakan distribusi dan penggabungan ruang satu dimensi.Transkrip
Francesco Romano
Halo semua.
Hari ini, kita akan membahas secara mendalam cara menskalakan aplikasi ke setiap layar dengan Jetpack Compose.
Mengapa perlu menskalakan ke setiap layar?
Karena ekosistem Android telah berkembang di berbagai faktor bentuk yang berbeda.
APK Anda dapat berjalan di ponsel, perangkat foldable, tablet, Chromebook dan Googlebook, TV, sistem infotainmen otomotif, perangkat XR, dan sebagainya.
Jadi, hal pertama adalah menjangkau pengguna di mana pun mereka berada.
Kedua, peralihan platform Android ke aplikasi adaptif tidak bersifat sementara.
Kita tidak akan kembali ke dunia yang hanya menggunakan ponsel, teknologi ini akan terus ada.
Berikut adalah linimasa singkatnya.
Pada tahun 2022, kami mulai meletakkan fondasinya.
Kami memiliki Android 12L, rilis pertama Jetpack WindowManager, kami memberi Anda API seperti class ukuran jendela, dan kami memublikasikan versi pertama pedoman kualitas aplikasi adaptif.
Kemudian, kami juga memperbarui Google Play sehingga rating dibagi menurut jenis perangkat, dan kami mulai menyertakan pedoman kualitas aplikasi adaptif ke dalam keputusan penentuan peringkat.
Di Android 15, kami juga menghadirkan mode jendela desktop di AOSP, dan Compose Material 3 Adaptive yang mencapai versi stabil.
Kemudian, mulai Android 16, SDK 36, sistem akan mengabaikan kunci orientasi, kunci rasio aspek, dan flag kemampuan perubahan ukuran di layar yang lebih besar.
Bersama dengan rilis layar terhubung, kini APK Anda dapat berjalan di hampir semua ukuran jendela.
SDK 36 memberi Anda satu tanda manifes sementara untuk memilih tidak ikut penggantian orientasi dan rasio aspek.
Di Android 17, opsi penonaktifan tersebut tidak ada lagi.
Jadi, membangun aplikasi adaptif adalah persyaratan platform dasar.
Selama satu dekade terakhir, kami telah bekerja sama dengan tim di seluruh ekosistem, dan kami mempelajari secara langsung mengapa membangun aplikasi adaptif terasa sulit.
Anda mengalami beberapa kehilangan status, API jendela yang terfragmentasi, sinyal yang tidak jelas tentang kapan harus beralih tata letak, dan beberapa asumsi lama yang tertanam jauh di dalam codebase.
Jadi, kami mengambil semua yang kami pelajari dari tantangan developer selama satu dekade ini, dan kami membangunnya langsung ke dua tempat: pertama, ke toolkit UI itu sendiri.
Hari ini, kita akan mempelajari beberapa kemampuan baru yang penting di Jetpack Compose: kueri media, petak, FlexBox, dan Navigation 3.
Namun, juga dalam alat agentic yang menulis kode bersama Anda melalui kemampuan Android resmi untuk Gemini di Android Studio dan Android CLI.
Misalnya, kita akan melihat kemampuan adaptif Compose yang dirilis di Google I/O awal tahun ini.
Aplikasi ini sudah mampu memigrasikan tata letak ponsel ke ukuran jendela yang lebih besar.
Sekarang, aplikasi ini juga dapat memperbaiki kehilangan status, menerapkan pengalaman unik perangkat foldable seperti mode di atas meja, dan meningkatkan dukungan input dengan menemukan dan menerapkan pintasan keyboard, kursor kustom, tooltip, dan sebagainya.
Oke, mari kita mulai dengan Jetpack Compose, yang merupakan fondasi untuk semua ini.
Jetpack Compose menjadi stabil pada tahun 2021, sehingga tahun ini adalah ulang tahunnya yang kelima.
Dan selama lima tahun tersebut, kami telah melihat di ribuan aplikasi produksi betapa jauh lebih mudah dan andalnya membangun aplikasi adaptif dengan Compose.
Pengembangan Android kini secara resmi mengutamakan Compose.
Artinya, semua tata letak adaptif, gaya, dan komponen navigasi modern yang baru dibuat untuk Compose.
Dari perspektif arsitektur sistem, keunggulan terbesar Compose untuk aplikasi adaptif dibandingkan sistem tampilan lama adalah cara status dan UI berinteraksi.
Untuk menjelaskannya, saya ingin membuat perbandingan singkat dengan sistem tampilan.
Jadi, pikirkan cara kerja sistem tampilan.
UI Anda adalah hierarki objek yang dapat diubah yang di-inflate sekali dari file XML statis.
Anda memiliki penentu resource yang berbeda, dan mari kita mulai dengan masalah utama di sini: duplikasi di berbagai penentu resource.
Saat mem-build UI adaptif di XML, Anda tidak memiliki logika kondisional di dalam file tata letak.
Sebagai gantinya, Anda cukup menduplikasi seluruh hierarki di seluruh folder resource, seperti tata letak, tata letak jendela terkecil 600 dp, tata letak lanskap, dan sebagainya.
Oke, lihat contoh EditText di sebelah kanan.
<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>Komponen logis yang sama persis yang menangani input teks, dan tersebar di berbagai file XML.
Jika Anda memperbarui atribut seperti inputType atau maxLength di tata letak ponsel, tidak ada apa pun di compiler yang memaksa Anda untuk memperbaruinya juga di tata letak tablet.
Karena komponen dipisahkan di berbagai file, penyimpangan struktural hampir pasti terjadi seiring waktu.
Duplikasi tersebut juga terkait dengan masalah lain, yaitu hilangnya status selama perubahan konfigurasi.
Ingat bahwa dalam sistem View, komponen UI bersifat stateful.
EditText memiliki buffer Editable internal, posisi kursor kustom, status fokus, dan beberapa status.
Jadi, saat pengguna, misalnya, membentangkan perangkat atau mengubah ukuran jendela melebihi batas tertentu, Android akan menghancurkan seluruh hierarki tampilan dan meng-inflate file XML baru dari awal.
Untuk menyimpan input pengguna, framework mengandalkan onSaveInstanceState, yang akan mengemas status yang ditautkan ke ID tampilan tersebut.
Namun, hal ini menciptakan tiga kemungkinan mode kegagalan.
Pertama, jika seseorang tidak sengaja menamai ID text_input, bukan text_msg di XML lain, pemulihan status akan otomatis menghapus teks input pengguna tanpa peringatan.
Kedua, onRestoreInstanceState berjalan setelah onViewCreated, yang sering kali dapat bertabrakan dengan observer ViewModel, sehingga dapat menyebabkan loop callback.
Dan ketiga, jika Anda menukar jenis widget di seluruh penentu, misalnya di layar rapat, mungkin Anda menginginkan spinner, dan di layar yang lebih lebar, Anda menginginkan RadioGroup, jika keduanya memiliki ID yang sama, hal ini akan menyebabkan error saat runtime dengan ClassCastException.
Di Jetpack Compose, UI Anda bukanlah objek yang Anda buat sekali.
UI Anda adalah fungsi murni dari status.
Dan hal itu mengubah sepenuhnya definisi perubahan konfigurasi.
Apa yang dimaksud dengan perubahan konfigurasi di Compose?
Saya punya petunjuk.
Francesco Romano
Ya.
Di Compose, perubahan konfigurasi hanyalah rekomposisi lain, dan rekomposisi terjadi setiap saat.
Saat jendela melebar, mungkin karena pengguna membentangkan perangkat, composable Anda akan membaca ukuran baru, dan composable yang output-nya bergantung pada ukuran tersebut akan dijalankan ulang.
Itulah seluruh acaranya.
Namun sekarang, mari kita lihat apa artinya menulis aplikasi adaptif modern menggunakan alat dan komponen Compose terbaru.
Jadi, dunia XML membuat Anda mengajukan pertanyaan seperti, "Apakah saya menjalankan aplikasi di ponsel, tablet, atau desktop?", lalu membuat cabang UI berdasarkan kategori perangkat.
Namun, ada alasan mendasar untuk menghindari hal tersebut.
Meskipun berjalan di perangkat fisik yang sama persis, konteks aplikasi dan kemampuan UI dapat berubah secara dinamis saat runtime.
Jadi, pesan yang ingin saya sampaikan adalah bahwa keputusan UI harus didasarkan pada kemampuan aktif saat ini: ukuran jendela saat ini, postur, beberapa karakteristik hardware.
Jadi, mari kita lihat contoh klasik, yaitu mode jendela desktop di monitor eksternal.
Jika Anda menghubungkan ponsel Pixel ke monitor eksternal di meja, proses aplikasi yang sama persis yang baru saja berjalan di layar potret 6 inci kini berjalan di monitor lebar 32 inci dengan keyboard fisik dan mouse terpasang.
Jadi, memeriksa jenis perangkat yang menjalankan aplikasi tidak menjamin apa pun tentang layar tempat aplikasi tersebut ditampilkan, atau modalitas inputnya.
Selain itu, Android mendukung multi-aplikasi, sehingga jendela dapat diubah ukurannya.
Meskipun layar Googlebook lebih besar daripada ponsel, aplikasi Anda mungkin berjalan di jendela selebar 400 dp, bersama dengan aplikasi lain.
Jadi, mengkueri metrik tampilan fisik statis juga bisa menjadi tidak berarti.
Jadi, untuk merancang aplikasi untuk Compose terlebih dahulu, kita perlu memahami cara membuat kueri lingkungan.
Dan saya ingin memulai dengan mencoba memformalkan konsep kemampuan UI.
Kemampuan UI tidak hanya mencakup lebar dan tinggi jendela atau postur perangkat foldable, tetapi juga modalitas input dan output yang saat ini aktif.
Kami telah melihat bahwa di lingkungan desktop, pengguna tidak selalu menggunakan layar sentuh.
Mereka dapat menggunakan keyboard dan mouse.
Dalam sistem otomotif, beberapa mobil memiliki kenop putar atau input yang berbeda, cara yang berbeda untuk memasukkan sinyal.
Selain itu, selain ukuran layar dan input, seberapa jauh pengguna dari layar, seperti jarak pandang, atau apakah kamera atau mikrofon hardware saat ini terhubung.
Itulah semua kemampuan UI.
Francesco Romano
Sekarang, jika Anda ingin mengambil semua sinyal, Anda harus menggabungkan 5, 6 API berbeda dari berbagai sumber.
Anda memerlukan objek konfigurasi, WindowMetricsCalculator, WindowInfoTracker, InputManager, PackageManager, lalu, secara manual, mengonversi masing-masing objek tersebut dalam status, status Compose.
Apakah Anda suka melakukannya?
Tidak.
Untuk mengatasi hal ini, kami memperkenalkan MediaQuery API di androidx.compose.ui.
mediaQuery { ... }
derivedMediaQuery { ... }Fitur ini terinspirasi oleh kueri media CSS.
Dan kata media tidak berarti pemutaran audio atau video.
Hal ini mengacu pada media, lingkungan tempat UI Anda dirender.
Dan MediaQuery memberi Anda satu cara deklaratif native Compose untuk mengkueri kemampuan UI.
Mari kita lihat properti yang ditampilkan di 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 dan windowHeight dalam Dp.
windowPosture, yang saat ini ditujukan untuk perangkat foldable, dan dapat berupa Flat, Tabletop, atau Book.
pointerPrecision memberi tahu Anda presisi perangkat penunjuk utama: Fine untuk mouse, Stylus, atau Trackpad; Coarse untuk Sentuh; Blunt untuk joystick, pengontrol, atau None.
keyboardKind dapat berupa fisik, virtual, atau tidak ada.
hasCamera dan hasMicrophone melacak secara dinamis apakah hardware ini tersedia.
Pikirkan kamera eksternal yang dapat dicolokkan.
Dan viewingDistance melaporkan Dekat, Sedang, atau Jauh.
Namun, akan lebih mudah jika kita melihat contoh nyata, yaitu mendeteksi apakah perangkat dalam mode Di Atas Meja.
Mode di atas meja adalah postur yang memungkinkan pengguna melipat perangkat menjadi setengah dan biasanya diletakkan di atas permukaan.
Dan mode ini sangat cocok untuk pemutaran video yang lama, panggilan video, dan fotografi time-lapse.
Fitur ini juga sangat berguna untuk, misalnya, mengikuti resep atau menonton slideshow.
Sebelumnya, jika ingin mendeteksi postur Tabletop sebelum MediaQuery, Anda harus menulis semua kode ini.
@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
}
}Anda harus mendapatkan konteks, mendapatkan instance WindowInfoTracker, mengumpulkan windowLayoutInfo, yang merupakan flow, status Compose berbasis siklus proses, lalu melakukan iterasi melalui displayFeatures untuk memeriksa apakah ada yang merupakan FoldingFeature, setengah terbuka, dan horizontal.
Dan itu hanya untuk mendeteksi postur.
Ada banyak boilerplate.
Dengan MediaQuery API baru, semua itu diringkas menjadi hanya beberapa baris kode ini.
@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
return mediaQuery {
when(windowPosture) {
UiMediaScope.Posture.Tabletop -> VideoPlayerLayout.Tabletop
else -> VideoPlayerLayout.Default
}
}
}Fungsi mediaQuery menggunakan satu parameter lambda, dan di dalam cakupan penerima lambda tersebut, fungsi ini mengekspos kumpulan properti yang baru saja kita lihat.
Dalam contoh ini, dengan hanya memeriksa windowPosture dalam ekspresi when, kita dapat langsung menampilkan composable yang tepat.
Berikut dua contoh lain cara menggunakan 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")
}
}
}Pertama adalah kepadatan informasi.
Jadi, saat pengguna menggunakan laptop, monitor desktop dengan mouse, dan presisi kursornya adalah Halus, kita dapat memperkecil padding pada komponen UI dari, dalam contoh ini, 8 Dp menjadi 2 Dp untuk menampilkan lebih banyak konten dengan nyaman, karena pengguna tidak menggunakan jari mereka, target sentuh dapat lebih kecil.
Selain itu, tindakan berbasis kemampuan.
Misalnya, kita memiliki panel input chat, kita dapat memeriksa hasCamera dan hasMicrophone untuk menampilkan atau menyembunyikan tombol tindakan panggilan video secara dinamis, atau kita dapat menggabungkan jenis keyboard fisik untuk menampilkan, atau tidak, beberapa petunjuk pintasan keyboard seperti tekan Ctrl+Enter untuk mengirim.
Semua itu diambil pada waktu proses.
MediaQuery juga sangat cocok untuk pengujian dan pembuatan pratinjau.
@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()
}
}Jadi, pada dasarnya, MediaQuery me-resolve penerimanya melalui composition local yang disebut LocalUiMediaScope.
Jadi, Anda cukup memberikan UiMediaScope palsu melalui CompositionLocalProvider dalam pratinjau, dan Anda tidak perlu mengaktifkan perangkat foldable dan menempatkan 90° atau memasang mouse hanya untuk menjalankan pengujian Compose UI.
Francesco Romano
Oke, sekarang kita tahu cara mengkueri kemampuan UI.
Sekarang, mari kita lihat cara membuat tata letak yang sebenarnya.
Untuk itu, kami ingin memperkenalkan Grid, yang merupakan primitif tata letak baru di androidx.compose.foundation.layout, yang kini stabil.
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 memungkinkan Anda menerapkan tata letak dua dimensi dengan menampilkan item dalam kolom dan baris yang beradaptasi dengan ukuran penampung yang tersedia.
Mari kita lihat beberapa hal yang dapat Anda lakukan di blok konfigurasi.
Anda dapat menambahkan elemen atau trek baru dengan memanggil kolom atau baris, dan Anda dapat menggunakan loop Kotlin standar seperti repeat.
Anda dapat menetapkan gutter di antara jalur.
Anda dapat mengontrol arah penempatan otomatis melalui flow-- GridFlow.Row atau GridFlow.Column.
Berikut adalah detail arsitektur utama.
Blok konfigurasi berjalan selama penerusan pengukuran, dan cakupan konfigurasi petak menerapkan kepadatan dan mengekspos batasan.
Artinya, Anda dapat memeriksa constraints.maxWidth.toDp() langsung di dalam konfigurasi untuk menghitung trek tanpa penerusan rekomposisi tambahan.
Di Grid, saat Anda menambahkan turunan-- saat Anda menambahkan turunan tanpa pengubah, turunan tersebut akan otomatis mengalir ke sel berikutnya.
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))
}Jika menginginkan kontrol eksplisit, Anda dapat menggunakan Modifier.gridItem, dan Anda memiliki koordinat berbasis satu.
Jadi, baris 1, kolom 1 adalah sel kiri atas.
Fungsi ini mendukung pengindeksan negatif, sehingga baris -1 adalah baris terakhir, dan seterusnya.
Anda dapat menggunakan rowSpan untuk merentangkan item di seluruh area 2D, dan ada juga tumpang-tindih.
Jadi, jika dua item ditempatkan ke dalam sel yang tumpang-tindih, Grid akan menumpuknya di atas satu sama lain dalam batas tersebut, dan pengurutan Z ditentukan oleh urutan komposisi.
Jadi, item berikutnya akan digambar di atas item lainnya seperti Box.
Sekarang, kita sudah mengetahui dasar-dasar Grid.
Mari kita lihat contoh pemutar video.
Bergantung pada apakah perangkat dalam posisi datar atau setengah terbuka, kita perlu beralih antara tata letak default atau tata letak mode di atas meja.
Sebelum Grid, kita harus membuat dua composable yang berbeda, DefaultLayout dan TabletopLayout, lalu mencabangkan kode pada ekspresi 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)
}
}Anda mungkin berpikir, "Apa salahnya?"
Jika Anda melihat tata letak tersebut, kontennya identik.
Keduanya menampilkan pemutar dan pengontrol yang sama persis.
Satu-satunya perbedaan di antara keduanya adalah cara kedua elemen ini disusun di layar.
Hingga saat ini, karena tidak memiliki cara untuk meneruskan pengaturan tata letak itu sendiri sebagai parameter, kami terpaksa menentukan dua composable terpisah.
Sekarang, kita dapat membuat parameter konfigurasi tata letak dan menangani semuanya dalam satu fungsi composable.
Kita akan melihat caranya.
Jadi, dengan Grid API, kita dapat menentukan area bernama sebagai 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()
}
}Kemudian, kita dapat menentukan composable turunan, pemutar, dan pengontrol, untuk ditempatkan di dalam petak, dan kita dapat mengaitkan setiap turunan dengan area bernama yang memanggil Modifier.gridItem, dan kita meneruskan areaId.
Dan di sini kita memberi tahu Grid untuk menempatkan pemutar ke area bernama VideoPlayerArea.Player dan pengontrol ke VideoPlayerArea.Controller.
Dan di sini kita menentukan dua lambda konfigurasi dalam 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)
}
}Untuk tata letak pemutar video default, kita mengonfigurasi petak yang terdiri dari satu baris dan satu kolom, tetapi perhatikan bahwa meskipun hanya ada satu sel, kita masih dapat menetapkan dua nama area ke sel yang sama persis.
Dalam hal ini, seperti yang telah kita lihat sebelumnya, pengontrol ditempatkan di atas pemutar.
Untuk mode di atas meja, kita hanya perlu menentukan dua baris dan satu kolom, dengan menempatkan pemutar di paruh atas dan pengontrol di paruh bawah, pada dasarnya, seperti ini.
Setelah menentukan lambda konfigurasi tersebut, kita dapat menggunakan MediaQuery langsung ke konfigurasi petak.
@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()
}
}
}Jadi, sekarang kita dapat memiliki satu composable tata letak pemutar video dengan UI yang beralih secara dinamis di antara dua konfigurasi.
Primitif berikutnya yang ingin saya bahas adalah 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) }
)
}
}
}Jadi, meskipun Grid menangani tata letak baris dan kolom 2D, FlexBox, yang kini juga stabil, menangani tata letak satu dimensi yang masih memerlukan distribusi ruang yang cerdas, penyusutan, dan penggabungan multibaris.
Ini adalah tata letak yang praktis dan responsif yang dapat Anda gunakan sebagai pengganti dua composable terpisah, seperti FlowRow dan FlowColumn, karena ini adalah satu penampung yang arahnya dapat berubah secara dinamis di dalam blok konfigurasinya tanpa membuat ulang composable turunan.
Dan menerapkan algoritma CSS FlexBox lengkap, sehingga Anda memiliki wrap, justify-content, align-items, gap.
Dan pada setiap turunan, Anda memiliki Modifier.flex yang memberi Anda basis, faktor pertumbuhan dan penyusutan relatif, alignSelf, dan sebagainya.
Francesco Romano
Oke, sekarang mari kita beralih dari definisi tingkat layar ke tata letak tingkat aplikasi, cara kita menyusun navigasi tingkat atas dan pengaturan layar multi-panel di seluruh aplikasi, bukan hanya satu layar.
Meskipun MediaQuery memberi Anda lebar dan tinggi jendela yang tepat dalam Dp, untuk arsitektur tingkat aplikasi, Anda memerlukan titik henti sementara area pandang yang standar dan ditentukan, yaitu class ukuran jendela.
Pada dasarnya, Anda ingin setiap layar responsif dalam setiap bucket ini, sekaligus dapat memiliki lebih banyak perubahan dan update struktural serta perbedaan antara berbagai bucket.
Misalnya, dengan hanya menampilkan daftar di layar kecil atau daftar dan detail di jendela yang diperluas.
Anda dapat membuat class ukuran jendela langsung di atas UiMediaScope dengan cara yang sangat rapi, yaitu menentukan properti ekstensi di 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...
}Satu hal yang perlu diperhatikan, widget ini menggunakan derivedMediaQuery, bukan MediaQuery standar.
Mengapa?
Karena lebar dan tinggi jendela adalah nilai yang sering berubah.
Saat pengguna mengubah ukuran aplikasi Anda, seperti di desktop, nilai ini dapat berubah setiap frame.
Dan Anda mungkin tidak ingin merekomposisi scaffold tingkat aplikasi di setiap frame, tetapi hanya saat jendela benar-benar melewati titik henti sementara.
Setelah memiliki class ukuran jendela, bagaimana cara mentransisikan aplikasi antara alur panel tunggal ke tata letak multi-panel saat kita memiliki lebih banyak ruang?
Di sinilah Jetpack Navigation 3 unggul, karena Nav 3 dirancang dari awal berdasarkan status dan scene Compose.
Pertama, Anda memiliki data sebelumnya, yang merupakan daftar NavKey, sehingga sangat fleksibel dan mudah disimpan sebagai status.
Setiap kunci di stack di-resolve ke NavEntry yang berisi composable dan metadatanya.
NavDisplay tidak melakukan hard code pada cara entri digambar; melainkan mengevaluasi daftar strategi tampilan.
Dalam strategi, Anda dapat memeriksa kemampuan UI, misalnya, class ukuran jendela.
if (isExpandedWidth) {
/* return a multi-pane scene */
}
else {
/* return a single pane scene */
}Anda dapat memeriksa data sebelumnya, lalu memutuskan apakah akan merender satu NavEntry dalam satu panel, atau beberapa item secara bersamaan, atau satu di atas yang lain.
Sangat fleksibel.
Selain strategi multi-panel, class ukuran jendela juga menentukan komponen navigasi yang ingin Anda gunakan untuk memaksimalkan ergonomi dan penggunaan ruang layar.
Misalnya, pada lebar rapat, Anda menginginkan menu navigasi bawah, pada lebar sedang, NavRail, pada lebar diperluas, panel navigasi penuh.
Saat Anda menggabungkan Nav3 untuk keputusan tata letak tingkat aplikasi dan komponen responsif untuk definisi tingkat layar, Anda akan mendapatkan aplikasi yang terlihat seperti ini dan berfungsi dengan baik di hampir setiap ukuran dan lingkungan jendela.
Khusus untuk Nav 3, jika Anda ingin mengetahui lebih lanjut update terbaru, termasuk dukungan untuk deep link, lihat diskusi Mem-build Deep Link yang Aman dari Jenis dengan Jetpack Navigation 3.
Francesco Romano
Oke, kita telah membahas banyak API baru hari ini: MediaQuery, Grid, FlexBox, Nav3.
Dan itulah cara Jetpack Compose membantu Anda mem-build aplikasi adaptif.
Namun, di era pengembangan agentic, ada keuntungan lain yang sangat penting dalam menggunakan Jetpack Compose: jauh lebih efektif.
Anda mungkin mendengarnya selama keynote, kami telah melihat aplikasi menggunakan token lebih dari 30% lebih sedikit setelah bermigrasi ke Compose, dan kami berupaya untuk lebih mengoptimalkan Agentic Workflows Anda untuk aplikasi Compose.
Hari ini, saya ingin menyoroti update utama pada keterampilan Compose Adaptive.
android skills list
android skills add adaptiveSeperti yang saya katakan, selain memigrasikan tata letak ke Grid, Flexbox, dan MediaQuery, model ini sekarang secara eksplisit menangani pengelolaan status end-to-end di seluruh aplikasi, dan kami menguji kemampuannya terhadap bug kehilangan status di dunia nyata: mempertahankan navigasi, posisi scroll, pemutaran media aktif, input teks yang diketik setengah, dan sebagainya.
Keterampilan ini sekarang juga mencakup pengoptimalan postur perangkat foldable, seperti melakukan refaktorisasi layar media dan kamera secara otomatis ke mode di atas meja, tetapi juga saat aplikasi Anda berjalan di Googlebook atau monitor eksternal dengan mouse dan keyboard, tata letak hanyalah sebagian dari cerita.
Keterampilan Adaptive kini dapat mengaudit UI Anda untuk menemukan dan menerapkan pintasan keyboard, kursor penunjuk kustom, status pengarahan kursor, tooltip, dan sebagainya.
Mari kita lihat penerapannya.
Saya merekam demo untuk hal ini.
Jadi, kita sudah memiliki aplikasi.
Kita bisa melihat hewan peliharaan kita.
Kita bisa mengirim pesan kepada mereka.
Kita bisa melihat video mereka.
Ya, kita bisa melihat hewan peliharaan teratas.
Namun, Anda sudah melihat bahwa hasilnya tidak bagus.
Sekarang, mari kita lihat. Saya hanya ingin melihat gambar yang memiliki unsur drama dan kucing.
Oke, saya ingin melihatnya lebih besar.
Dah, itu kembali ke beranda.
Oke, saya akan memberi tahu teman saya tentang hal ini.
Ya, jadi, "Buddy", hm.
Aplikasi ini tidak bagus.
Terlalu lebar juga.
Anda melihat bahwa ukurannya terlalu lebar.
Mari kita coba mengubah ukurannya.
Apa?
Eh, ya, oke.
Dan teks yang saya ketik hilang.
Oke, saatnya meminta Gemini untuk memperbaikinya.
Jadi, berkat kemampuan Adaptif, perintahnya bisa sesederhana "buat aplikasi ini adaptif".
Seperti yang Anda lihat, saya menggunakan Gemini 3.0 Flash yang bersama dengan keterampilan tersebut dapat melakukannya.
Alat ini mengambil keterampilan, dan sekarang mulai menjalankan perubahan.
Alat ini akan mengajukan beberapa pertanyaan, seperti, "Oh, MediaQuery bersifat eksperimental.
Do you really want to use it?"
Dan teruskan.
Saya harus mempercepatnya untuk memotong beberapa bagian, tetapi jika Anda datang ke area demo, Anda dapat mencoba keterampilan ini sendiri.
Dan ini hanyalah salah satu keterampilan yang kami rilis.
Misalnya, kami juga memiliki keterampilan baru yang akan membantu Anda membangun pengalaman stilus yang luar biasa.
Jadi, datanglah ke area tersebut untuk mencobanya.
Jadi, sudah selesai.
Perintah ini mengubah 11 file.
Mari kita lihat penerapannya.
Oke.
Grid sudah melakukan tugasnya.
Navigasi dipindahkan ke samping.
Mari kita coba filternya.
Saya rasa sekarang akan berfungsi.
Ya, tentu saja.
Oke, mari kita kirim pesan ke teman saya, dan beri tahu bahwa aplikasinya sudah bagus sekarang.
Mari kita lihat tampilannya dengan lebih banyak ruang.
Ya, aplikasi ini dimigrasikan ke tata letak daftar-detail.
Operasi ini memerlukan waktu sekitar 20 menit dan sekitar 200.000 token.
Oke.
Francesco Romano
Biasanya, saya memulai presentasi dengan metrik bisnis dan kasus keberhasilan, tetapi untuk ADS, saya ingin langsung membahas kode.
Namun, saya ingin meluangkan waktu beberapa menit untuk membagikan laba atas investasi engineering.
Saat ini, aplikasi Android Anda berpotensi berjalan di lebih dari 580 juta perangkat layar besar yang aktif, dan kami juga tahu bahwa pengguna multiperangkat dan perangkat foldable adalah salah satu segmen dengan engagement tertinggi di Android.
Mari kita lihat dua studi kasus konkret yang membuktikan apa yang terjadi saat Anda mengirimkan build adaptif.
Pertama adalah Gratitude, yang merupakan aplikasi perawatan diri dan jurnal harian populer.
Dengan mengadopsi desain layar adaptif, Gratitude menjadikan tablet dan perangkat foldable sebagai saluran akuisisi dengan pertumbuhan tinggi.
Pertama, mereka menggandakan kecepatan akuisisi di perangkat layar besar.
Penginstalan harian yang khusus berasal dari tablet dan perangkat foldable meningkat sebesar 100%.
Mereka memperluas pangsa layar besar dari keseluruhan basis pengguna mereka dari 7% menjadi 10% dari total penginstalan.
Yang kedua adalah BoxBoxClub, yang merupakan aplikasi pendamping Formula 1 dan motorsport, dan setelah mengupdate build mereka untuk perangkat foldable dan layar yang lebih besar, telemetri mereka menunjukkan peningkatan yang sangat besar: peningkatan 7,6% pada pengguna aktif secara keseluruhan dan peningkatan 54% pada waktu engagement.
Oke.
Kita sudah berada di akhir presentasi, dan saya ingin memberikan tiga poin penting.
Dengan membuat aplikasi Adaptif, aplikasi Anda dapat mendukung lebih banyak perangkat dan lebih banyak pengguna, sekaligus mempersiapkan aplikasi Anda untuk ekosistem yang terus berkembang ini.
Pengembangan Android mengutamakan Compose.
Semua alat, library, dan keterampilan baru dibuat untuk Compose.
Compose sangat cocok untuk tata letak adaptif dan pengembangan agentic.
Codebase lebih kecil dan lebih mudah dipahami, agen menggunakan lebih sedikit token saat menangani kode Compose.
Jadi, silakan coba semua alat, keterampilan, dan API baru, serta nikmati prosesnya.
Terima kasih.
<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