LoginSignup
5
5

More than 5 years have passed since last update.

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

Last updated at Posted at 2018-10-25

少し前にこのような記事を書いたのですが
Chrome Custom Tabsに対応しているChromeでないブラウザアプリたち

ChromeをChrome Custom Tabsで呼び出すとオプションメニューは以下のような表示になります。

見た目的には標準のOptionsMenuと似たような表示なっていますが、上にボタンが配置され、下部に「Powered by Chrome」と入っています。

カッコいいですよね?

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

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

標準のOptionsMenu 今回作ったもの
device-2018-10-25-230805.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にありますが、SupportLibraryにも同名のクラスがあり、こっちを使った方がよいでしょう。android.support.v7.widget.ListPopupWindow、AndroidXではandroidx.appcompat.widget.ListPopupWindowになります。

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

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

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

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を指定します。

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とほぼ同じものなのでこれを利用しています。

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

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は存在しないので、onCreateonCreateOptionsMenu、初回の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/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に流すことで、既存の仕組みと同じようにイベントを受け取ることができます。

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

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からは以下のように使います。

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

まとめ

簡単にとまではいきませんでしたが、ちょっとした細工で以下のようにクレジット付きのオプションメニューを作ることができました。

OptionsMenuの仕組みに乗っかるのが本当に使いやすいのか、別クラスを作るぐらいならActivityにコールバックするよりはリスナー登録できるようにした方がいいんじゃないか、などありますが、カスタマイズされたポップアップメニューはこうやれば作れるということが分かったと思うので、それぞれ工夫していけばよいと思います。

5
5
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
5
5