AndroidのOptionsMenuにクレジットを入れたい

少し前にこのような記事を書いたのですが
[Chrome Custom Tabsに対応しているChromeでないブラウザアプリたち](https://qiita.com/ryo_mm2d/items/9cd73f61d40addca207d)

ChromeをChrome Custom Tabsで呼び出すとオプションメニューは以下のような表示になります。
<img src="https://qiita-image-store.s3.amazonaws.com/0/155171/1eae3e8e-0b35-e0df-0c40-2288d7c65758.png" width="400px">
見た目的には標準のOptionsMenuと似たような表示なっていますが、上にボタンが配置され、下部に「**Powered by Chrome**」と入っています。

**カッコいい**ですよね？

ボタンのところまで作るのはめんどくさそうだけど、下に「Powered by ○○」と入れるだけなら比較的簡単にできるんじゃないだろうか。ということで、これの作り方を調べてみました。

最終的にできたものの見た目は以下のような感じです。

|標準のOptionsMenu|今回作ったもの|
|:-:|:-:|
|![](https://qiita-image-store.s3.amazonaws.com/0/155171/b800c109-e904-6dcd-7b76-7c4809a7d2e9.png)|![device-2018-10-25-230805.png](https://qiita-image-store.s3.amazonaws.com/0/155171/55a94133-9f57-be78-8f9f-fa06c9e53ffa.png)|

全体のソースコードは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](https://developer.android.com/reference/android/widget/ListPopupWindow)にありますが、SupportLibraryにも同名のクラスがあり、こっちを使った方がよいでしょう。[android.support.v7.widget.ListPopupWindow](https://developer.android.com/reference/android/support/v7/widget/ListPopupWindow)、AndroidXでは[androidx.appcompat.widget.ListPopupWindow](https://developer.android.com/reference/androidx/appcompat/widget/ListPopupWindow)になります。

本来のOptionsMenuの表示も実態としてはListPopupWindowが使われており、これを使えば出現時のアニメーションなども同じように表示してくれます。
内部ではListViewを持っていて、Adapterを渡すことでメニューを作ります。また、setPromptというメソッドを使ってViewを渡すとそれをListViewの上か下に表示させることができます。今回はこれを利用します。

## クレジットのレイアウトを作成

これは何でもいいのですが以下のような感じで作りました。イタリックとかドロップシャドウとか**ださいとか言わない**

```xml:layout_credit.xml
<?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を指定します。

```xml:menu/custom.xml
<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とほぼ同じものなのでこれを利用しています。
![](https://qiita-image-store.s3.amazonaws.com/0/155171/00792409-1aff-613c-c77f-55fe10261cae.png)

見た目としては以下のようになります。表示の位置が少し違いますがほぼ同じ表示になってくれています。

|OverflowMenu|Actionとして追加したもの|
|:-:|:-:|
|![](https://qiita-image-store.s3.amazonaws.com/0/155171/57dab133-72ea-8294-b49e-30caf3fc877e.png)|![](https://qiita-image-store.s3.amazonaws.com/0/155171/6521f9e4-2b7e-9c73-1454-4636d0a4722b.png)|

## ポップアップの表示

このOverflowボタンは通常のメニューなのでタップすると`onOptionItemSelected`がコールされます。
そこで以下のようにしてみます。

```kotlin
    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
    }
```

そうすると以下のようになります。
<img src="https://qiita-image-store.s3.amazonaws.com/0/155171/9919592a-e410-8e82-899d-32c129e40396.gif" width="400px">

うーん。ちゃんとクレジットはついてくれたけど、思ったのと違う。

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`の段階では取得できないので注意が必要です。

```kotlin
it.anchorView = findViewById(R.id.action_overflow) ?: toolbar
```

結果、さらに残念な感じになりました
<img src="https://qiita-image-store.s3.amazonaws.com/0/155171/d49c110c-4be1-cdee-efba-5c6545f8460c.png" width="400px">

### 幅の指定

幅について何も指定しない場合、AchorViewの幅で作成されてしまいます。当然アイコンの幅では表示に足りないので幅を指定しなければなりません。本来のOptionsMenuではメニューの内容の幅を計算した上で指定するようにしていますが、ここでは面倒なので固定値で指定してしまいます。

```kotlin
it.width = Math.round(WIDTH * resources.displayMetrics.density)
...
companion object {
    @Dimension(unit = Dimension.DP)
    private const val WIDTH = 200
}
```

結果、いい感じだけどListPopupWindowはAnchorViewの下に表示されるので所望の位置とは違っています。
<img src="https://qiita-image-store.s3.amazonaws.com/0/155171/19f0d395-3b83-490c-c9dc-380227318622.png" width="400px"/>

### オフセットの調整

Materialデザイン以降のメニューはボタンを覆い隠す形で表示されるのでその分のオフセットの調整を行います。
また、前項のスクショで分かるように右端にくっついてしまっているのでその分のオフセット調整も行います。
正直これは泥臭すぎるので適切な方法が他にあるのかもしれません。

```kotlin
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
}
```

これで見た目は完成です。
<img src="https://qiita-image-store.s3.amazonaws.com/0/155171/d61cb10e-d0e4-dcc8-21d3-17b3ec14f789.png" width="400px"/>
全体のコードはこんな感じ

```kotlin
    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"`で非表示に指定しておきます。

```xml:menu/custom.xml
<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`に流すことで、既存の仕組みと同じようにイベントを受け取ることができます。

```kotlin
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`が呼ばれたあとに表示させるようにしました。
このポップアップの仕組みをヘルパークラスに抽出し、まとめたのが以下になります。

```kotlin:CustomOptionsMenuHelper.kt
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からは以下のように使います。

```kotlin:CustomMenuActivity.kt
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
    }
}
```

# まとめ

簡単にとまではいきませんでしたが、ちょっとした細工で以下のようにクレジット付きのオプションメニューを作ることができました。
<img src="https://qiita-image-store.s3.amazonaws.com/0/155171/9539d355-d4eb-17f8-8b73-f9e7c55d72d8.gif" width="400px"/>
OptionsMenuの仕組みに乗っかるのが本当に使いやすいのか、別クラスを作るぐらいならActivityにコールバックするよりはリスナー登録できるようにした方がいいんじゃないか、などありますが、カスタマイズされたポップアップメニューはこうやれば作れるということが分かったと思うので、それぞれ工夫していけばよいと思います。