LoginSignup
2
2

More than 1 year has passed since last update.

ウィジェット一覧を作る ~ホームアプリ(ランチャーアプリ)の作り方~

Last updated at Posted at 2021-09-01

ホームアプリ(ランチャーアプリ)の作り方シリーズ
今回は端末にインストールされているウィジェット一覧を表示させてみます。

※シリーズが続くとは言っていない

※壁紙の関係で文字の可読性が滅茶苦茶低いのはご容赦を

インストールされているウィジェット情報の取得

ウィジェット情報にアクセスするにはAppWidgetManagerを利用します。
インストールされているウィジェット情報は以下のメソッドで取得することができます。

public List<AppWidgetProviderInfo> getInstalledProviders()

一覧情報は超シンプルなホームアプリを作る ~ホームアプリ(ランチャーアプリ)の作り方~で作ったアプリ一覧と同様に作っていきますが、ウィジェットの情報として表示したいものを考えてデータクラスを作ってみます。

data class WidgetInfo(
    val appWidgetProviderInfo: AppWidgetProviderInfo,
    val label: String,
    val previewImage: Drawable?,
    val minWidth: Int,
    val minHeight: Int,
    val packageName: String,
)

appWidgetProviderInfoは今回は使いませんが、念のため持たせておきます。

また、このウィジェットを提供しているアプリの情報も欲しいところですね、以下のように定義しておきます。

data class WidgetPackageInfo(
    val label: String,
    val icon: Drawable?,
    val packageName: String,
    val widgets: List<WidgetInfo>,
)

アプリの情報に、先ほどのウィジェットの情報を持たせる形にしています。
これをAppWidgetManagerから取得して返還する処理を以下のように実装してみます。

object WidgetPackageInfoList {
    private var defaultIcon: Drawable? = null

    private fun getDefaultIcon(context: Context): Drawable {
        return defaultIcon
            ?: AppCompatResources.getDrawable(context, R.drawable.ic_android)
                ?.also { defaultIcon = it }!!
    }

    fun create(context: Context): List<WidgetPackageInfo> =
        AppWidgetManager.getInstance(context)
            .installedProviders
            .map { it.toWidgetInfo(context) }
            .groupBy { it.packageName }
            .map { it.toWidgetPackageInfo(context) }

    private fun AppWidgetProviderInfo.toWidgetInfo(context: Context): WidgetInfo {
        val label = loadLabel(context.packageManager) ?: ""
        val image = loadPreviewImage(context, 0)
        val packageName = provider.packageName
        return WidgetInfo(this, label, image, minWidth, minHeight, packageName)
    }

    private fun Map.Entry<String, List<WidgetInfo>>.toWidgetPackageInfo(context: Context): WidgetPackageInfo {
        val packageManager = context.packageManager
        val packageInfo = packageManager.getPackageInfo(key, 0)
        val applicationInfo = packageInfo.applicationInfo
        val label = applicationInfo.loadLabel(packageManager).toString()
        val icon = applicationInfo.loadIcon(packageManager) ?: getDefaultIcon(context)
        return WidgetPackageInfo(label, icon, packageInfo.packageName, value)
    }
}

AppWidgetProviderInfoにはlabelやicon、previewImageはそれぞれのアプリのリソースIDだけが入っています。
DrawableやStringなどの具体的データはそれぞれのアプリから読み出します。それぞれloadメソッドが用意されていますのでそれを利用します。
また、アプリの情報は起動アプリ一覧の時とは異なり、ActivityInfoではなく、PackageInfoを利用します。

ちょっと戸惑うかもしれないのは loadPreviewImage(context, 0)の第二引数でしょうか?ここにはdensityDpiの値を指定して、読み出すリソースのdensityを指定することができます。拡大して表示する場合などに、端末のdensityより大きな値を指定して、高解像度のリソースを読み出すとかができます。0を指定すると端末のdensityで読み出されるので通常0を指定します。

ウィジェット一覧をRecyclerViewに表示する

比較的簡単に一覧情報を取得し、表示したい要素を持ったdata classのリストを作ることができたので、これを表示するだけです。
一つのアプリが複数のウィジェットを持っているため、アプリ情報の下にそのアプリの持っているウィジェット一覧を表示する形にしてみます。

まずは、アプリ情報はアイコンとアプリ名とパッケージ名を表示します。

item_widget_header.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:id="@+id/root"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="?android:attr/selectableItemBackground"
    >

    <ImageView
        android:id="@+id/icon"
        android:layout_width="24dp"
        android:layout_height="24dp"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:layout_marginBottom="8dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:srcCompat="@drawable/ic_launcher"
        />

    <TextView
        android:id="@+id/label"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:layout_marginEnd="8dp"
        android:ellipsize="end"
        android:maxLines="1"
        android:shadowColor="@android:color/black"
        android:shadowRadius="4"
        android:textColor="@color/app_name"
        android:textSize="@dimen/text_size_widget_list_app_title"
        app:layout_constraintBottom_toTopOf="@+id/packageName"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintStart_toEndOf="@+id/icon"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_chainStyle="packed"
        />

    <TextView
        android:id="@+id/packageName"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginEnd="8dp"
        android:layout_marginBottom="8dp"
        android:ellipsize="end"
        android:maxLines="1"
        android:shadowColor="@android:color/black"
        android:shadowRadius="4"
        android:textColor="@color/app_name"
        android:textSize="@dimen/text_size_widget_list_app_package"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintStart_toEndOf="@+id/icon"
        app:layout_constraintTop_toBottomOf="@+id/label"
        />
</androidx.constraintlayout.widget.ConstraintLayout>

ウィジェットも同様に、プレビューイメージとウィジェット名とサイズ情報を表示させます

item_wdget_entity.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:id="@+id/root"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="?android:attr/selectableItemBackground"
    >

    <ImageView
        android:id="@+id/preview"
        android:layout_width="144dp"
        android:layout_height="96dp"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:layout_marginBottom="8dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:srcCompat="@drawable/ic_launcher"
        />

    <TextView
        android:id="@+id/label"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:layout_marginEnd="8dp"
        android:ellipsize="end"
        android:maxLines="1"
        android:shadowColor="@android:color/black"
        android:shadowRadius="4"
        android:textColor="@color/app_name"
        android:textSize="@dimen/text_size_widget_list_widget_title"
        app:layout_constraintBottom_toTopOf="@+id/size"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintStart_toEndOf="@+id/preview"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_chainStyle="packed"
        />

    <TextView
        android:id="@+id/size"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginEnd="8dp"
        android:layout_marginBottom="8dp"
        android:ellipsize="end"
        android:maxLines="1"
        android:shadowColor="@android:color/black"
        android:shadowRadius="4"
        android:textColor="@color/app_name"
        android:textSize="@dimen/text_size_widget_list_widget_package"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintStart_toEndOf="@+id/preview"
        app:layout_constraintTop_toBottomOf="@+id/label"
        />
</androidx.constraintlayout.widget.ConstraintLayout>

入れ子構造のリストを一つのAdapterで作るのは結構面倒ですが、ConcatAdapterを使えば簡単ですね。
以下のように、一つのアプリ分を作るAdapterを作ります。

class WidgetPackageAdapter(
    private val layoutInflater: LayoutInflater,
    private val info: WidgetPackageInfo
) : Adapter<WidgetViewHolder>() {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WidgetViewHolder =
        if (viewType == 0) {
            WidgetHeaderViewHolder.create(layoutInflater, parent)
        } else {
            WidgetEntryViewHolder.create(layoutInflater, parent)
        }

    override fun onBindViewHolder(holder: WidgetViewHolder, position: Int) {
        holder.apply(info, position - 1)
    }

    override fun getItemViewType(position: Int): Int = if (position == 0) 0 else 1
    override fun getItemCount(): Int = info.widgets.size + 1
}

abstract class WidgetViewHolder(itemView: View) : ViewHolder(itemView) {
    abstract fun apply(info: WidgetPackageInfo, position: Int)
}

class WidgetHeaderViewHolder(
    private val binding: ItemWidgetHeaderBinding
) : WidgetViewHolder(binding.root) {
    override fun apply(info: WidgetPackageInfo, position: Int) {
        binding.icon.setImageDrawable(info.icon)
        binding.label.text = info.label
        binding.packageName.text = info.packageName
    }

    companion object {
        fun create(inflater: LayoutInflater, parent: ViewGroup): WidgetHeaderViewHolder =
            WidgetHeaderViewHolder(ItemWidgetHeaderBinding.inflate(inflater, parent, false))
    }
}

class WidgetEntryViewHolder(
    private val binding: ItemWidgetEntryBinding
) : WidgetViewHolder(binding.root) {
    override fun apply(info: WidgetPackageInfo, position: Int) {
        val widget = info.widgets[position]
        binding.preview.setImageDrawable(widget.previewImage)
        binding.label.text = widget.label
        binding.size.text = "${widget.minWidth} x ${widget.minHeight}"
    }

    companion object {
        fun create(inflater: LayoutInflater, parent: ViewGroup): WidgetEntryViewHolder =
            WidgetEntryViewHolder(ItemWidgetEntryBinding.inflate(inflater, parent, false))
    }
}

あとは、各アプリごとに上記Adapterをつくって、ConcastAdapterでまとめて表示させます

class WidgetDrawerActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val binding = ActivityWidgetDrawerBinding.inflate(layoutInflater)
        setContentView(binding.root)
        val list = WidgetPackageInfoList.create(this)
        binding.recyclerView.layoutManager = LinearLayoutManager(this)
        binding.recyclerView.adapter = ConcatAdapter().also { adapter ->
            list.forEach { adapter.addAdapter(WidgetPackageAdapter(layoutInflater, it)) }
        }

        ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { _, insets ->
            val systemInsets = insets.getInsets(Type.systemBars())
            binding.recyclerView.setPadding(0, systemInsets.top, 0, systemInsets.bottom)
            insets
        }
    }
}

以上で端末内のウィジェットの一覧表示をさせるところまでが実装できます。
ところで、ウィジェットを作ったことがあれば分かると思いますが、android:minWidthやandroid:minHeightはdpで指定しますし、ここでやったようにAppWidgetProviderInfoに格納されているminimumWidthやminimumHeightはpixelサイズです。
広く使われているホームアプリではだいたいブロック数でサイズが表示されていますが、minimumWidthやminimumHeightをそれぞれのホームアプリのブロックサイズ何個分に相当するかを計算して表示しているわけですね。

以上です。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2