1. Sebelum memulai
Prasyarat
- Pengalaman membangun aplikasi Android.
- Pengalaman menggunakan Jetpack Compose.
Yang Anda perlukan
Yang akan Anda pelajari
- Dasar-dasar tata letak adaptif dan Navigation 3
- Menerapkan tarik lalu lepas
- Mendukung pintasan keyboard
- Mengaktifkan menu konteks
2. Memulai persiapan
Untuk memulai, ikuti langkah-langkah ini:
- Luncurkan Android Studio
- Klik File > New >
Project from Version control
- Tempelkan URL:
https://github.com/android/socialite.git
- Klik
Clone
Tunggu hingga project dimuat sepenuhnya.
- Buka Terminal dan jalankan:
$ git checkout codelab-adaptive-apps-start
- Jalankan sinkronisasi Gradle
Di Android Studio, pilih File > Sync Project with Gradle Files
- (opsional) Download emulator Desktop Besar
Di Android Studio, pilih Tools > Device Manager > + > Create Virtual Device > New hardware profile
Pilih Jenis Perangkat: Desktop
Ukuran layar: 14 inci
Resolusi: 1920 x 1080 piksel
Klik Finish
- Jalankan aplikasi di emulator tablet atau desktop
3. Memahami aplikasi contoh
Dalam tutorial ini, Anda akan menggunakan aplikasi chat contoh yang disebut Socialite, yang dibangun dengan Jetpack Compose.
Di aplikasi ini, Anda dapat melakukan chat dengan berbagai hewan, dan mereka akan merespons pesan Anda, masing-masing dengan caranya sendiri.
Saat ini, aplikasi ini adalah aplikasi berfokus pada perangkat seluler yang tidak dioptimalkan untuk perangkat besar seperti tablet atau desktop.
Kita akan menyesuaikan aplikasi untuk perangkat layar besar, serta menambahkan beberapa fitur untuk meningkatkan pengalaman di semua faktor bentuk.
Mari kita mulai.
4. Dasar-dasar tata letak adaptif + Navigation 3
$ git checkout codelab-adaptive-apps-step-1
Saat ini, aplikasi selalu menampilkan satu panel dalam satu waktu, berapa pun ruang layar yang tersedia.
Kita akan memperbaikinya dengan menggunakan adaptive layouts
, yang menampilkan satu atau beberapa panel bergantung pada ukuran jendela saat ini. Dalam codelab ini, kita akan menggunakan tata letak adaptif untuk menampilkan layar chat list
dan chat detail
secara otomatis secara berdampingan, jika ada cukup ruang jendela.
Tata letak adaptif dirancang untuk integrasi yang lancar ke aplikasi apa pun.
Dalam tutorial ini, kita akan berfokus pada cara menggunakannya dengan library Navigation 3, yang merupakan dasar dalam membangun aplikasi Socialite.
Dasar-dasar Navigation 3
Untuk memahami Navigation 3, mari kita mulai dengan beberapa terminologi:
- NavEntry - Beberapa konten yang ditampilkan dalam aplikasi yang dapat dinavigasi oleh pengguna. Konten ini diidentifikasi secara unik oleh kunci. NavEntry tidak harus mengisi seluruh jendela yang tersedia untuk aplikasi. Lebih dari satu NavEntry dapat ditampilkan secara bersamaan (info selengkapnya nanti).
- Kunci - ID unik untuk NavEntry. Kunci disimpan di data sebelumnya.
- Data sebelumnya - Stack kunci yang mewakili elemen NavEntry yang sebelumnya telah ditampilkan, atau saat ini sedang ditampilkan. Untuk menavigasi, tekan tombol ke dalam atau keluarkan tombol dari stack.
Di Socialite, layar pertama yang ingin kita tampilkan saat pengguna meluncurkan aplikasi adalah daftar chat. Oleh karena itu, kita membuat data sebelumnya dan melakukan inisialisasi dengan kunci yang mewakili layar tersebut.
Main.kt
// Create a new back stack
val backStack = rememberNavBackStack(ChatsList)
...
// Navigate to a particular chat
backStack.add(ChatThread(chatId = chatId))
...
// Navigate back
backStack.removeLastOrNull()
Penerapan Navigation 3
Kita akan menerapkan Navigation 3 langsung di composable titik entri Main
.
Hapus tanda komentar pada panggilan fungsi MainNavigation
untuk menghubungkan logika navigasi.
Sekarang, mari kita mulai membuat infrastruktur navigasi.
Pertama-tama, buat data sebelumnya. Ini adalah dasar dari Navigation 3.
NavDisplay
Hingga saat ini, kita telah membahas beberapa konsep Navigation 3. Namun, bagaimana library menentukan objek mana yang mewakili data sebelumnya, dan bagaimana mengubah elemennya menjadi UI yang sebenarnya?
Perkenalkan NavDisplay
. Ini adalah komponen yang menyatukan semuanya dan merender data sebelumnya. Proses ini memerlukan beberapa parameter penting. Mari kita bahas satu per satu.
Parameter 1 — Data sebelumnya
NavDisplay
memerlukan akses ke data sebelumnya untuk merender kontennya. Mari kita teruskan.
Parameter 2 — EntryProvider
EntryProvider
adalah lambda yang mengubah kunci data sebelumnya menjadi konten UI composable. Lambda ini mengambil kunci dan menampilkan NavEntry
, yang berisi konten yang akan ditampilkan, serta metadata tentang cara menampilkannya (info selengkapnya nanti).
NavDisplay
memanggil lambda ini setiap kali perlu mendapatkan konten untuk kunci tertentu. Misalnya, saat kunci baru ditambahkan ke data sebelumnya.
Saat ini, jika mengklik ikon Timeline di Socialite, kita akan melihat pesan "Unknown back stack key: Timeline".
Hal ini karena, meskipun kunci Timeline ditambahkan ke data sebelumnya, EntryProvider
tidak tahu cara merendernya, sehingga kembali ke penerapan default. Hal yang sama terjadi saat kita mengklik ikon Settings. Mari kita perbaiki dengan memastikan EntryProvider
menangani kunci data sebelumnya Timeline dan Settings dengan benar.
Parameter 3 — SceneStrategy
Parameter penting berikutnya dari NavDisplay
adalah SceneStrategy
. Parameter ini digunakan saat kita ingin menampilkan beberapa elemen NavEntry
secara bersamaan. Setiap strategi menentukan cara beberapa elemen NavEntry
ditampilkan berdampingan atau ditumpuk di atas satu sama lain.
Misalnya, jika kita menggunakan DialogSceneStrategy
dan menandai beberapa NavEntry
dengan metadata khusus, dialog akan muncul di atas konten saat ini, bukan memenuhi layar penuh.
Dalam kasus ini, kita akan menggunakan SceneStrategy yang berbeda — ListDetailSceneStrategy
. Parameter ini dirancang untuk tata letak daftar-detail kanonis.
Pertama, mari kita tambahkan parameter tersebut di konstruktor NavDisplay
.
sceneStrategy = rememberListDetailSceneStrategy(),
Sekarang kita perlu menandai ChatList
NavEntry
sebagai panel daftar, dan ChatThread
NavEntry sebagai panel detail, sehingga strategi dapat menentukan kapan kedua elemen NavEntry ini berada di data sebelumnya dan harus ditampilkan secara berdampingan.
Sebagai langkah berikutnya, tandai ChatsList
NavEntry
sebagai panel daftar.
entryProvider = { backStackKey ->
when (backStackKey) {
is ChatsList -> NavEntry(
key = backStackKey,
metadata = ListDetailSceneStrategy.listPane(),
) {
...
}
...
}
}
Demikian pula, tandai ChatThread
NavEntry
sebagai panel detail.
entryProvider = { backStackKey ->
when (backStackKey) {
is ChatThread -> NavEntry(
key = backStackKey,
metadata = ListDetailSceneStrategy.detailPane(),
) {
...
}
...
}
}
Dengan demikian, kita telah berhasil mengintegrasikan tata letak adaptif ke dalam aplikasi.
5. Menarik lalu melepas
$ git checkout codelab-adaptive-apps-step-2
Pada langkah ini, kita akan menambahkan dukungan tarik lalu lepas, yang memungkinkan pengguna menarik gambar dari aplikasi Files ke Socialite.
Tujuan kita adalah mengaktifkan tarik lalu lepas di area message list
, yang ditentukan oleh composable MessageList
, yang terletak di file ChatScreen.kt
.
Di Jetpack Compose, dukungan tarik lalu lepas diterapkan oleh pengubah dragAndDropTarget
. Kita menerapkannya ke composable yang perlu menerima item yang dilepas.
Modifier.dragAndDropTarget(
shouldStartDragAndDrop = { event ->
// condition to accept dragged item
},
target = // DragAndDropTarget
)
Pengubah memiliki dua parameter.
- Yang pertama,
shouldStartDragAndDrop
, memungkinkan composable memfilter peristiwa tarik lalu lepas. Dalam kasus ini, kita hanya ingin menerima gambar dan mengabaikan semua jenis data lainnya. - Yang kedua,
target
, adalah callback yang menentukan logika untuk menangani peristiwa tarik lalu lepas yang diterima.
Pertama, mari kita mulai dengan menambahkan dragAndDropTarget
ke composable MessageList
.
.dragAndDropTarget(
shouldStartDragAndDrop = { event ->
event.mimeTypes().any { it.startsWith("image/") }
},
target = remember {
object : DragAndDropTarget {
override fun onDrop(event: DragAndDropEvent): Boolean {
TODO("Not yet implemented")
}
}
}
),
Objek callback target
perlu menerapkan metode onDrop()
, yang menggunakan DragAndDropEvent
sebagai argumennya.
Metode ini dipanggil saat pengguna meletakkan item ke composable. Metode ini menampilkan true
jika item ditangani; false
, jika ditolak.
Setiap DragAndDropEvent
berisi objek ClipData
, yang mengenkapsulasi data yang sedang ditarik.
Data di dalam ClipData
adalah array objek Item
. Karena beberapa item dapat ditarik sekaligus, setiap Item
mewakili salah satunya.
target = remember {
object : DragAndDropTarget {
override fun onDrop(event: DragAndDropEvent): Boolean {
val clipData = event.toAndroidDragEvent().clipData
if (clipData != null && clipData.itemCount > 0) {
repeat(clipData.itemCount) { i ->
val item = clipData.getItemAt(i)
// TODO: Implement Item handling
}
return true
}
return false
}
}
}
Item
dapat berisi data dalam bentuk URI, teks, atau Intent
.
Dalam kasus ini, karena hanya menerima gambar, kita secara khusus mencari URI.
Jika Item
berisi URI, kita perlu:
- Meminta izin tarik lalu lepas untuk mengakses URI
- Menangani URI (dalam kasus ini dengan memanggil fungsi
onMediaItemAttached()
yang telah diterapkan) - Memberikan izin
override fun onDrop(event: DragAndDropEvent): Boolean {
val clipData = event.toAndroidDragEvent().clipData
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N
&& clipData != null && clipData.itemCount > 0) {
repeat(clipData.itemCount) { i ->
val item = clipData.getItemAt(i)
val passedUri = item.uri?.toString()
if (!passedUri.isNullOrEmpty()) {
val dropPermission = activity
.requestDragAndDropPermissions(
event.toAndroidDragEvent()
)
try {
val mimeType = context.contentResolver
.getType(passedUri.toUri()) ?: ""
onMediaItemAttached(MediaItem(passedUri, mimeType))
} finally {
dropPermission.release()
}
}
}
return true
}
return false
}
Pada tahap ini, tarik lalu lepas telah diterapkan sepenuhnya, dan Anda berhasil menarik foto dari aplikasi Files ke Socialite.
Mari kita buat tampilannya lebih baik dengan menambahkan batas visual untuk menandai bahwa area tersebut dapat menerima item yang dilepas.
Untuk melakukannya, kita dapat menggunakan hook tambahan yang sesuai dengan berbagai tahap sesi tarik lalu lepas:
onStarted()
: Dipanggil saat sesi tarik lalu lepas dimulai danDragAndDropTarget
ini memenuhi syarat untuk menerima item. Ini adalah tempat yang tepat untuk menyiapkan status UI untuk sesi yang masuk.onEntered()
: Dipicu saat item yang ditarik memasuki batasDragAndDropTarget
ini.onMoved()
: Dipanggil saat item yang ditarik bergerak dalam batasDragAndDropTarget
ini.onExited()
: Dipanggil saat item yang ditarik bergerak ke luar batasDragAndDropTarget
ini.onChanged()
: Dipanggil saat ada perubahan dalam sesi tarik lalu lepas saat berada dalam batas target ini — misalnya, jika tombol pengubah ditekan atau dilepaskan.onEnded()
: Dipanggil saat sesi tarik lalu lepas berakhir. SetiapDragAndDropTarget
yang sebelumnya menerima peristiwaonStarted
akan menerima peristiwa ini. Hal ini berguna untuk mereset status UI.
Untuk menambahkan batas visual, kita perlu melakukan hal berikut:
- Buat variabel boolean yang diingat yang ditetapkan ke
true
saat tarik lalu lepas dimulai, dan reset kembali kefalse
saat berakhir. - Terapkan pengubah ke composable
MessageList
yang merender batas saat variabel ini adalahtrue
override fun onEntered(event: DragAndDropEvent) {
super.onEntered(event)
isDraggedOver = true
}
override fun onEnded(event: DragAndDropEvent) {
super.onExited(event)
isDraggedOver = false
}
6. Pintasan keyboard
$ git checkout codelab-adaptive-apps-step-3
Saat menggunakan aplikasi chat di desktop, pengguna mengharapkan pintasan keyboard yang sudah dikenal — seperti mengirim pesan dengan tombol Enter.
Pada langkah ini, kita akan menambahkan perilaku tersebut ke aplikasi.
Peristiwa keyboard di Compose ditangani dengan pengubah.
Ada dua pengubah utama:
onPreviewKeyEvent
- menangkap peristiwa keyboard sebelum ditangani oleh elemen yang difokuskan. Sebagai bagian dari penerapan, kita memutuskan apakah akan menyebarkan peristiwa lebih lanjut atau menggunakannya.onKeyEvent
- menangkap peristiwa keyboard setelah ditangani oleh elemen yang difokuskan. Peristiwa ini hanya dipicu jika pengendali lain tidak menggunakan peristiwa.
Dalam kasus ini, menggunakan onKeyEvent
pada TextField
tidak akan berfungsi, karena pengendali default menggunakan peristiwa tombol Enter — dan memindahkan kursor ke baris baru.
.onPreviewKeyEvent { keyEvent ->
//TODO: implement key event handling
},
Lambda di dalam pengubah akan dipanggil dua kali untuk setiap penekanan tombol — saat pengguna menekan tombol dan sekali saat pengguna melepaskannya.
Kita dapat menentukannya dengan memeriksa properti type
dari objek KeyEvent
. Objek peristiwa juga mengekspos flag pengubah, termasuk:
isAltPressed
isCtrlPressed
isMetaPressed
isShiftPressed
Menampilkan true
dari lambda akan memberi tahu Compose bahwa kode kita telah menangani peristiwa tombol dan mencegah perilaku default, seperti menyisipkan baris baru.
Sekarang, terapkan pengubah onPreviewKeyEvent
. Periksa apakah peristiwa sesuai dengan tombol Enter yang ditekan dan tidak ada pengubah shift, alt, ctrl, atau meta yang diterapkan. Kemudian, panggil fungsi onSendClick()
.
.onPreviewKeyEvent { keyEvent ->
if (keyEvent.key == Key.Enter && keyEvent.type == KeyEventType.KeyDown
&& keyEvent.isShiftPressed == false
&& keyEvent.isAltPressed == false
&& keyEvent.isCtrlPressed == false
&& keyEvent.isMetaPressed == false) {
onSendClick()
true
} else {
false
}
},
7. Menu konteks
$ git checkout codelab-adaptive-apps-step-4
Menu konteks adalah bagian penting dari UI adaptif.
Pada langkah ini, kita akan menambahkan menu pop-up Reply yang muncul saat pengguna mengklik kanan pesan.
Ada banyak gestur berbeda yang didukung secara default, misalnya pengubah clickable
memungkinkan deteksi klik dengan mudah.
Untuk gestur kustom, seperti klik kanan, kita dapat menggunakan pengubah pointerInput
, yang memberi kita akses ke peristiwa pointer mentah dan kontrol penuh atas deteksi gestur.
Pertama, mari kita tambahkan UI yang akan merespons klik kanan. Dalam kasus ini, kita ingin menampilkan DropdownMenu
dengan satu item: tombol Reply. Kita memerlukan 2 variabel remember
:
rightClickOffset
menyimpan posisi klik sehingga kita dapat memindahkan tombol Reply di dekat kursorisMenuVisible
untuk mengontrol apakah akan menampilkan atau menyembunyikan tombol Reply
Nilainya akan diperbarui sebagai bagian dari penanganan gestur klik kanan.
Kita juga perlu menggabungkan composable pesan dalam Box
, sehingga DropdownMenu
dapat muncul sebagai tumpukan di atasnya.
@Composable
internal fun MessageBubble(
...
) {
var rightClickOffset by remember { mutableStateOf<DpOffset>(DpOffset.Zero) }
var isMenuVisible by remember { mutableStateOf(false) }
val density = LocalDensity.current
Box(
modifier = Modifier
.pointerInput(Unit) {
// TODO: Implement right click handling
}
.then(modifier),
) {
AnimatedVisibility(isMenuVisible) {
DropdownMenu(
expanded = true,
onDismissRequest = { isMenuVisible = false },
offset = rightClickOffset,
) {
DropdownMenuItem(
text = { Text("Reply") },
onClick = {
// Custom Reply functionality
},
)
}
}
MessageBubbleSurface(
...
) {
...
}
}
}
Sekarang, mari kita terapkan pengubah pointerInput
. Pertama, kita akan menambahkan awaitEachGesture
, yang memulai cakupan baru setiap kali pengguna memulai gestur baru. Di dalam cakupan tersebut, kita perlu:
- Mendapatkan peristiwa pointer berikutnya —
awaitPointerEvent()
menyediakan objek yang mewakili peristiwa pointer - Memfilter penekanan klik kanan murni — kita memeriksa bahwa hanya tombol sekunder yang ditekan
- Mengambil posisi klik — ambil posisi dalam piksel dan konversikan ke
DpOffset
sehingga penempatan menu tidak bergantung pada DPI - Menampilkan menu — tetapkan
isMenuVisible
=true
dan simpan offset sehinggaDropdownMenu
muncul tepat di tempat pointer berada - Menggunakan peristiwa — memanggil
consume()
pada penekanan dan rilis yang cocok, sehingga mencegah pengendali lain bereaksi
.pointerInput(Unit) {
awaitEachGesture { // Start listening for pointer gestures
val event = awaitPointerEvent()
if (
event.type == PointerEventType.Press
&& !event.buttons.isPrimaryPressed
&& event.buttons.isSecondaryPressed
&& !event.buttons.isTertiaryPressed
// all pointer inputs just went down
&& event.changes.fastAll { it.changedToDown() }
) {
// Get the pressed pointer info
val press = event.changes.find { it.pressed }
if (press != null) {
// Convert raw press coordinates (px) to dp for positioning the menu
rightClickOffset = with(density) {
isMenuVisible = true // Show the context menu
DpOffset(
press.position.x.toDp(),
press.position.y.toDp()
)
}
}
// Consume the press event so it doesn't propagate further
event.changes.forEach {
it.consume()
}
// Wait for the release and consume it as well
waitForUpOrCancellation()?.consume()
}
}
}
8. Selamat
Selamat! Anda berhasil memigrasikan aplikasi ke Navigation 3 dan menambahkan:
- Tata Letak Adaptif
- Menarik lalu melepas
- Pintasan keyboard
- Menu konteks
Ini adalah fondasi yang kokoh untuk membangun aplikasi yang sepenuhnya adaptif.
Pelajari lebih lanjut