Androidにおいて、ホームアプリ(ランチャーアプリ)は特殊なアプリではなく、作り方も一般的なアプリとほとんど一緒です。しかし、滅多に一から作ることがないものでもあるので、その作り方を解説してみます。
ここでは、最低限の機能の超シンプルなホームアプリを作ります。
最低限といっても、「システムからホームアプリと認識される」という意味にすると、intent-filterを定義するだけで終わってしまいます。
- インストールされているアプリを起動する機能をもつ
- システムからホームアプリとして認識される
- 壁紙が表示される
を最低限の要件としようと思います。
Android 5以降ぐらいからは、SystemUIから設定アプリを起動するボタンが提供されているのでそれほど気にする必要は無いですが、設定アプリを起動する機能を作る前に、ホームアプリとして作ってしまって、それをデフォルトのホームアプリに設定してしまうと、GUIから設定変更する手段が失われる、なんてことにもなるので、まずはアプリの起動機能を作ります。
ホームアプリ(ランチャーアプリ)の作り方について他にも書いていますので参考まで
- 超シンプルなホームアプリを作る ~ホームアプリ(ランチャーアプリ)の作り方~ ←イマココ
- システム壁紙を制御する ~ホームアプリ(ランチャーアプリ)の作り方~
- ウィジェット一覧を作る ~ホームアプリ(ランチャーアプリ)の作り方~
- ウィジェットをアプリ上に表示する ~ホームアプリ(ランチャーアプリ)の作り方~
ランチャー機能の実装
アプリの一覧を取得する
まずはアプリの情報を集めます。
表示する上で、アプリ名に該当するラベルとアプリアイコンがほしいですね。
それからアプリを起動するために、そのアプリのパッケージ名とActivity名が必要なのでそれらをまとめたComponentNameを保持する以下のようなデータクラスを用意してこれに詰め込みます。
data class AppInfo(
val icon: Drawable,
val label: String,
val componentName: ComponentName
)
ランチャーに表示されるアプリ一覧は、インストールされているアプリすべて、という訳ではないですよね。一般アプリを作るときに設定していますが
<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を残すかどうかはアプリの戦略次第だとは思いますが、少なくとも開発段階ではあった方が便利です)
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 を設定すればこの制限は外れますし、このパーミッションを取得するユースケースとしてランチャーアプリが一番最初に上げられています。
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission"
/>
しかし、ランチャー機能以外を提供しないのであれば、前述のクエリに反応するアプリだけで良いので、以下のような指定でもかまいません。
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
</queries>
アプリ一覧を表示する
このリストをRecyclerViewに表示させてみましょう。
それっぽくという意味であればアイコン+ラベルとグリッド状に並べたいかもしれませんが、ひとまずリスト表示します。確認用にパッケージ名も表示させるようにしています。
レイアウトはこんな感じ
<?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はこんな風に特にひねりもありませんが、アイテムのタップはコンストラクタで渡されるラムダを呼び出すことでコールバックするようにしてます。
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)
}
}
タップのコールバックは置いておいて、まずは表示させてみましょう。
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
に起動させる処理を追加しました。
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に渡すコールバックラムダからこのメソッドをたたきます。
adapter = AppAdapter(layoutInflater) { view, info ->
info.launch(this)
}
これでアプリを起動させる機能を実装することができました。
機能的にはランチャーアプリとして最低限のところまできました。
いわゆるシステム設定も他のアプリと同様に表示され、起動できるようになっているはずです。
タップしたアイコンから始まるアニメーションをつける
アプリを起動できるようになりましたが、これだけでは起動したアプリが唐突に始まっているように見えます。
一方、一般のホームアプリは、だいたいタップしたアイコンからアプリが始まったようにみせるアニメーションをつけて、アプリを起動させています。
必須機能というわけではないですが、これを実装してみます。
AdapterクラスでonClickListenerからコールバックを呼び出しているところを変更し、アイコンのViewをコールバックの第一引数に渡すように変更します。
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
に渡します。
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) {
}
}
}
ちょっとしたことですが、「起動している感」を出すことができます。
アプリ一覧を更新できるようにする。
アプリ一覧はホームアプリ起動中も変化する可能性があります。これに対応させます。
まずは、Adapterクラスを変更して、リストを更新できるようにします。
せっかくRecyclerViewを使っているので、単にリストを入れ替えるのではなく追加削除のアニメーションをつけられるようにDiffUtilを使います。
以下の実装をAppAdapter.ktに追加します。
list
をコンストラクタではなくupdateList
というメソッド経由で渡すようにして、DiffUtilを使って更新するようにしています。
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を受け取ることができません。
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を設定します。
<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して空実装しておきます。これで終了しなくなりました。
override fun finish() {
}
壁紙を表示する
ホームアプリは壁紙が表示されるのが通常ですね。豊富なカスタマイズを提供する場合など、アプリ自身で描画して表示する場合がありますが、ひとまずシステムで設定している壁紙を表示させることにします。
システムで設定している壁紙が背景として表示されるようにするにはstyleを変更します。
Theme.wallpaperというstyleを使えばよいのですが、AppCompatActivityではAppCompat系のstyleを使う必要があるので使えません。
そこで、Theme.AppCompatを継承したスタイルを定義して壁紙の表示を指定します。
<style name="AppTheme.Wallpaper" parent="@style/Theme.AppCompat">
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowShowWallpaper">true</item>
</style>
こんな感じでシステムの壁紙が表示されるようになります。
見た目の調整
壁紙は表示できたけど、Toolbarとか邪魔ですね。
最近はステータスバーやナビゲーションバーも透けさせるのがトレンドなのでやってみましょう。
<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"
を設定して、ステータスバーとナビゲーションバー、およびノッチを考慮したパディングを設定します。
recyclerView.setOnApplyWindowInsetsListener { _, insets ->
recyclerView.setPadding(
0,
insets.systemWindowInsetTop,
0,
insets.systemWindowInsetBottom
)
insets
}
また、背景を壁紙にしたので、単色のテキストでは視認性が悪い場合があります。ドロップシャドウをつけましょう。
<TextView
...
android:shadowColor="@android:color/black"
android:shadowRadius="4"
/>
ダブルカットアウトのシミュレート付きで実行、以下のようにかぶらない位置までスクロールできるようになっています。
機能をそぎ落としすぎで、ウィジェットやショートカットも作れないし、壁紙の変更やアプリ設定へのショートカットといったホームアプリなら備えていて当たり前の機能もありませんが、超シンプルなホームアプリ完成です。