少し前にこのような記事を書いたのですが
Chrome Custom Tabsに対応しているChromeでないブラウザアプリたち
ChromeをChrome Custom Tabsで呼び出すとオプションメニューは以下のような表示になります。
見た目的には標準のOptionsMenuと似たような表示なっていますが、上にボタンが配置され、下部に「Powered by Chrome」と入っています。
カッコいいですよね?
ボタンのところまで作るのはめんどくさそうだけど、下に「Powered by ○○」と入れるだけなら比較的簡単にできるんじゃないだろうか。ということで、これの作り方を調べてみました。
最終的にできたものの見た目は以下のような感じです。
標準のOptionsMenu | 今回作ったもの |
---|---|
全体のソースコードはGitHubにあります。
https://github.com/ohmae/custom-options-menu-sample
標準のOptionsMenuはカスタマイズ可能か?
結論からいうと無理です。
checkboxやradiogroupを作ることまでできますが、表示を弄る仕組みが用意されていません。
実際にこのメニューを表示させている実装がどこにあるのかをToolbarのソースコードから追いかけていくと
Toolbar→ActionMenuView→ActionMenuPresenter→OverflowPopup→MenuPopupHelper→StandardMenuPopup→MenuPopupWindow
is-a/has-a関係がごちゃごちゃになっていますが、これだけ追いかけてMenuPopupWindowにたどり着きました。このクラスはListPopupWindowのサブクラスでOptionsMenuのポップアップ部分の表示を行っています。
このインスタンスを取得できればどうにかできそうではありますが、そもそもメソッドがない、あってもメソッドやクラスがpublicじゃない、@hide
や@RestrictTo(LIBRARY_GROUP)
がついていたりする。階層も深いしリフレクションでたたくのも非現実的です。
ListPopupWindowをつかってメニューを自作する
Toolbarなどに対する指定ではカスタマイズできないと分かりました。
そのため、メニューの仕組みを自前で作るしかありません。
完全に独自の仕組みにしてしまえば何でもありですが、OptionsMenuの作り方から極力外れない方法で実現する方法を探ります。
menuをxmlで定義できたりする部分はできればそのままでいきたいですし、見た目やアニメーションも妥協無くいきたいところです。
そこで、ListPopupWindowを使うことにします。
ListPopupWindowを使うと簡単にポップアップメニューを作ることができます。
android.widget.ListPopupWindowにありますが、SupportLibraryにも同名のクラスがあり、こっちを使った方がよいでしょう。android.support.v7.widget.ListPopupWindow、AndroidXではandroidx.appcompat.widget.ListPopupWindowになります。
本来のOptionsMenuの表示も実態としてはListPopupWindowが使われており、これを使えば出現時のアニメーションなども同じように表示してくれます。
内部ではListViewを持っていて、Adapterを渡すことでメニューを作ります。また、setPromptというメソッドを使ってViewを渡すとそれをListViewの上か下に表示させることができます。今回はこれを利用します。
クレジットのレイアウトを作成
これは何でもいいのですが以下のような感じで作りました。イタリックとかドロップシャドウとかださいとか言わない
<?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:orientation="vertical"
>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/border_credit"
/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="end"
android:paddingBottom="2dp"
android:paddingEnd="8dp"
android:paddingStart="8dp"
android:paddingTop="0dp"
android:shadowColor="@color/shadow_credit"
android:shadowDx="2.0"
android:shadowDy="2.0"
android:shadowRadius="4.0"
android:text="@string/credit"
android:textColor="@color/text_credit"
android:textSize="12sp"
android:textStyle="italic"
/>
</LinearLayout>
OverflowButtonを作る
OptionsMenuはapp:showAsAction
がneverになっているものがあるか、ifRoomになっているものがToolbarに入るスペースがない場合にOverflowMenuButtonが表示され、それをタップしたときにポップアップメニューとして表示されます。
しかし、本来のメニューが表示されてしまうと困るので、ポップアップとして出てくる分は少なくとも非表示にしておく必要があります。そうするとOverflowMenuButton自体が表示されません。そこで、toolbarにActionとしてOverflowMenuButton相当のメニューを一つ作ることにします。
これだけは絶対にActionとして表示されないといけないのでapp:showAsAction
はalwaysを指定します。
<menu
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"
tools:context="net.mm2d.myapplication.MainActivity"
>
<item
android:id="@+id/action_overflow"
android:icon="@drawable/ic_overflow"
android:orderInCategory="0"
android:title="@string/action_overflow"
app:showAsAction="always"
tools:ignore="AlwaysShowAction"
/>
</menu>
アイコンは「more vert」というClip ArtがOverflowButtonとほぼ同じものなのでこれを利用しています。
見た目としては以下のようになります。表示の位置が少し違いますがほぼ同じ表示になってくれています。
OverflowMenu | Actionとして追加したもの |
---|---|
ポップアップの表示
このOverflowボタンは通常のメニューなのでタップするとonOptionItemSelected
がコールされます。
そこで以下のようにしてみます。
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_overflow ->
ListPopupWindow(this).also {
it.setDropDownGravity(Gravity.END)
it.setPromptView(layoutInflater.inflate(R.layout.layout_credit, toolbar, false))
it.promptPosition = ListPopupWindow.POSITION_PROMPT_BELOW
it.inputMethodMode = PopupWindow.INPUT_METHOD_NOT_NEEDED
val adapter = ArrayAdapter<String>(this, layout.simple_list_item_1)
adapter.add(getString(R.string.action_share))
adapter.add(getString(R.string.action_settings))
it.setAdapter(adapter)
it.anchorView = toolbar
}.show()
else -> Toast.makeText(this, item.title, Toast.LENGTH_LONG).show()
}
return true
}
うーん。ちゃんとクレジットはついてくれたけど、思ったのと違う。
ListPopupWindowはanchorViewとして設定したViewと同じ幅になってしまうので、Toolbarを指定するのがまずいようです。
またアニメーションの起点もanchorViewになるので幅を別途指定するにしてもToolbarをanchorViewとするとおかしくなります。
表示の補正
AnchorView
まず、anchorViewについてですが、Toolbarではなくタップ対象であるActionのViewにすべきですね。
このViewはActionMenuItemViewというクラスでToolbar→ActionMenuView→ActionMenuItemViewという階層に作成されます。
ViewのIDとしてmenuのxmlで指定したメニューのIDがそのまま使われているので、findViewById
で取得できます。
ただし、メニューが表示されたあとでなければこのViewは存在しないので、onCreate
やonCreateOptionsMenu
、初回のonPrepareOptionsMenu
の段階では取得できないので注意が必要です。
it.anchorView = findViewById(R.id.action_overflow) ?: toolbar
幅の指定
幅について何も指定しない場合、AchorViewの幅で作成されてしまいます。当然アイコンの幅では表示に足りないので幅を指定しなければなりません。本来のOptionsMenuではメニューの内容の幅を計算した上で指定するようにしていますが、ここでは面倒なので固定値で指定してしまいます。
it.width = Math.round(WIDTH * resources.displayMetrics.density)
...
companion object {
@Dimension(unit = Dimension.DP)
private const val WIDTH = 200
}
結果、いい感じだけどListPopupWindowはAnchorViewの下に表示されるので所望の位置とは違っています。
オフセットの調整
Materialデザイン以降のメニューはボタンを覆い隠す形で表示されるのでその分のオフセットの調整を行います。
また、前項のスクショで分かるように右端にくっついてしまっているのでその分のオフセット調整も行います。
正直これは泥臭すぎるので適切な方法が他にあるのかもしれません。
val margin = Math.round(MARGIN * resources.displayMetrics.density)
it.verticalOffset = -anchorView.height
it.horizontalOffset = -margin
...
companion object {
@Dimension(unit = Dimension.DP)
private const val MARGIN = 4
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_overflow ->
ListPopupWindow(this).also {
it.width = Math.round(WIDTH * resources.displayMetrics.density)
it.setDropDownGravity(Gravity.END)
it.setPromptView(layoutInflater.inflate(R.layout.layout_credit, toolbar, false))
it.promptPosition = ListPopupWindow.POSITION_PROMPT_BELOW
it.inputMethodMode = PopupWindow.INPUT_METHOD_NOT_NEEDED
val adapter = ArrayAdapter<String>(this, layout.simple_list_item_1)
adapter.add(getString(R.string.action_share))
adapter.add(getString(R.string.action_settings))
it.setAdapter(adapter)
val margin = Math.round(MARGIN * resources.displayMetrics.density)
val anchorView: View = findViewById(R.id.action_overflow) ?: toolbar
it.anchorView = anchorView
it.verticalOffset = -anchorView.height
it.horizontalOffset = -margin
}.show()
else -> Toast.makeText(this, item.title, Toast.LENGTH_LONG).show()
}
return true
}
扱い方をOptionsMenuに近づける
これまではArrayAdapter<String>
を使っていましたが、クリックイベントの受け取りまで考えると機能が不足していますし、できればOptionsMenuと同じようにxmlで定義できるようにしたいので、その変更を行います。
menuのxmlで以下のようにポップアップで表示するoverflowというgroupを作り、android:visible="false"
で非表示に指定しておきます。
<menu
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"
tools:context="net.mm2d.myapplication.MainActivity"
>
<item
android:id="@+id/action_overflow"
android:icon="@drawable/ic_overflow"
android:orderInCategory="0"
android:title="@string/action_overflow"
app:showAsAction="always"
tools:ignore="AlwaysShowAction"
/>
<group
android:id="@+id/overflow"
android:visible="false"
>
<item
android:id="@+id/action_share"
android:orderInCategory="100"
android:title="@string/action_share"
app:showAsAction="never"
/>
<item
android:id="@+id/action_settings"
android:orderInCategory="100"
android:title="@string/action_settings"
app:showAsAction="never"
/>
</group>
</menu>
ArrayAdapterの型引数にMenuItemを指定したものを作り、inflateしたMenuからoverflowのグループになっているものを入れるようにします。
MenuItemのtoString
はtitleの文字列を返すのでこのままでメニューを作ることができます。
メニューのクリックイベントはactivityのonOptionsItemSelected
に流すことで、既存の仕組みと同じようにイベントを受け取ることができます。
val adapter = ArrayAdapter<MenuItem>(activity, android.R.layout.simple_list_item_1)
menu.forEach {
if (it.groupId == R.id.overflow) {
adapter.add(it)
}
}
it.setAdapter(adapter)
it.setOnItemClickListener { _, _, position, _ ->
onOptionsItemSelected(adapter.getItem(position))
it.dismiss()
}
onPrepareOptionsMenu が実行されるようにする
通常のOptionsMenuでは、ポップアップメニューが表示される前にonPrepareOptionsMenu
がコールされるため、ここでメニューの変更などができますが、本来のポップアップが表示されないようにしているため実行されません。
そのため表示するときはinvalidateOptionsMenu
をコールし、onPrepareOptionsMenu
が呼ばれたあとに表示させるようにしました。
このポップアップの仕組みをヘルパークラスに抽出し、まとめたのが以下になります。
class CustomOptionsMenuHelper(activity: Activity, toolbarId: Int, private val overflowIconId: Int) {
private val activityReference = WeakReference(activity)
private val toolbar = activity.findViewById<Toolbar>(toolbarId)
private val adapter = ArrayAdapter<MenuItem>(activity, android.R.layout.simple_list_item_1)
private val margin = Math.round(MARGIN * activity.resources.displayMetrics.density)
private val popup = ListPopupWindow(activity).also {
it.width = Math.round(WIDTH * activity.resources.displayMetrics.density)
it.setDropDownGravity(Gravity.END)
it.setPromptView(activity.layoutInflater.inflate(R.layout.layout_credit, toolbar, false))
it.promptPosition = ListPopupWindow.POSITION_PROMPT_BELOW
it.inputMethodMode = PopupWindow.INPUT_METHOD_NOT_NEEDED
it.setAdapter(adapter)
it.setOnItemClickListener { _, _, position, _ ->
activity.onOptionsItemSelected(adapter.getItem(position))
it.dismiss()
}
}
private var invalidateBySelect = false
fun onPrepareOptionsMenu(menu: Menu, overflowGroupId: Int): Boolean {
if (!invalidateBySelect) {
return true
}
invalidateBySelect = false
adapter.clear()
menu.forEach {
if (it.groupId == overflowGroupId) {
adapter.add(it)
}
}
val anchorView = toolbar.findViewById<View>(overflowIconId) ?: toolbar
popup.anchorView = anchorView
popup.verticalOffset = -anchorView.height
popup.horizontalOffset = -margin
popup.show()
return true
}
fun onSelectOverflowMenu() {
invalidateBySelect = true
activityReference.get()?.invalidateOptionsMenu()
}
companion object {
@Dimension(unit = Dimension.DP)
private const val MARGIN = 4
@Dimension(unit = Dimension.DP)
private const val WIDTH = 200
}
}
Activityからは以下のように使います。
class CustomMenuActivity : AppCompatActivity() {
private lateinit var optionsMenu: CustomOptionsMenuHelper
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_custom_menu)
setSupportActionBar(toolbar)
optionsMenu = CustomOptionsMenuHelper(this, R.id.toolbar, R.id.action_overflow)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.custom, menu)
return true
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
return optionsMenu.onPrepareOptionsMenu(menu, R.id.overflow)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_overflow -> optionsMenu.onSelectOverflowMenu()
else -> Toast.makeText(this, item.title, Toast.LENGTH_LONG).show()
}
return true
}
}
まとめ
簡単にとまではいきませんでしたが、ちょっとした細工で以下のようにクレジット付きのオプションメニューを作ることができました。
OptionsMenuの仕組みに乗っかるのが本当に使いやすいのか、別クラスを作るぐらいならActivityにコールバックするよりはリスナー登録できるようにした方がいいんじゃないか、などありますが、カスタマイズされたポップアップメニューはこうやれば作れるということが分かったと思うので、それぞれ工夫していけばよいと思います。