見出し画像

[Android] ToolbarのNavigation Iconをデフォルトの矢印に戻す方法の調査過程を晒してみる

NTTレゾナントテクノロジーの西添です。モバイルアプリエンジニアをやっています。

今回はAndroidのToolbarにまつわるTipsと、そのTipsに辿り着くまでの試行錯誤の過程をご紹介します。課題の解答に辿り着くまでの試行錯誤について語る人はあまりいないと思いますが、一方で、実際の業務では知らないことを調べる能力がエンジニアとして重要な能力であると思います。Androidアプリ開発を独学で勉強している方や、周りに詳しい人がいない状況で孤軍奮闘している方の中には、他のAndroidエンジニアはどんなふうに調査しているんだろう、Androidフレームワークの挙動を追ってみたいけどどうすれば目的のソースコードに辿り着けるんだろう等の疑問を抱いている方もいらっしゃるのではないでしょうか?この記事がその疑問に対する一つの回答例になれば幸いです。

解決したいこと

AndroidのToolbarの左端に配置できるアイコンのことをNavigation Iconといいます。矢印アイコン(←)やハンバーガーアイコン(☰)がよく表示されているところです。矢印アイコンを表示するための簡単なコードを以下に示します。

AndroidManifest.xml
※ themeがNoActionBarになっているのがポイントです

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.toolbarsample">

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.MaterialComponents.DayNight.NoActionBar">
        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

activity_main.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="match_parent"
    tools:context=".MainActivity">

    <androidx.appcompat.widget.Toolbar
        android:id="@+id/toolbar"
        style="@style/Widget.MaterialComponents.Toolbar.Primary"
        android:layout_width="0dp"
        android:layout_height="?attr/actionBarSize"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

MainActivity.kt

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val toolbar = findViewById<Toolbar>(R.id.toolbar)
        setSupportActionBar(toolbar)
        supportActionBar?.setDisplayHomeAsUpEnabled(true)
    }
}

上記の supportActionBar?.setDisplayHomeAsUpEnabled(true) でNavigation Iconとして矢印アイコン(←)が表示されるようになります。

supportActionBar?.setDisplayHomeAsUpEnabled(true) でToolbarに矢印アイコンが表示された

Navigation Iconには任意のDrawableを指定することができます。次のように supportActionBar?.setHomeAsUpIndicator(resId) で表示したいDrawableのリソースIDを指定します。

val toolbar = findViewById<Toolbar>(R.id.toolbar)
setSupportActionBar(toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.setHomeAsUpIndicator(android.R.drawable.star_on)
supportActionBar?.setHomeAsUpIndicator(resId) でToolbarに任意のアイコンを表示できた

さて、ここからが本題です。いまNavigation Iconをカスタマイズしている状況ですが、これをデフォルトの矢印アイコン(←)に戻すにはどうすればよいでしょうか?

解答

supportActionBar?.setHomeAsUpIndicator(null) を実行するだけでデフォルトの矢印アイコン(←)に戻せます。簡単ですね。

一応、Android API Referenceにも書いてありました。(the default drawable from the themeとはなんだろう🤔  という疑問が湧いてきますが)

If you pass null to this method, the default drawable from the theme will be used.

Android API Reference

解答に辿り着くまでの過程

1. Googleで検索する

みなさんは上述したような課題が出たときにまず何をしますか?
私はググりました。ブラウザの履歴を確認してみたら検索キーワードは「android toolbar reset navigation icon」でした。それで出てきたのはこちらのページです。

Android Toolbar Change Navigation/Back Icon/homeAsUpIndicator (Maintain Click Effect) | Lua Software Code

この記事には今回の課題にストレートに答える内容が書いてありました。

Restore Original Navigation Icon
Assuming you changed the navigation icon as per the above instruction, how do you restore the original back icon?
Option 1: R.attr.homeAsUpIndicator
(省略)
Option 2: R.drawable.abc_ic_ab_back_material
(省略)
Option 3: Save original drawable before switching
(省略)
Option 4: Material Icon
(省略)

Android Toolbar Change Navigation/Back Icon/homeAsUpIndicator (Maintain Click Effect) | Lua Software Code

方法が4つ書かれていて、よくまとまっているいい記事です。でも、ちょっと待てよと思いました。Option 1はtintが効かないのか黒い矢印アイコンが表示されてしまうし、Option 2はAndroidフレームワークのプライベートなリソースを直接参照してしまうから保守性に難があるし、Option 3は気をつけてプログラミングしないとバグを生んでしまいそうだし、Option 4に至っては自前で新しいDrawableを作成しないといけません。

もう少し筋の良い方法はないのかなと思ってしばらくググってみましたが、私のググり能力ではドンピシャなページは見つけられませんでした。本当はこの時点で上述したAndroid API Referenceを発見できていればよかったのですが、それが出てくるような検索キーワードは思いつきませんでした……

2. Toolbar/ActionBarの内部処理を追う

そこで次に私がやったのはToolbar/ActionBar自体のコードリーディングです。supportActionBar?.setDisplayHomeAsUpEnabled(true) で矢印アイコンが表示されるのであれば、その関数内に書かれている矢印アイコンの表示処理に何かヒントがあるのではないか、という発想です。

まず、Android Studioのエディタで setDisplayHomeAsUpEnable(true) にカーソルを置いて ⌘ + B を押します(ショートカットキーはmacOSの場合)。

MainActivity

するとActionBar.javaのsetDisplayHomeAsUpEnabled関数に飛びました。

ActionBar#setDisplayHomeAsUpEnabled(boolean)

しかしabstractな関数でした。この関数の実装がどこにあるのかわからないので、別のアプローチで探ることにします。MainActivityの方に戻って、今度はToolbarの上にカーソルを置いて ⌘ + B を押します。

Toolbarクラス

見ての通りToolbarクラスはActionBarクラスを継承していないので目的のsetDisplayHomeAsUpEnabled関数はあるはずがありませんが、Navigation Iconを表示する処理は必ず書かれているはずです。⌘ + F で検索窓を開き「navigationicon」というキーワードで検索してみます。すると、setNavigationIconという関数が見つかりました。それっぽい雰囲気を感じます。

Toolbar#setNavigationIcon(Drawable)

setNavigationIcon関数の先頭にブレークポイントを設定し、デバッグ実行します。

ブレークポイントを設定してデバッグ実行する

暫く待つと、画面下部に[Debug] ウィンドウが表示され、指定したブレークポイントで処理が止まります。

Android StudioのDebug Tool Window

画面左側には以下のスタックトレースが表示されています。
※ 以下のテキストはスタックトレースを右クリックして「Copy Stack」を選択するとクリップボードにコピーされます。手打ちしたわけではありません。

setNavigationIcon:1012, Toolbar (androidx.appcompat.widget)
updateNavigationIcon:615, ToolbarWidgetWrapper (androidx.appcompat.widget)
setDisplayOptions:396, ToolbarWidgetWrapper (androidx.appcompat.widget)
setDisplayOptions:263, ToolbarActionBar (androidx.appcompat.app)
setDisplayHomeAsUpEnabled:278, ToolbarActionBar (androidx.appcompat.app)
onCreate:17, MainActivity (com.example.toolbarsample)
performCreate:7994, Activity (android.app)
performCreate:7978, Activity (android.app)
callActivityOnCreate:1309, Instrumentation (android.app)
performLaunchActivity:3404, ActivityThread (android.app)
handleLaunchActivity:3595, ActivityThread (android.app)
execute:85, LaunchActivityItem (android.app.servertransaction)
executeCallbacks:135, TransactionExecutor (android.app.servertransaction)
execute:95, TransactionExecutor (android.app.servertransaction)
handleMessage:2066, ActivityThread$H (android.app)
dispatchMessage:106, Handler (android.os)
loop:223, Looper (android.os)
main:7664, ActivityThread (android.app)
invoke:-1, Method (java.lang.reflect)
run:592, RuntimeInit$MethodAndArgsCaller (com.android.internal.os)
main:947, ZygoteInit (com.android.internal.os)

上から5行目に目的のToolbarActionBar#setDisplayHomeAsUpEnabled(boolean)があります。そこをクリックすると関数の定義箇所に飛びます。

ToolbarActionBar#setDisplayHomeAsUpEnabled(boolean)

今showHomeAsUp変数がtrueであり、 setDisplayOptions( DISPLAY_HOME_AS_UP, DISPLAY_HOME_AS_UP) が実行されることがわかります。ではToolbarActionBar#setDisplayOptions(int, int)の定義に飛んでみましょう。

ToolbarActionBar#setDisplayOptions(int, int)

なにやらビット演算をしていますね。次のToolbarWidgetWrapper#setDisplayOptions(int)に飛びます。

ToolbarWidgetWrapper#setDisplayOptions(int)

この関数も読んでもあまり意味がなさそうなので、次のToolbarWidgetWrapper#updateNavigationIcon()に飛びます。

ToolbarWidgetWrapper#updateNavigationIcon()

ここが一番見たかったコードですね。デバッガによるとmNavIconはnullなので、 mToolbar.setNavigationIcon(mDefaultNavigationIcon); が実行されます。この mDefaultNavigationIcon にはVectorDrawableが格納されているようです。では、どんなDrawableが格納されているのでしょうか?

mDefaultNavigationIconの上にマウスカーソルをのせて右クリックし、[Find Usages]を選択します。

Find Usages

そうすると画面下部の[Find] ウィンドウが自動で開き、mDefaultNavigationIcon変数をreadしている箇所とwriteしている箇所の一覧が表示されます。

mDefaultNavigationIcon変数のUsage

[Value write] の1箇目が初期値を代入しているコードです。R.styleable.ActionBar_homeAsUpIndicatorに対応するDrawableが代入されています。

public ToolbarWidgetWrapper(Toolbar toolbar, boolean style,
        int defaultNavigationContentDescription, int defaultNavigationIcon) {
    (省略)
    final TintTypedArray a = TintTypedArray.obtainStyledAttributes(toolbar.getContext(),
                null, R.styleable.ActionBar, R.attr.actionBarStyle, 0);
    mDefaultNavigationIcon = a.getDrawable(R.styleable.ActionBar_homeAsUpIndicator);

R.styleable.ActionBar_homeAsUpIndicatorはどのようなDrawableかを知りたいので、AndroidManifest.xml の android:theme で指定しているテーマの親を ⌘ + B で地道に辿っていくと、最終的に以下の内容の values.xml に辿り着きました。

...
<style name="Base.V7.Theme.AppCompat" parent="Platform.AppCompat">
    ...
    <item name="homeAsUpIndicator">@drawable/abc_ic_ab_back_material</item>
    ...

@drawable/abc_ic_ab_back_material の中身を見るために、abc_ic_ab_back_material の上にカーソルを置いて ⌘ + B を押します。

@drawable/abc_ic_ab_back_material

ようやくデフォルトの矢印アイコンのDrawableに辿り着きました。ここまでをまとめると、ToolbarWidgetWrapperのmDefaultNavigationIcon変数にデフォルトの矢印アイコンのDrawableが格納されていることがわかりました。

ここで改めてToolbarWidgetWrapper#updateNavigationIcon()を見直します。

mToolbar.setNavigationIcon(mNavIcon != null ? mNavIcon : mDefaultNavigationIcon);

mNavIcon変数がnullであればsetNavigationIcon関数の引数がmDefaultNavigationIcon変数になり、デフォルトの矢印アイコンが表示されるということがわかります。この行にブレークポイントを設定し、再度デバッグ実行します。そうすると、下記の2関数のそれぞれの実行時にToolbarWidgetWrapper#updateNavigationIcon()が呼ばれることがわかります。

  • supportActionBar?.setDisplayHomeAsUpEnabled(true)

  • supportActionBar?.setHomeAsUpIndicator(android.R.drawable.star_on)

supportActionBar?.setHomeAsUpIndicator(android.R.drawable.star_on) 実行時のToolbarWidgetWrapper#updateNavigationIcon()

supportActionBar?.setHomeAsUpIndicator(android.R.drawable.star_on)の実行時点では、mNavIcon変数にBitmapが格納されています(上図参照)。ということは、supportActionBar?.setHomeAsUpIndicator(null) を実行すればToolbarWidgetWrapper#updateNavigationIcon()でmNavIcon == null となるのではないかという仮説が立てられます。

ちなみに、この時点でのスタックトレースは以下のようになっています。

updateNavigationIcon:615, ToolbarWidgetWrapper (androidx.appcompat.widget)
setNavigationIcon:597, ToolbarWidgetWrapper (androidx.appcompat.widget)
setNavigationIcon:602, ToolbarWidgetWrapper (androidx.appcompat.widget)
setHomeAsUpIndicator:164, ToolbarActionBar (androidx.appcompat.app)
onCreate:18, MainActivity (com.example.toolbarsample)
(以下略)

それでは実際に確かめてみましょう。ソースコードに supportActionBar?.setHomeAsUpIndicator(null) を追記し、もう一度デバッグ実行します。ToolbarWidgetWrapper#updateNavigationIcon()にブレークポイントを設定したままなので、いま追記した supportActionBar?.setHomeAsUpIndicator(null) の実行時に処理が止まります。

supportActionBar?.setHomeAsUpIndicator(null) 実行時のToolbarWidgetWrapper#updateNavigationIcon()

想定通り、mNavIconはnullになっていますね。画面上もデフォルトの矢印アイコンが表示されていました。

なお、このときのスタックトレースは以下のようになっていました。Drawableが引数になっている方のsetHomeAsUpIndicatorが呼ばれ、リソースID指定の場合と比べて一段浅くなっていますね。

updateNavigationIcon:615, ToolbarWidgetWrapper (androidx.appcompat.widget)
setNavigationIcon:597, ToolbarWidgetWrapper (androidx.appcompat.widget)
setHomeAsUpIndicator:159, ToolbarActionBar (androidx.appcompat.app)
onCreate:19, MainActivity (com.example.toolbarsample)
(以下略)

私の場合はここまで来てようやくAndroid API ReferenceでsetHomeAsUpIndicatorのドキュメントを見て、なんだ載っているじゃないか!と思いました。面倒臭がらずに英語のドキュメントもちゃんと読みましょう。そういえば先日、3人で延べ4人日くらいかけて調査していた不具合の解決方法がそのライブラリのREADMEの一番下に書いてあったことがありました。英語で書かれたREADMEもちゃんと読みましょう。

別解

ところでAndroid API Referenceを見るとsetHomeAsUpIndicator関数は引数の型が違うものが2種類あります。

nullを渡すと setHomeAsUpIndicator (Drawable indicator) の方が実行されます。ではint型の方でもデフォルトの矢印アイコンに戻せるのかというと、0を渡せばいいとAndroid API Referenceに書いてありました。

If you pass 0 to this method, the default drawable from the theme will be used.

Android API Reference

実際、 supportActionBar?.setHomeAsUpIndicator(0) でデフォルトの矢印アイコンに戻りました。

反省

正解に辿り着くまでにだいぶ遠回りしてしまいました。そもそも、最初にAPI Referenceを読んでおけばすぐに解決したわけですね。setHomeAsUpIndicator関数でカスタムのDrawableを指定しているのだから同じ関数でリセットできるはずと考えて、setHomeAsUpIndicator関数にカーソルを置いて F1 キーを押してドキュメントを表示してちゃんと読めばよかったわけです。

Quick Documentation

しかし当時の私の心境としては、最初にググって出てきたページに矢印アイコンのDrawableを改めて指定する方法しか書かれていなかったことから、「なんとかしてデフォルトの矢印アイコンのリソースを探してこないといけない」と思い込んでしまいました。まさかデフォルトのアイコンがToolbarWidgetWrapperクラスのインスタンス変数に保持されていて、後からそれに戻せるなんて思いもよりませんでした。

得られた教訓としては、ググる前にまずドキュメントを読め、になるでしょうか。場合によりけりでしょうけど……

まとめ

今回は恥ずかしながら私の調査過程を晒してみたわけですが、参考になったでしょうか?もちろんこれがベストな調査方法だとは考えていないですし、これ以外の調査方法もあるでしょうから、あくまで一例と捉えていただければと思います。

宣伝

NTTレゾナントテクノロジーでは一緒に働いてくれるAndroid/iOSアプリエンジニアを募集中です。もし興味がありましたら採用ページを是非ご覧ください。

この記事が気に入ったらサポートをしてみませんか?