Aplikasi adaptif

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:

  1. Luncurkan Android Studio
  2. Klik File > New > Project from Version control
  3. Tempelkan URL:
https://github.com/android/socialite.git
  1. Klik Clone

Tunggu hingga project dimuat sepenuhnya.

  1. Buka Terminal dan jalankan:
$ git checkout codelab-adaptive-apps-start
  1. Jalankan sinkronisasi Gradle

Di Android Studio, pilih File > Sync Project with Gradle Files

  1. (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

  1. 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. e9e4541f0f76d669.png

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.

c549fd9fa64589e9.gif

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.

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()

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.

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".

532134900a30c9c.gif

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.

78fe1bb6689c9b93.gif

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:

  1. Meminta izin tarik lalu lepas untuk mengakses URI
  2. Menangani URI (dalam kasus ini dengan memanggil fungsi onMediaItemAttached() yang telah diterapkan)
  3. 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:

  1. onStarted() : Dipanggil saat sesi tarik lalu lepas dimulai dan DragAndDropTarget ini memenuhi syarat untuk menerima item. Ini adalah tempat yang tepat untuk menyiapkan status UI untuk sesi yang masuk.
  2. onEntered() : Dipicu saat item yang ditarik memasuki batas DragAndDropTarget ini.
  3. onMoved() : Dipanggil saat item yang ditarik bergerak dalam batas DragAndDropTarget ini.
  4. onExited() : Dipanggil saat item yang ditarik bergerak ke luar batas DragAndDropTarget ini.
  5. onChanged() : Dipanggil saat ada perubahan dalam sesi tarik lalu lepas saat berada dalam batas target ini — misalnya, jika tombol pengubah ditekan atau dilepaskan.
  6. onEnded() : Dipanggil saat sesi tarik lalu lepas berakhir. Setiap DragAndDropTarget yang sebelumnya menerima peristiwa onStarted akan menerima peristiwa ini. Hal ini berguna untuk mereset status UI.

Untuk menambahkan batas visual, kita perlu melakukan hal berikut:

  1. Buat variabel boolean yang diingat yang ditetapkan ke true saat tarik lalu lepas dimulai, dan reset kembali ke false saat berakhir.
  2. Terapkan pengubah ke composable MessageList yang merender batas saat variabel ini adalah true
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.

d9d30ae7e0230422.gif

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 kursor
  • isMenuVisible 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:

  1. Mendapatkan peristiwa pointer berikutnyaawaitPointerEvent() menyediakan objek yang mewakili peristiwa pointer
  2. Memfilter penekanan klik kanan murni — kita memeriksa bahwa hanya tombol sekunder yang ditekan
  3. Mengambil posisi klik — ambil posisi dalam piksel dan konversikan ke DpOffset sehingga penempatan menu tidak bergantung pada DPI
  4. Menampilkan menu — tetapkan isMenuVisible = true dan simpan offset sehingga DropdownMenu muncul tepat di tempat pointer berada
  5. 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