59
Help us understand the problem. What are the problem?

posted at

updated at

Organization

超シンプルなホームアプリを作る ~ホームアプリ(ランチャーアプリ)の作り方~

Androidにおいて、ホームアプリ(ランチャーアプリ)は特殊なアプリではなく、作り方も一般的なアプリとほとんど一緒です。しかし、滅多に一から作ることがないものでもあるので、その作り方を解説してみます。

ここでは、最低限の機能の超シンプルなホームアプリを作ります。
最低限といっても、「システムからホームアプリと認識される」という意味にすると、intent-filterを定義するだけで終わってしまいます。

  • インストールされているアプリを起動する機能をもつ
  • システムからホームアプリとして認識される
  • 壁紙が表示される

を最低限の要件としようと思います。

Android 5以降ぐらいからは、SystemUIから設定アプリを起動するボタンが提供されているのでそれほど気にする必要は無いですが、設定アプリを起動する機能を作る前に、ホームアプリとして作ってしまって、それをデフォルトのホームアプリに設定してしまうと、GUIから設定変更する手段が失われる、なんてことにもなるので、まずはアプリの起動機能を作ります。

ホームアプリ(ランチャーアプリ)の作り方について他にも書いていますので参考まで

ランチャー機能の実装

アプリの一覧を取得する

まずはアプリの情報を集めます。
表示する上で、アプリ名に該当するラベルとアプリアイコンがほしいですね。
それからアプリを起動するために、そのアプリのパッケージ名とActivity名が必要なのでそれらをまとめたComponentNameを保持する以下のようなデータクラスを用意してこれに詰め込みます。

AppInfo.kt
data class AppInfo(
    val icon: Drawable,
    val label: String,
    val componentName: ComponentName
)

ランチャーに表示されるアプリ一覧は、インストールされているアプリすべて、という訳ではないですよね。一般アプリを作るときに設定していますが

AndroidManifest.xml
<intent-filter>
    <action android:name="android.intent.action.MAIN"/>
    <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>

というintent-filterが設定されているAcitivty (Launcher Activity) の一覧をランチャーに表示することになります。アイコンが表示されないアプリもあるかもしれませんし、一つのアプリで複数のアイコンが表示されるアプリもあります。

つまり、PackageManager#queryIntentActivitiesを使って以下のようにすることで、ランチャーに表示すべきアプリ情報を集めることができます。必要なのはActivityInfoなのでmapしてます。また自分自身(ランチャーアプリ自体)が表示されるのは変なので除外しています。(最終的にCATEGORY_LAUNCHERのintent-filterを残すかどうかはアプリの戦略次第だとは思いますが、少なくとも開発段階ではあった方が便利です)

AppInfoList.kt
fun create(context: Context): List<AppInfo> {
    val pm = context.packageManager
    val intent = Intent(Intent.ACTION_MAIN)
        .also { it.addCategory(Intent.CATEGORY_LAUNCHER) }
    return pm.queryIntentActivities(intent, PackageManager.MATCH_ALL)
        .asSequence()
        .mapNotNull { it.activityInfo }
        .filter { it.packageName != context.packageName }
        .map {
            AppInfo(
                it.loadIcon(pm) ?: getDefaultIcon(context),
                it.loadLabel(pm).toString(),
                ComponentName(it.packageName, it.name)
            )
        }
        .sortedBy { it.label }
        .toList()
}

iconやlabelについてはActivityInfoに直接入っているわけではなく、ActivityInfo#loadIcon() ActivityInfo#loadLabel()を使って取得します。
アイコンは取得できない場合もあり得ますので、ない場合はデフォルトのアイコンになるようにしておきます。
最後にラベル名でソートしておきます。

PackageVisivilityへの対応

Android 11からはPackage Visibilityの設定が必要になります。
https://developer.android.com/training/basics/intents/package-visibility
QUERY_ALL_PACKAGES を設定すればこの制限は外れますし、このパーミッションを取得するユースケースとしてランチャーアプリが一番最初に上げられています。

AndroidManifest.xml
<uses-permission
    android:name="android.permission.QUERY_ALL_PACKAGES"
    tools:ignore="QueryAllPackagesPermission"
    />

しかし、ランチャー機能以外を提供しないのであれば、前述のクエリに反応するアプリだけで良いので、以下のような指定でもかまいません。

AndroidManifest.xml
<queries>
    <intent>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent>
</queries>

アプリ一覧を表示する

このリストをRecyclerViewに表示させてみましょう。
それっぽくという意味であればアイコン+ラベルとグリッド状に並べたいかもしれませんが、ひとまずリスト表示します。確認用にパッケージ名も表示させるようにしています。

レイアウトはこんな感じ

li_application.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="wrap_content"
    android:foreground="?android:attr/selectableItemBackground"
    >

    <ImageView
        android:id="@+id/icon"
        android:layout_width="48dp"
        android:layout_height="48dp"
        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:ignore="ContentDescription"
        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:textAppearance="@style/TextAppearance.AppCompat.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"
        tools:text="Launcher"
        />

    <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:textAppearance="@style/TextAppearance.AppCompat"
        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"
        tools:text="net.mm2d.launcher"
        />

</androidx.constraintlayout.widget.ConstraintLayout>

Adapterはこんな風に特にひねりもありませんが、アイテムのタップはコンストラクタで渡されるラムダを呼び出すことでコールバックするようにしてます。

AppAdapter.kt
class AppAdapter(
    private val inflater: LayoutInflater,
    private val list: List<AppInfo>,
    private val onClick: (view: View, info: AppInfo) -> Unit
) : Adapter<AppAdapter.AppViewHolder>() {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppViewHolder =
        AppViewHolder(inflater.inflate(layout.li_application, parent, false))

    override fun getItemCount(): Int = list.size

    override fun onBindViewHolder(holder: AppViewHolder, position: Int) {
        val info = list[position]
        holder.itemView.setOnClickListener { onClick(it, info) }
        holder.icon.setImageDrawable(info.icon)
        holder.label.text = info.label
        holder.packageName.text = info.componentName.packageName
    }

    class AppViewHolder(itemView: View) : ViewHolder(itemView) {
        val icon: ImageView = itemView.findViewById(id.icon)
        val label: TextView = itemView.findViewById(id.label)
        val packageName: TextView = itemView.findViewById(id.packageName)
    }
}

タップのコールバックは置いておいて、まずは表示させてみましょう。

LauncherActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(layout.activity_main)
    adapter = AppAdapter(layoutInflater, AppInfoList.create(this)) { view, info ->
    }
    recyclerView.adapter = adapter
    recyclerView.layoutManager = LinearLayoutManager(this)
}

以下のように表示されます。

アプリを起動させる

リスト作成時にComponenNameをすでに取得していますので、アプリを起動させるのは簡単ですね。
以下のようにAppInfo.ktに起動させる処理を追加しました。

AppInfo.kt
data class AppInfo(
    val icon: Drawable,
    val label: String,
    val componentName: ComponentName
) {
    fun launch(context: Context) {
        try {
            val intent = Intent(Intent.ACTION_MAIN).also {
                it.flags = Intent.FLAG_ACTIVITY_NEW_TASK or
                        Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED
                it.addCategory(Intent.CATEGORY_LAUNCHER)
                it.component = componentName
            }
            context.startActivity(intent)
        } catch (e: ActivityNotFoundException) {
        }
    }
}

RecyclerViewのAdapterに渡すコールバックラムダからこのメソッドをたたきます。

LauncherActivity.kt
adapter = AppAdapter(layoutInflater) { view, info ->
    info.launch(this)
}

これでアプリを起動させる機能を実装することができました。

機能的にはランチャーアプリとして最低限のところまできました。
いわゆるシステム設定も他のアプリと同様に表示され、起動できるようになっているはずです。

タップしたアイコンから始まるアニメーションをつける

アプリを起動できるようになりましたが、これだけでは起動したアプリが唐突に始まっているように見えます。
一方、一般のホームアプリは、だいたいタップしたアイコンからアプリが始まったようにみせるアニメーションをつけて、アプリを起動させています。
必須機能というわけではないですが、これを実装してみます。

AdapterクラスでonClickListenerからコールバックを呼び出しているところを変更し、アイコンのViewをコールバックの第一引数に渡すように変更します。

AppAdapter.kt
override fun onBindViewHolder(holder: AppViewHolder, position: Int) {
    val info = list[position]
    holder.itemView.setOnClickListener { onClick(holder.icon, info) } // <- ココ
    holder.icon.setImageDrawable(info.icon)
    holder.label.text = info.label
    holder.packageName.text = info.componentName.packageName
}

この引数をlaunchメソッドに伝えてActivityOptions#makeScaleUpAnimationを使ってアニメーションBundleを作成し、startActivityに渡します。

AppInfo.kt
data class AppInfo(
    val icon: Drawable,
    val label: String,
    val componentName: ComponentName
) {
    fun launch(context: Context, view: View? = null) {
        try {
            val intent = Intent(Intent.ACTION_MAIN).also {
                it.flags = Intent.FLAG_ACTIVITY_NEW_TASK or
                        Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED
                it.addCategory(Intent.CATEGORY_LAUNCHER)
                it.component = componentName
            }
            val options = view?.let {
                ActivityOptions.makeScaleUpAnimation(it, 0, 0, it.width, it.height)
                    .toBundle()
            }
            context.startActivity(intent, options)
        } catch (e: ActivityNotFoundException) {
        }
    }
}

ちょっとしたことですが、「起動している感」を出すことができます。
2.gif

アプリ一覧を更新できるようにする。

アプリ一覧はホームアプリ起動中も変化する可能性があります。これに対応させます。

まずは、Adapterクラスを変更して、リストを更新できるようにします。
せっかくRecyclerViewを使っているので、単にリストを入れ替えるのではなく追加削除のアニメーションをつけられるようにDiffUtilを使います。

以下の実装をAppAdapter.ktに追加します。
listをコンストラクタではなくupdateListというメソッド経由で渡すようにして、DiffUtilを使って更新するようにしています。

AppAdapter.kt
private var list: List<AppInfo> = emptyList()

fun updateList(newList: List<AppInfo>) {
    val diff = DiffUtil.calculateDiff(DiffCallback(list, newList), true)
    list = newList
    diff.dispatchUpdatesTo(this)
}

private class DiffCallback(
    private val old: List<AppInfo>,
    private val new: List<AppInfo>
) : Callback() {
    override fun getOldListSize(): Int = old.size
    override fun getNewListSize(): Int = new.size
    override fun areItemsTheSame(op: Int, np: Int): Boolean =
        old[op].componentName == new[np].componentName
    override fun areContentsTheSame(op: Int, np: Int): Boolean =
        old[op].label == new[np].label && old[op].icon == new[np].icon
}

アプリの追加・削除はBroadcastIntentで通知されます。

Action 意味
Intent.ACTION_PACKAGE_ADDED アプリがインストールされた
Intent.ACTION_PACKAGE_REMOVED アプリが削除された
Intent.ACTION_PACKAGE_CHANGED アプリの情報が更新された
Intent.ACTION_PACKAGE_REPLACED アプリが更新された

ACTION_PACKAGE_CHANGEDはコンポーネントの有無を動的に変更させた場合などで投げられるので監視する必要があります。ACTION_PACKAGE_REPLACEDは新バージョンへ更新された場合ですが、更新タイミングでアイコンやラベルが変化している可能性があるのでこちらも監視します。

このアプリでは起動中の変化だけ受け取れればよいと言うことで、LauncherActivityのonCreateからonDestoryの間でBroadcastIntentを受け取ってリストを更新します。
IntentFilterに"package"のデータスキーマを追加するのを忘れないようにしましょう。これがないとこれらのBroadcastIntentを受け取ることができません。

LauncherActivity.kt
private val packageReceiver = object : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        adapter.updateList(AppInfoList.create(this@LauncherActivity))
    }
}

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    registerReceiver(packageReceiver, IntentFilter().also {
        it.addAction(Intent.ACTION_PACKAGE_ADDED)
        it.addAction(Intent.ACTION_PACKAGE_REMOVED)
        it.addAction(Intent.ACTION_PACKAGE_CHANGED)
        it.addAction(Intent.ACTION_PACKAGE_REPLACED)
        it.addDataScheme("package")
    })
}

override fun onDestroy() {
    super.onDestroy()
    unregisterReceiver(packageReceiver)
}

ホームアプリらしく振る舞わせる

システムからホームアプリとして認識されるようにする

Launcherの起点となるAcitivtyに以下のintent-filterを設定します。

AndroidManifest.xml
<intent-filter>
    <action android:name="android.intent.action.MAIN"/>

    <category android:name="android.intent.category.HOME"/>
    <category android:name="android.intent.category.DEFAULT"/>
</intent-filter>

このintent-filterがあるとシステムからホームアプリとして認識され、ホームキータップ時に起動されるアプリとなります。

以下のようにホームキーを押したときに候補に現れて

デフォルトアプリの設定にも候補として出てくるようになります。

ホームキーで起動できるようになるのでCATEGORY_LAUNCHERのintent-filterは不要になりますが、他のホームアプリがデフォルトになっている場合に、そこから起動してもらうこともできるので、残しておいてもよいと思います。

intent-filterはactivity-aliasに設定

デフォルトホームの設定はアプリというよりActivityに紐付きます。
そのためリファクタリングなどで起動起点となるActivityの名前やパッケージが変更されると、アップデート時にデフォルトホームの設定が解除されてしまうので注意が必要です。ホームアプリはデフォルトに設定されてなんぼのアプリなので、自分からそれを外しにいく行為は避けたいですね。
しかし、それにアプリ内設計が引きずられるのもよくないので、上記intent-filterはactivity-aliasに設定して、実体となるActivityに制約がかからないようにするのがよいでしょう。

Activityが終了しないようにする

一般アプリだとバックキーとかで最終的にアプリを終了させないといけませんが、ホームアプリの場合は終了してもその後表示するものがないので、終了しないようにしないといけません。

LauncherActivityのfinishをoverrideして空実装しておきます。これで終了しなくなりました。

LauncherActivity.kt
override fun finish() {
}

壁紙を表示する

ホームアプリは壁紙が表示されるのが通常ですね。豊富なカスタマイズを提供する場合など、アプリ自身で描画して表示する場合がありますが、ひとまずシステムで設定している壁紙を表示させることにします。

システムで設定している壁紙が背景として表示されるようにするにはstyleを変更します。
Theme.wallpaperというstyleを使えばよいのですが、AppCompatActivityではAppCompat系のstyleを使う必要があるので使えません。
そこで、Theme.AppCompatを継承したスタイルを定義して壁紙の表示を指定します。

styles.xml
<style name="AppTheme.Wallpaper" parent="@style/Theme.AppCompat">
    <item name="android:windowBackground">@android:color/transparent</item>
    <item name="android:windowShowWallpaper">true</item>
</style>

こんな感じでシステムの壁紙が表示されるようになります。

見た目の調整

壁紙は表示できたけど、Toolbarとか邪魔ですね。
最近はステータスバーやナビゲーションバーも透けさせるのがトレンドなのでやってみましょう。

styles.xml
<style name="AppTheme.Wallpaper" parent="@style/Theme.AppCompat">
    <item name="android:windowLightStatusBar">false</item>
    <item name="android:windowTranslucentStatus">true</item>
    <item name="android:windowTranslucentNavigation">true</item>
    <item name="windowActionBar">false</item>
    <item name="windowNoTitle">true</item>
    <item name="android:windowBackground">@android:color/transparent</item>
    <item name="android:windowShowWallpaper">true</item>
</style>

いい感じですがステータスバーとかナビゲーションバーの下にアイコンが食い込んでいてタップしにくくなっています。

RecyclerViewにandroid:clipToPadding="false"を設定して、ステータスバーとナビゲーションバー、およびノッチを考慮したパディングを設定します。

LauncherActivity.kt
recyclerView.setOnApplyWindowInsetsListener { _, insets ->
    recyclerView.setPadding(
        0,
        insets.systemWindowInsetTop,
        0,
        insets.systemWindowInsetBottom
    )
    insets
}

また、背景を壁紙にしたので、単色のテキストでは視認性が悪い場合があります。ドロップシャドウをつけましょう。

li_application.xml
<TextView
    ...
    android:shadowColor="@android:color/black"
    android:shadowRadius="4"
    />

ダブルカットアウトのシミュレート付きで実行、以下のようにかぶらない位置までスクロールできるようになっています。

機能をそぎ落としすぎで、ウィジェットやショートカットも作れないし、壁紙の変更やアプリ設定へのショートカットといったホームアプリなら備えていて当たり前の機能もありませんが、超シンプルなホームアプリ完成です。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
59
Help us understand the problem. What are the problem?