Cara Membuat Infinite Scroll dengan Kotlin: Panduan Lengkap di Android

Dalam pengembangan aplikasi Android modern, efisiensi dalam menampilkan data dalam jumlah besar adalah kunci pengalaman pengguna yang baik. Bayangkan aplikasi media sosial atau e-commerce; Anda tidak mungkin memuat semua postingan atau produk sekaligus. Di sinilah konsep Infinite Scroll atau endless scrolling menjadi sangat relevan.

Infinite scroll memungkinkan pengguna untuk memuat lebih banyak konten secara otomatis saat mereka menggulir ke bawah, tanpa perlu menekan tombol “Muat Lebih Banyak”. Ini adalah pola UI yang sering kita lihat di aplikasi populer seperti Instagram, Twitter, atau TikTok. Dengan Kotlin di Android, implementasi infinite scroll bisa dilakukan dengan relatif mudah, asalkan kita memahami konsep di baliknya.

Sebagai developer, saya seringkali dihadapkan pada skenario di mana data dari API bersifat paginated. Menggunakan infinite scroll bukan hanya meningkatkan UX, tapi juga efisiensi resource di sisi client dan server. Artikel ini akan memandu Anda langkah demi langkah cara mengimplementasikan infinite scroll di aplikasi Android menggunakan Kotlin, lengkap dengan praktik terbaik dan masalah umum yang sering saya temui di lapangan.

Apa itu Infinite Scroll dan Mengapa Penting?

Infinite scroll adalah teknik di mana konten baru dimuat secara dinamis dan ditambahkan ke bagian bawah daftar (biasanya RecyclerView) saat pengguna mendekati akhir daftar yang sudah ada. Konsep utamanya adalah memuat data secara bertahap atau per halaman (pagination) dari server, bukan memuat semuanya sekaligus.

Mengapa Infinite Scroll Penting?

  • User Experience (UX) yang Lebih Baik: Pengguna dapat terus-menerus mengonsumsi konten tanpa interupsi, memberikan aliran informasi yang mulus.
  • Performa Aplikasi: Memuat data dalam jumlah kecil secara bertahap mengurangi beban memori dan pemrosesan awal aplikasi, membuat aplikasi terasa lebih cepat dan responsif.
  • Efisiensi Jaringan: Hanya data yang dibutuhkan yang diambil, mengurangi penggunaan bandwidth dan mempercepat waktu respons API.
  • Optimasi Server: Server tidak perlu mengirimkan seluruh dataset dalam satu respons, yang bisa sangat besar dan membebani server.

Dalam praktiknya, implementasi infinite scroll memerlukan penanganan state yang cermat, seperti kapan harus memuat data, apakah sedang memuat, dan apakah sudah mencapai akhir data. Kesalahan dalam penanganan ini bisa menyebabkan data duplikat atau bahkan crash aplikasi.

Prasyarat

Sebelum kita memulai, pastikan Anda memiliki:

  • Android Studio: Versi terbaru direkomendasikan.
  • Kotlin: Pemahaman dasar tentang Kotlin dan pengembangan Android.
  • RecyclerView: Pemahaman cara kerja RecyclerView, Adapter, dan LayoutManager.
  • Gradle: Pemahaman dasar tentang konfigurasi dependensi Gradle.

Kita akan menggunakan RecyclerView karena ini adalah komponen terbaik di Android untuk menampilkan daftar data yang efisien dan bisa di-scroll.

Langkah 1: Setup Proyek Android Baru

Buka Android Studio dan buat proyek baru dengan template “Empty Activity”. Pastikan bahasa yang dipilih adalah Kotlin. Beri nama proyek Anda, misalnya “InfiniteScrollApp”.

Tambahkan dependensi RecyclerView ke file build.gradle.kts (Module :app) Anda:


dependencies {
    // ... dependensi lainnya
    implementation("androidx.recyclerview:recyclerview:1.3.2") // Atau versi terbaru
}

Jangan lupa untuk melakukan “Sync Project with Gradle Files” setelah menambahkan dependensi.

Langkah 2: Siapkan Data Source (Simulasi API)

Untuk tutorial ini, kita akan mensimulasikan panggilan API yang mengembalikan daftar item per halaman. Dalam proyek nyata, ini akan menjadi panggilan HTTP ke backend Anda. Kita akan menggunakan Handler dan postDelayed untuk mensimulasikan latensi jaringan.

Buat sebuah class baru bernama DataSource:


// DataSource.kt
package com.tubianto.infinitescrollapp

import android.os.Handler
import android.os.Looper

object DataSource {

    private const val ITEMS_PER_PAGE = 20
    private const val TOTAL_ITEMS = 100 // Total item yang tersedia di "server"
    private const val API_LATENCY_MS = 1500L // Simulasi delay API

    fun getItems(page: Int, callback: (List<String>?, Boolean) -> Unit) {
        Handler(Looper.getMainLooper()).postDelayed({
            val start = (page - 1) * ITEMS_PER_PAGE
            val end = (start + ITEMS_PER_PAGE).coerceAtMost(TOTAL_ITEMS)

            if (start >= TOTAL_ITEMS) {
                // Tidak ada item lagi
                callback(emptyList(), true)
                return@postDelayed
            }

            val items = (start until end).map { "Item Nomor ${it + 1}" }
            val isLastPage = end == TOTAL_ITEMS

            callback(items, isLastPage)
        }, API_LATENCY_MS)
    }
}

Dalam DataSource ini:

  • Kita mensimulasikan total 100 item.
  • Setiap “panggilan API” mengembalikan 20 item.
  • Ada delay 1.5 detik untuk mensimulasikan latensi jaringan.
  • Fungsi getItems menerima nomor halaman dan callback yang akan dipanggil setelah data siap. Callback ini juga mengembalikan boolean isLastPage untuk memberi tahu apakah sudah mencapai halaman terakhir.

Langkah 3: Buat Model Data

Untuk kesederhanaan, kita akan menggunakan String sebagai item data kita. Namun, dalam aplikasi nyata, Anda akan memiliki data class yang lebih kompleks.

Kita juga akan membutuhkan model untuk menampilkan indikator loading. Jadi, kita akan membuat sebuah sealed class atau interface untuk merepresentasikan dua tipe item di RecyclerView: item data dan item loading.


// MyItem.kt
package com.tubianto.infinitescrollapp

sealed class MyItem {
    data class DataItem(val text: String) : MyItem()
    object LoadingItem : MyItem()
}

Langkah 4: Implementasi RecyclerView Adapter

Adapter akan bertanggung jawab untuk menampilkan data di RecyclerView. Kita perlu mendukung dua tipe tampilan: item data dan item loading.


// MyAdapter.kt
package com.tubianto.infinitescrollapp

import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ProgressBar
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView

class MyAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {

    private val items: MutableList<MyItem> = mutableListOf()

    companion object {
        private const val VIEW_TYPE_DATA = 0
        private const val VIEW_TYPE_LOADING = 1
    }

    fun addItems(newItems: List<MyItem.DataItem>) {
        val startPosition = items.size
        items.addAll(newItems)
        notifyItemRangeInserted(startPosition, newItems.size)
    }

    fun addLoadingFooter() {
        if (!isLoadingAdded()) {
            items.add(MyItem.LoadingItem)
            notifyItemInserted(items.size - 1)
        }
    }

    fun removeLoadingFooter() {
        if (isLoadingAdded()) {
            val position = items.size - 1
            if (position >= 0 && items[position] is MyItem.LoadingItem) {
                items.removeAt(position)
                notifyItemRemoved(position)
            }
        }
    }

    fun isLoadingAdded(): Boolean {
        return items.isNotEmpty() && items.last() is MyItem.LoadingItem
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        return if (viewType == VIEW_TYPE_DATA) {
            val view = LayoutInflater.from(parent.context).inflate(android.R.layout.simple_list_item_1, parent, false)
            DataViewHolder(view)
        } else {
            val view = LayoutInflater.from(parent.context).inflate(R.layout.item_loading, parent, false)
            LoadingViewHolder(view)
        }
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        if (getItemViewType(position) == VIEW_TYPE_DATA) {
            (holder as DataViewHolder).bind(items[position] as MyItem.DataItem)
        }
        // No binding needed for LoadingViewHolder
    }

    override fun getItemCount(): Int = items.size

    override fun getItemViewType(position: Int): Int {
        return if (items[position] is MyItem.DataItem) VIEW_TYPE_DATA else VIEW_TYPE_LOADING
    }

    inner class DataViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        private val textView: TextView = itemView.findViewById(android.R.id.text1)

        fun bind(item: MyItem.DataItem) {
            textView.text = item.text
        }
    }

    inner class LoadingViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        // ProgressBar will be visible by default
    }
}

Kita juga perlu layout untuk LoadingViewHolder. Buat file item_loading.xml di folder res/layout/:


<!-- res/layout/item_loading.xml -->
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:gravity="center"
    android:padding="8dp"
    android:orientation="vertical">

    <ProgressBar
        android:id="@+id/progressBar"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Memuat lebih banyak..."
        android:layout_marginTop="8dp"/>

</LinearLayout>

Poin penting di MyAdapter:

  • Mendukung dua VIEW_TYPE (data dan loading).
  • Fungsi addItems untuk menambahkan data baru.
  • Fungsi addLoadingFooter dan removeLoadingFooter untuk menampilkan/menyembunyikan indikator loading di bagian bawah daftar.
  • isLoadingAdded() untuk memeriksa apakah footer loading sudah ada.

Langkah 5: Implementasi Infinite Scroll Logic di Activity/Fragment

Sekarang, saatnya mengimplementasikan logika infinite scroll di MainActivity.kt (atau Fragment Anda).

Pertama, modifikasi activity_main.xml untuk menambahkan RecyclerView:


<!-- activity_main.xml -->
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

Kemudian, implementasikan logika di MainActivity.kt:


// MainActivity.kt
package com.tubianto.infinitescrollapp

import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.tubianto.infinitescrollapp.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private lateinit var myAdapter: MyAdapter
    private lateinit var layoutManager: LinearLayoutManager

    private var currentPage = 1
    private var isLoading = false
    private var isLastPage = false
    private val PAGE_SIZE = 20 // Harus sama dengan ITEMS_PER_PAGE di DataSource

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        setupRecyclerView()
        loadNextPage() // Muat halaman pertama
    }

    private fun setupRecyclerView() {
        myAdapter = MyAdapter()
        layoutManager = LinearLayoutManager(this)
        binding.recyclerView.layoutManager = layoutManager
        binding.recyclerView.adapter = myAdapter

        binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)

                val visibleItemCount = layoutManager.childCount
                val totalItemCount = layoutManager.itemCount
                val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()

                if (!isLoading && !isLastPage) {
                    if ((visibleItemCount + firstVisibleItemPosition) >= totalItemCount
                        && firstVisibleItemPosition >= 0
                        && totalItemCount >= PAGE_SIZE // Pastikan ada cukup item untuk digulir
                    ) {
                        loadNextPage()
                    }
                }
            }
        })
    }

    private fun loadNextPage() {
        if (isLastPage) return

        isLoading = true
        myAdapter.addLoadingFooter() // Tampilkan footer loading

        Log.d("InfiniteScroll", "Memuat halaman $currentPage")

        DataSource.getItems(currentPage) { items, lastPage ->
            isLoading = false
            myAdapter.removeLoadingFooter() // Sembunyikan footer loading

            items?.let {
                val dataItems = it.map { text -> MyItem.DataItem(text) }
                myAdapter.addItems(dataItems)
            }

            if (lastPage) {
                isLastPage = true
                Log.d("InfiniteScroll", "Sudah mencapai halaman terakhir.")
            } else {
                currentPage++
            }
        }
    }
}

Penjelasan Logika Infinite Scroll:

  • currentPage: Melacak halaman yang sedang dimuat.
  • isLoading: Bendera untuk mencegah pemuatan data ganda saat permintaan API sedang berlangsung.
  • isLastPage: Bendera untuk memberi tahu jika semua data sudah dimuat dan tidak ada halaman lagi.
  • addOnScrollListener: Ini adalah inti dari infinite scroll. Listener ini mendeteksi saat pengguna menggulir.
  • Di dalam onScrolled, kita memeriksa beberapa kondisi:
    • Apakah sedang tidak loading (!isLoading)?
    • Apakah belum mencapai halaman terakhir (!isLastPage)?
    • Apakah item yang terlihat ditambah posisi item pertama yang terlihat sudah mencapai atau melebihi total item? Ini berarti pengguna mendekati akhir daftar.
    • Apakah posisi item pertama yang terlihat valid (>= 0)?
    • Apakah total item saat ini lebih besar dari atau sama dengan ukuran satu halaman (PAGE_SIZE)? Ini penting untuk mencegah pemuatan ganda pada daftar yang sangat kecil di awal.
  • Jika semua kondisi terpenuhi, fungsi loadNextPage() dipanggil.
  • loadNextPage():
    • Mengatur isLoading = true.
    • Menambahkan LoadingItem ke adapter untuk menampilkan indikator.
    • Memanggil DataSource.getItems().
    • Setelah data diterima, isLoading diatur kembali ke false.
    • LoadingItem dihapus dari adapter.
    • Data baru ditambahkan ke adapter.
    • Memperbarui isLastPage dan currentPage berdasarkan respons dari DataSource.

Masalah yang Sering Terjadi

Sebagai seorang praktisi, saya sering menemukan beberapa masalah umum saat mengimplementasikan infinite scroll. Berikut adalah beberapa di antaranya dan cara mengatasinya:

1. Loader Muncul Terus-menerus atau Tidak Pernah Hilang

Gejala: Indikator loading muncul berulang kali meskipun tidak ada data baru, atau tetap terlihat bahkan setelah data dimuat.

Penyebab: Seringkali ini karena kesalahan dalam penanganan bendera isLoading. Jika isLoading tidak diatur kembali ke false setelah panggilan API selesai (baik berhasil maupun gagal), logika scroll akan selalu berpikir bahwa ia sedang memuat.

Solusi: Pastikan isLoading = false dipanggil di dalam callback API Anda, terlepas dari apakah panggilan tersebut berhasil atau gagal. Juga, pastikan removeLoadingFooter() dipanggil setelah data diterima atau terjadi error.

2. Data Duplikat atau Item yang Salah

Gejala: Saat menggulir, item-item yang sama muncul lagi, atau urutan data menjadi kacau.

Penyebab: Ini biasanya terjadi karena masalah dengan logika pagination di sisi client atau server, atau cara Anda menambahkan data ke adapter. Misalnya, nomor halaman yang salah dikirim ke API, atau adapter menambahkan data ke posisi yang salah.

Solusi:

  • Verifikasi bahwa currentPage selalu dikirim dengan benar ke API dan diinkrementasi hanya setelah data berhasil dimuat.
  • Pastikan API Anda mengembalikan data unik per halaman.
  • Gunakan DiffUtil di RecyclerView.Adapter Anda jika memungkinkan untuk mengelola perubahan daftar secara efisien dan mencegah masalah duplikasi saat item diperbarui atau ditambahkan. Meskipun kita tidak menggunakannya di sini untuk kesederhanaan, ini adalah praktik terbaik untuk performa.

3. Performa Buruk atau Jeda Saat Memuat Data Baru

Gejala: Aplikasi terasa lambat atau mengalami “jeda” sesaat saat data baru dimuat.

Penyebab: Meskipun infinite scroll dirancang untuk performa, implementasi yang tidak optimal bisa menyebabkan masalah. Beberapa penyebab umum adalah:

  • Operasi UI berat di thread utama saat data dimuat atau diproses.
  • Layout item yang kompleks yang memerlukan banyak perhitungan saat di-inflate.
  • Menggunakan notifyDataSetChanged() secara berlebihan, yang memaksa seluruh RecyclerView digambar ulang.

Solusi:

  • Pastikan semua operasi jaringan dan pemrosesan data dilakukan di thread background. Kotlin Coroutines atau RxJava sangat membantu di sini.
  • Optimalkan layout item Anda agar seringkas mungkin.
  • Gunakan metode notifikasi adapter yang lebih spesifik seperti notifyItemRangeInserted(), notifyItemInserted(), atau lebih baik lagi, DiffUtil dengan ListAdapter untuk pembaruan yang efisien.

4. Memuat Terlalu Cepat atau Terlalu Lambat

Gejala: Data baru dimuat terlalu dini (sebelum pengguna mencapai akhir) atau terlalu terlambat (pengguna melihat bagian kosong).

Penyebab: Angka threshold untuk mendeteksi “mendekati akhir” daftar tidak optimal.

Solusi: Sesuaikan kondisi (visibleItemCount + firstVisibleItemPosition) >= totalItemCount - THRESHOLD di onScrolled. Misalnya, Anda bisa menambahkan margin agar data dimuat saat pengguna masih 5-10 item dari akhir daftar. Ini memberikan pengalaman yang lebih mulus.

Pengalaman dan Pertimbangan Praktis

Setelah bertahun-tahun mengembangkan aplikasi dengan infinite scroll, ada beberapa pertimbangan yang selalu saya tekankan:

Kapan Menggunakan Infinite Scroll dan Kapan Tidak

  • Gunakan Ketika: Anda memiliki daftar data yang sangat panjang dan tidak terstruktur secara hierarkis (misalnya, feed berita, daftar produk, riwayat chat). Pengguna cenderung ingin terus-menerus mengonsumsi konten.
  • Jangan Gunakan Ketika:
    • Anda memiliki daftar yang relatif pendek dan terbatas.
    • Pengguna perlu mencapai bagian footer untuk melihat informasi penting (misalnya, syarat dan ketentuan, tombol “checkout” di e-commerce). Infinite scroll bisa menghalangi akses ke footer.
    • Pengguna perlu melompat antar halaman atau mencari item berdasarkan nomor halaman. Untuk kasus ini, pagination tradisional mungkin lebih baik.

Penanganan Error dan State Kosong

Bagaimana jika panggilan API gagal? Atau tidak ada data sama sekali? Penting untuk menampilkan UI yang sesuai (misalnya, pesan error, tombol “coba lagi”, atau teks “Tidak ada data”). Dalam contoh ini, kita tidak menanganinya secara eksplisit, namun di aplikasi nyata, Anda harus menambahkan logika ini di loadNextPage().

Caching Data

Untuk pengalaman yang lebih baik, pertimbangkan untuk menyimpan data yang sudah dimuat (misalnya, di Room database atau shared preferences) sehingga aplikasi dapat menampilkan sebagian data bahkan saat offline atau saat memuat ulang.

Optimasi Memori dan RecyclerView

Untuk daftar yang sangat panjang, pastikan Anda menggunakan RecyclerView dengan optimal. Ini termasuk:

  • View Holder Pattern: Otomatis diimplementasikan oleh RecyclerView.
  • DiffUtil atau ListAdapter: Untuk pembaruan daftar yang efisien dan meminimalkan gambar ulang.
  • Menghindari kebocoran memori: Pastikan Anda melepaskan listener atau referensi yang tidak diperlukan saat Activity/Fragment dihancurkan.

Ketersediaan Data di Awal

Beberapa developer cenderung memuat data pertama di awal (misalnya, halaman 1) saat aplikasi dibuka, kemudian menggunakan infinite scroll untuk halaman berikutnya. Ini adalah pendekatan yang baik untuk memberikan konten instan kepada pengguna.

FAQ

Q: Apakah logika infinite scroll ini bisa diterapkan di Jetpack Compose?

A: Tentu saja! Konsepnya sama. Di Jetpack Compose, Anda akan menggunakan LazyColumn atau LazyRow, dan mengelola state pemuatan (isLoading, isLastPage, currentPage) di ViewModel. Anda kemudian akan memicu pemuatan data saat item terakhir dalam daftar terlihat atau hampir terlihat, mirip dengan deteksi scroll di RecyclerView.

Q: Berapa jumlah item ideal per halaman (PAGE_SIZE)?

A: Tidak ada angka ajaib, ini sangat tergantung pada kompleksitas item Anda dan latensi API. Umumnya, angka antara 10 hingga 50 item per halaman adalah praktik yang baik. Untuk item yang sangat ringan (teks saja), Anda bisa menaikkannya. Untuk item yang kompleks (gambar resolusi tinggi, banyak data), pertahankan di angka yang lebih rendah. Tujuannya adalah menyeimbangkan antara frekuensi panggilan API dan jumlah data yang dimuat per panggilan.

Q: Bagaimana jika API saya tidak mendukung pagination?

A: Jika API Anda tidak mendukung pagination, Anda harus mempertimbangkan untuk memuat seluruh data sekali dan kemudian melakukan pagination di sisi client. Namun, ini tidak disarankan untuk dataset yang sangat besar karena bisa membebani memori dan menyebabkan kinerja buruk. Alternatifnya adalah meminta perubahan pada API agar mendukung pagination.

Q: Apa alternatif dari Infinite Scroll?

A: Alternatif utama adalah pagination tradisional dengan tombol “Berikutnya” atau nomor halaman. Ini cocok untuk daftar yang membutuhkan navigasi spesifik antar halaman atau jika Anda ingin pengguna melihat footer. Ada juga pendekatan “Load More” di mana ada tombol di akhir daftar yang harus diklik pengguna untuk memuat lebih banyak item.

Kesimpulan

Infinite scroll adalah fitur powerful yang dapat secara signifikan meningkatkan pengalaman pengguna di aplikasi Android Anda, terutama untuk daftar data yang dinamis dan berpotensi sangat panjang. Dengan memahami prinsip-prinsip pagination, manajemen state (isLoading, isLastPage), dan implementasi RecyclerView.OnScrollListener yang tepat, Anda bisa membangun fitur ini dengan kokoh.

Meskipun terlihat sederhana, saya sering melihat developer baru terjebak pada detail-detail kecil seperti penanganan isLoading yang salah atau pembaruan adapter yang tidak efisien. Ingatlah untuk selalu mengutamakan performa dan pengalaman pengguna, serta mengantisipasi berbagai skenario (seperti error atau data kosong) untuk aplikasi yang tangguh. Dengan panduan ini, Anda sekarang memiliki fondasi yang kuat untuk mengimplementasikan infinite scroll di proyek Kotlin Android Anda.

TAGS: Infinite Scroll, Kotlin, Android Development, RecyclerView, Pagination, Android Tutorial, Developer Workflow, Programming, Mobile App


Baca Juga

You May Also Like

Tinggalkan Balasan

Alamat email Anda tidak akan dipublikasikan. Ruas yang wajib ditandai *