Widget di Android. Fitur yang jarang ditemukan

Halo, Habr! Nama saya Alexander Khakimov, saya seorang pengembang android di FINCH.

Apakah kebetulan desain Anda untuk iOS, dan Anda harus menyesuaikannya untuk android? Jika demikian, apakah desainer Anda sering menggunakan widget? Sayangnya, widget adalah kasus yang jarang terjadi bagi banyak pengembang, karena jarang ada orang yang bekerja dengannya

.

Pembuatan widget


Untuk membuat widget, Anda perlu tahu:

  1. Fitur komponen widget.
  2. Fitur menampilkan widget di kotak layar.
  3. Fitur pembaruan widget.

Kami akan menganalisis setiap item secara terpisah.

Fitur komponen widget


Pengembang mana pun yang telah bekerja dengan RemoteViews setidaknya satu kali sudah familiar dengan item ini. Jika Anda salah satunya, jangan ragu untuk beralih ke poin berikutnya.

RemoteViews dirancang untuk menggambarkan dan mengelola hierarki Tampilan yang merupakan bagian dari proses di aplikasi lain. Menggunakan manajemen hierarki, Anda dapat mengubah properti atau memanggil metode yang dimiliki oleh View, yang merupakan bagian dari aplikasi lain. RemoteViews mencakup sekumpulan komponen terbatas dari pustaka komponen android.widget standar.

Lihat di dalam widget berfungsi dalam proses terpisah (biasanya ini adalah layar beranda), oleh karena itu, untuk mengubah UI widget, gunakan ekstensi BroadcastReceiver - AppWidgetProvider, yang berfungsi di aplikasi kami.

Fitur menampilkan widget di "kisi" layar


Sebenarnya, poin ini tidak begitu rumit, jika Anda melihat pedoman resmi :
Setiap widget harus menetapkan minWidth dan minHeight, yang menunjukkan jumlah ruang minimum yang harus dikonsumsi secara default. Saat pengguna menambahkan widget ke layar Beranda, umumnya akan menempati lebih dari lebar minimum dan tinggi yang Anda tentukan. Layar Beranda Android menawarkan pengguna kotak ruang yang tersedia tempat mereka dapat menempatkan widget dan ikon. Kisi ini dapat bervariasi menurut perangkat; misalnya, banyak handset menawarkan kisi 4x4, dan tablet dapat menawarkan kisi yang lebih besar, 8x7.

Menerjemahkan ke dalam bahasa Rusia: setiap widget harus mengatur lebar minimum dan tinggi sendiri untuk menunjukkan ruang minimum yang akan ditempati secara default.

gambar
Contoh pengaturan widget saat membuat di Android Studio

Widget yang ditambahkan ke layar Beranda biasanya akan memakan lebih banyak ruang daripada lebar minimum dan tinggi layar yang Anda tetapkan. Layar Beranda Android memberi pengguna kisi ruang yang tersedia tempat widget dan ikon dapat ditemukan. Kisi ini dapat beragam menurut perangkat; misalnya, banyak ponsel menawarkan kisi 4x4, dan tablet dapat menawarkan kisi 8x4 besar.

Dari sini menjadi jelas bahwa kisi perangkat dapat berupa apa saja, dan ukuran sel dapat bervariasi, tergantung pada ukuran kisi. Dengan demikian, konten widget harus dirancang dengan mempertimbangkan fitur-fitur ini.

Lebar minimum dan tinggi widget untuk jumlah kolom dan baris tertentu dapat dihitung menggunakan rumus:

minSideSizeDp = 70 ร— n - 30, di mana n adalah jumlah baris atau kolom

. Saat ini, kisi minimum maksimum yang dapat Anda atur adalah 4x4. Ini memastikan bahwa widget Anda akan ditampilkan di semua perangkat.

Fitur pembaruan widget


Karena AppWidgetProvider pada dasarnya merupakan perpanjangan dari BroadcastReceiver, Anda dapat melakukan hal yang sama dengannya seperti dengan BroadcastReceiver biasa. AppWidgetProvider hanya mem-parsing bidang yang sesuai dari Intent yang diterima di onReceive dan memanggil metode intersepsi dengan ekstra yang diterima.

Kesulitan muncul dengan frekuensi memperbarui konten - intinya adalah perbedaan dalam operasi internal widget di iOS dan Android. Faktanya adalah bahwa data pada widget iOS diperbarui ketika widget menjadi terlihat oleh pengguna. Di Android, acara seperti itu tidak ada. Kami tidak dapat mengetahui kapan pengguna melihat widget.

Untuk widget di Android, metode pembaruan yang disarankan adalah pembaruan timer. Pengaturan timer diatur oleh update parameter widgetPeriodMillis. Sayangnya, pengaturan ini tidak memungkinkan memperbarui widget lebih dari sekali setiap 30 menit. Di bawah ini saya akan membicarakan hal ini lebih terinci.

Kasing widget


Selanjutnya kita akan berbicara tentang kasus yang kita miliki di FINCH dalam aplikasi lotre besar dengan aplikasi Stoloto untuk partisipasi dalam lotere negara.

Tugas aplikasi ini adalah untuk menyederhanakan dan membuat transparan bagi pengguna pilihan lotre dan pembelian tiket. Oleh karena itu, fungsi widget yang diperlukan cukup sederhana: perlihatkan game yang direkomendasikan pengguna untuk dibeli dan ketuk untuk pergi ke yang sesuai. Daftar gim ditentukan di server dan diperbarui secara berkala.

Dalam kasus kami, desain widget menyertakan dua status:

  • Untuk pengguna yang berwenang
  • Untuk pengguna yang tidak sah

Seorang pengguna yang berwenang perlu menunjukkan data profilnya: status dompet internal, jumlah tiket yang menunggu undian dan jumlah kemenangan yang hilang. Untuk masing-masing elemen ini, transisi ke layar di dalam aplikasi disediakan, berbeda dari yang lain.

gambar

gambar

Seperti yang mungkin Anda perhatikan, fitur lain untuk pengguna yang berwenang adalah tombol "segarkan", tetapi lebih lanjut tentang itu nanti.

Untuk mengimplementasikan tampilan dua negara, dengan mempertimbangkan desain, saya menggunakan RemoteAdapter sebagai implementasi RemoteViewsService untuk menghasilkan kartu konten.

Dan sekarang sedikit kode dan bagaimana semuanya bekerja di dalamnya. Jika Anda sudah memiliki pengalaman dengan widget, maka Anda tahu bahwa setiap pembaruan data widget dimulai dengan metode onUpdate:

override fun onUpdate(
        context: Context,
        appWidgetManager: AppWidgetManager,
        appWidgetIds: IntArray
    ) {
        injector.openScope(this, *arrayOf(this))
        // update each of the widgets with the remote adapter
        appWidgetIds
            .forEach {
                updateWidget(context, appWidgetManager, it)
          }
    }

Kami sedang menulis pembaruan untuk setiap instance widget kami.

private fun updateWidget(
        context: Context,
        appWidgetManager: AppWidgetManager,
        appWidgetId: Int
    ) {
// remoteViews   widgetId
        val remoteViews = RemoteViews(
            context.packageName,
            R.layout.app_widget_layout
...
//        
...
//    remoteViews
updateRemoteAdapter(context, remoteViews, appWidgetId)
 
//   remoteViews 
appWidgetManager.updateAppWidget(appWidgetId, remoteViews)
// collection view  
appWidgetManager.notifyAppWidgetViewDataChanged(
            appWidgetId,
            R.id.lvWidgetItems
        )
    }

Memperbarui adaptor.

private fun updateRemoteAdapter(context: Context, remoteViews: RemoteViews, appWidgetId: Int) {
//   RemoteViewsService   RemoteAdapter   
        val adapterIntent = Intent(context, StolotoAppWidgetRemoteViewsService::class.java).apply {
            putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
        }
        remoteViews.setRemoteAdapter(R.id.lvWidgetItems, adapterIntent)
// actionIntent  pendingIntent      
        val actionIntent = Intent(context, StolotoAppWidgetProvider::class.java).apply {
            action = WIDGET_CLICK_ACTION
            putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
        }
        val pendingIntent = PendingIntent.getBroadcast(
            context, 0, actionIntent,
            PendingIntent.FLAG_UPDATE_CURRENT
        )
// pendingIntent      
        remoteViews.setPendingIntentTemplate(R.id.lvWidgetItems, pendingIntent)
    }

Kami menulis implementasi layanan kami. Di dalamnya, penting bagi kami untuk menunjukkan implementasi antarmuka RemoteViewsService mana yang digunakan untuk menghasilkan konten.

class StolotoAppWidgetRemoteViewsService : RemoteViewsService() {
    override fun onGetViewFactory(intent: Intent): RemoteViewsFactory =
        StolotoAppWidgetRemoteViewsFactory(
            this.applicationContext,
            intent
        )
}

Ini sebenarnya adalah pembungkus tipis di atas Adaptor. Berkat dia, kami dapat mengaitkan data kami dengan tampilan pengumpulan jarak jauh. RemoteViewsFactory menyediakan metode untuk menghasilkan RemoteViews untuk setiap item dalam dataset. Konstruktor tidak memiliki persyaratan - semua yang saya lakukan adalah melewati konteks di dalamnya.

Berikutnya, beberapa kata tentang metode utama:

  1. onCreate - membuat adaptor.
  2. getLoadingView - metode ini menyarankan untuk mengembalikan View, yang akan ditampilkan oleh sistem alih-alih daftar item saat sedang dibuat. Jika Anda tidak membuat apa pun di sini, maka sistem menggunakan beberapa Tampilan default.
  3. getViewAt - metode ini menyarankan untuk membuat item daftar. Di sinilah standar penggunaan RemoteViews.
  4. onDataSetChanged dipanggil ketika permintaan diterima untuk memperbarui data dalam daftar. Itu Dalam metode ini, kami menyiapkan data untuk daftar. Metode ini dipertajam dengan eksekusi kode yang panjang dan berat.
  5. onDestroy dipanggil ketika daftar terakhir yang digunakan adaptor dihapus (satu adaptor dapat digunakan oleh beberapa daftar).
  6. RemoteViewsFactory hidup ketika semua instance daftar masih hidup, jadi kami dapat menyimpan data saat ini di dalamnya, misalnya, daftar item saat ini.

Tetapkan daftar data yang akan kami tampilkan:

private val widgetItems = ArrayList<WidgetItem>()

Saat membuat adaptor, kami mulai memuat data. Di sini Anda dapat dengan aman melakukan tugas-tugas sulit, termasuk berjalan dengan tenang ke jaringan yang menghalangi arus.

override fun onCreate() {
        updateDataSync()
}

Saat memanggil perintah untuk memperbarui data, kami juga memanggil updateDataSync ()

   override fun onDataSetChanged() {
        updateDataSync()
    }

Di dalam updateDataSync, semuanya juga sederhana. Kami menghapus daftar item saat ini. Unduh profil dan data permainan.

 private fun updateDataSync() {
        widgetItems.clear()
        updateProfileSync()
        updateGamesSync()
    }

Lebih menarik di sini

private fun updateProfileSync() {

Karena penting bagi kami untuk menampilkan profil hanya kepada pengguna yang berwenang, kami perlu mengunduh informasi profil hanya dalam kasus ini:

val isUserFullAuth = isUserFullAuthInteractor
            .execute()
            .blockingGet()
        if (isUserFullAuth) {
            val profile = getWidgetProfileInteractor
                .execute()
                .onErrorReturn {
                    WidgetProfile()
//           
                }
                .blockingGet()

Model WidgetProfile disusun dari sumber yang berbeda, sehingga logika tanda terima dan nilai defaultnya diatur sedemikian rupa sehingga nilai dompet negatif menunjukkan data atau masalah yang salah dengan tanda terima mereka.

Untuk logika bisnis, kurangnya data dompet sangat penting, oleh karena itu, dalam kasus dompet yang salah, model profil tidak akan dibuat dan ditambahkan ke daftar item.

  if (profile.walletAmount >= 0L) {
                widgetItems.add(
                    WidgetItem.Profile(
                        wallet = profile.walletAmount.toMoneyFormat(),
                        waitingTickets = if (profile.waitingTicketsCount >= 0) profile.waitingTicketsCount.toString() else "",
                        unpaidPrizeAmount = if (profile.unpaidPrizeAmount >= 0) profile.unpaidPrizeAmoount.toMoneyFormat() else ""
                    )
                )
            }
        }
    }

Metode updateGamesSync () menggunakan getWidgetGamesInteractor dan menambahkan satu set game yang relevan dengan widget ke daftar widgetItems.

Sebelum melanjutkan ke pembuatan kartu, pertimbangkan model WidgetItem lebih terinci. Ini diimplementasikan melalui kelas tertutup kotlin, yang membuat model lebih fleksibel, dan bekerja dengannya lebih nyaman.

sealed class WidgetItem {
 
    data class Profile(
        val wallet: String,
        val waitingTickets: String,
        val unpaidPrizeAmount: String
    ) : WidgetItem()
 
    data class Game(
        val id: String,
        val iconId: Int,
        val prizeValue: String,
        val date: String
    ) : WidgetItem()
}

Buat RemoteViews dan tentukan respons mereka melalui FillInIntent

override fun getViewAt(position: Int): RemoteViews {
        return when (val item = widgetItems[position]) {
            is WidgetItem.Profile -> {
              RemoteViews(
                        context.packageName,
                        R.layout.item_widget_user_profile
                    ).apply {
                        setTextViewText(R.id.tvWidgetWalletMoney, item.wallet)
                        setTextViewText(R.id.tvWidgetUnpaidCount, item.unpaidPrizeAmount)
                        setTextViewText(R.id.tvWidgetWaitingCount, item.waitingTickets)
                        setOnClickFillInIntent(
                            R.id.llWidgetProfileWallet, Intent().putExtra(
                                StolotoAppWidgetProvider.KEY_PROFILE_OPTIONS,
                                StolotoAppWidgetProvider.VALUE_USER_WALLET
                            )
                        )
                        setOnClickFillInIntent(
                            R.id.llWidgetProfileUnpaid, Intent().putExtra(
                                StolotoAppWidgetProvider.KEY_PROFILE_OPTIONS,
                                StolotoAppWidgetProvider.VALUE_UNPAID_PRIZE
                            )
                        )
                        setOnClickFillInIntent(
                            R.id.llWidgetProfileWaiting, Intent().putExtra(
                                StolotoAppWidgetProvider.KEY_PROFILE_OPTIONS,
                                StolotoAppWidgetProvider.VALUE_WAITING_TICKETS
                            )
                        )
                    }
 
            is WidgetItem.Game -> {
                RemoteViews(
                    context.packageName,
                    R.layout.item_widget_game
                ).apply {
                    setImageViewResource(R.id.ivWidgetGame, item.iconId)
                    setTextViewText(R.id.tvWidgetGamePrize, item.prizeValue)
                    setTextViewText(R.id.tvWidgetGameDate, item.date)
                    setOnClickFillInIntent(
                        R.id.llWidgetGame, Intent().putExtra(
                            StolotoAppWidgetProvider.KEY_GAME_CLICK, item.id
                        )
                    )
                }
            }
        }
    }

Metode setOnClickFillInIntent menetapkan maksud viewId yang ditentukan, yang akan dikombinasikan dengan induk PendingIntent untuk menentukan perilaku ketika mengklik pada tampilan dengan viewId ini. Dengan cara ini kami dapat menanggapi klik pengguna di WidgetProvider kami.

Pembaruan widget manual


Waktu pembaruan setengah jam ditetapkan untuk widget kami. Anda dapat memperbaruinya lebih sering, misalnya, melalui menari dengan WorkManager, tetapi mengapa memuat jaringan dan baterai Anda? Perilaku seperti itu pada tahap awal pengembangan tampak memadai.

Semuanya berubah ketika "bisnis" memperhatikan bahwa ketika pengguna melihat widget, data yang tidak relevan ditampilkan di atasnya: "Di sini di iPhone saya, saya membuka widget dan ada data segar paling profil saya."

Situasinya biasa: iOS menghasilkan kartu baru untuk SETIAP tampilan widget, karena untuk ini mereka memiliki layar khusus, dan Android pada dasarnya tidak memiliki acara untuk widget tersebut. Saya harus memperhitungkan bahwa beberapa lotere diadakan setiap 15 menit sekali, jadi widget harus memberikan informasi terkini - Anda ingin berpartisipasi dalam beberapa jenis undian, tetapi sudah terlewati.

Untuk keluar dari situasi yang tidak menyenangkan ini dan menyelesaikan masalah dengan memperbarui data, saya mengusulkan dan menerapkan solusi yang telah teruji oleh waktu - tombol "perbarui".

Tambahkan tombol ini ke tata letak tata letak dengan daftar dan inisialisasi perilakunya ketika updateWidget dipanggil.

...
// Intent   AppWidgetManager.ACTION_APPWIDGET_UPDATE
val intentUpdate = Intent(context, StolotoAppWidgetProvider::class.java)
intentUpdate.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE

//    
val ids = AppWidgetManager.getInstance(context)
   .getAppWidgetIds(ComponentName(context, StolotoAppWidgetProvider::class.java))
intentUpdate.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)

//  intent  PendingIntent,  PendingIntent.getBroadcast()
val pendingUpdate = PendingIntent.getBroadcast(
   context,
   appWidgetId,
   intentUpdate,
   PendingIntent.FLAG_UPDATE_CURRENT
)
//  pendingIntent      โ€˜โ€™
remoteViews.setOnClickPendingIntent(R.id.ivWidgetRefresh, pendingUpdate)
โ€ฆ

Perkembangan pertama menunjukkan gambar yang menyedihkan: dari menekan tombol "perbarui" ke pembaruan aktual, beberapa detik bisa berlalu. Meskipun widget dihasilkan oleh aplikasi kita, sebenarnya widget itu berada di bawah kendali sistem dan berkomunikasi dengan aplikasi kita melalui siaran.

Itu ketika Anda mengklik tombol "perbarui" widget kami, rantai dimulai:

  1. Dapatkan Intent di 'aksi' penyedia onReceive.
  2. AppWidgetManager.ACTION_APPWIDGET_UPDATE.
  3. Panggil Update untuk semua widget yang ditentukan dalam niat-e.
  4. Online untuk data baru.
  5. Refresh data lokal dan tampilkan kartu daftar baru.

Akibatnya, memperbarui widget tidak terlihat bagus, karena dengan mengklik tombol kami melihat widget yang sama selama beberapa detik. Tidak jelas apakah data diperbarui. Bagaimana mengatasi masalah respons visual?

Pertama, saya menambahkan bendera isWidgetLoading dengan akses global melalui interaktor. Peran parameter ini cukup sederhana - jangan tampilkan tombol segarkan saat data widget dimuat.

Kedua, saya membagi proses pemuatan data di pabrik menjadi tiga tahap:

enum class LoadingStep {
   START,
   MIDDLE,
   END
}

MULAI - mulai mengunduh. Pada tahap ini, keadaan semua tampilan adaptor dan bendera unduhan global berubah menjadi "memuat".

TENGAH - tahap pemuatan data utama. Setelah diunduh, bendera unduhan global diletakkan dalam status "dimuat", dan data yang diunduh ditampilkan di adaptor.

AKHIR - akhir unduhan. Adaptor tidak perlu mengubah data adaptor pada langkah ini. Langkah ini diperlukan untuk memproses tahapan pembaruan tampilan dengan benar di WidgetProvider.

Mari kita lihat lebih detail seperti apa tampilan tombol di provider:

if (isFullAuthorized && !widgetLoadingStateInteractor.isWidgetLoading) {
   remoteViews.setViewVisibility(R.id.ivWidgetRefresh, View.VISIBLE)
...
//     ,    
...   
} else {
   remoteViews.setViewVisibility(
       R.id.ivWidgetRefresh,
       if (isFullAuthorized) View.INVISIBLE else View.GONE //       .
   )
}

Sekarang mari kita lihat apa yang terjadi di adaptor:

private fun updateDataSync() {
   when (loadingStep) {
       START -> {
           widgetItems.forEach { it.isLoading = true }
           widgetLoadingStateInteractor.isWidgetLoading = true
           loadingStep = MIDDLE
           widgetManager.updateWidgets()
       }
       MIDDLE -> {
           widgetItems.clear()
           updateProfileSync()
           updateGamesSync()
           widgetLoadingStateInteractor.isWidgetLoading = false
           loadingStep = END
           widgetManager.updateWidgets()
       }
       END -> {
           loadingStep = START
       }
   }
}

gambar

Logika kerja:

  1. Di akhir langkah MULAI dan TENGAH, saya memanggil metode updateWidgets untuk memperbarui kondisi tampilan yang dikelola oleh penyedia.
  2. START ยซยป , MIDDLE.
  3. MIDDLE, ยซยป.
  4. MIDDLE, END.
  5. , END, ยซยป. , END loadingStep START.



Dengan bantuan implementasi seperti itu, saya mencapai kompromi antara persyaratan "bisnis" untuk melihat data yang relevan pada widget dan kebutuhan untuk "menarik" pembaruan terlalu sering.

Semoga artikel ini bermanfaat bagi Anda. Jika Anda memiliki pengalaman membuat widget untuk Android, maka beri tahu kami tentang hal itu di komentar.

Semoga berhasil

All Articles