Support Design LibraryのBottomNavigationViewを読んだ

Posted by kwmt27 on Thu, Mar 26, 2020

はじめに

support design libraryの特にBottomNavigationViewを読んでみました。

ガイドライン原文日本語訳に書いてあるのが、どのようにコードで書かれているのかという視点で読みました。

ちなみに、BottomNavigationというのはこのようなものです。

BottomNavigationViewを読む

[ガイドライン]最上位の移動先を3~5個表示する

  • 6個にした場合

IllegalArgumentExceptionが出ます。

  • 2個にした場合

Exceptionは出ずに、2個表示されます。

BottomNavigationViewBottomNavivgationMenu (MenuBuilder) に対して、app:menuで指定した app:menu="@menu/navigation" navigation.xmlをinflateする際にBottomNavivgationMenuにaddします。

BottomNavigationViewのコンストラクタでapp:menu="@menu/navigation"をinflateします。

if (a.hasValue(R.styleable.BottomNavigationView_menu)) {
    inflateMenu(a.getResourceId(R.styleable.BottomNavigationView_menu, 0));
}

XMLをパースしながらBottomNavivgationMenuインスタンスに対してaddしているところがあり、

public void addItem() {
    itemAdded = true;
    setItem(menu.add(groupId, itemId, itemCategoryOrder, itemTitle));
}
※`menu`が`BottomNavivgationMenu`インスタンスとなる。

この時、実際にaddする前にMenuのサイズがMAX_ITEM_COUNT(5)個より多かったら、IllegalArgumentExceptionを出しているようです。

if (size() + 1 > MAX_ITEM_COUNT) {
    throw new IllegalArgumentException(
            "Maximum number of items supported by BottomNavigationView is " + MAX_ITEM_COUNT
                    + ". Limit can be checked with BottomNavigationView#getMaxItemCount()");
}
stopDispatchingItemsChanged();
final MenuItem item = super.addInternal(group, id, categoryOrder, title);

3個未満については、特にチェックしていないので、表示されてしまいます。

[ガイドライン]アプリのメインの色でアクティブなアイコンを色付けします。

colorPrimaryを次のように設定している場合、

<color name="colorPrimary">#3F51B5</color>

と青く表示されて、colorPrimaryを変更するだけで下図のようにアイコンの色(とテキストの色)を変更できます。

BottomNavigationViewのコンストラクタで、xmlでitemIconTintitemTextColorをセットしてないければ、デフォルトカラーをセットするようになっていました。

if (a.hasValue(R.styleable.BottomNavigationView_itemIconTint)) {
    mMenuView.setIconTintList(
            a.getColorStateList(R.styleable.BottomNavigationView_itemIconTint));
} else {
    mMenuView.setIconTintList(
            createDefaultColorStateList(android.R.attr.textColorSecondary));
}
if (a.hasValue(R.styleable.BottomNavigationView_itemTextColor)) {
    mMenuView.setItemTextColor(
            a.getColorStateList(R.styleable.BottomNavigationView_itemTextColor));
} else {
    mMenuView.setItemTextColor(
            createDefaultColorStateList(android.R.attr.textColorSecondary));
}

createDefaultColorStateListメソッドは次のようになっています。

private ColorStateList createDefaultColorStateList(int baseColorThemeAttr) {
    final TypedValue value = new TypedValue();
    if (!getContext().getTheme().resolveAttribute(baseColorThemeAttr, value, true)) {
        return null;
    }
    ColorStateList baseColor = AppCompatResources.getColorStateList(
            getContext(), value.resourceId);
    if (!getContext().getTheme().resolveAttribute(
            android.support.v7.appcompat.R.attr.colorPrimary, value, true)) {
        return null;
    }
    int colorPrimary = value.data;
    int defaultColor = baseColor.getDefaultColor();
    return new ColorStateList(new int[][]{
            DISABLED_STATE_SET,
            CHECKED_STATE_SET,
            EMPTY_STATE_SET
    }, new int[]{
            baseColor.getColorForState(DISABLED_STATE_SET, defaultColor),
            colorPrimary,
            defaultColor
    });
}

下記の部分で、動的にテーマに設定されているcolorPrimaryを取得しています。

    final TypedValue value = new TypedValue();
    if (!getContext().getTheme().resolveAttribute(
            android.support.v7.appcompat.R.attr.colorPrimary, value, true)) {
        return null;
    }
    int colorPrimary = value.data;

参考: Android コードからテーマの属性値を取得する

あと、ColorStateListは、状態に対応したカラーを設定するリストになりますので、

return new ColorStateList(new int[][]{
        DISABLED_STATE_SET,
        CHECKED_STATE_SET,
        EMPTY_STATE_SET
}, new int[]{
        baseColor.getColorForState(DISABLED_STATE_SET, defaultColor),
        colorPrimary,
        defaultColor
});

この場合だと、選択状態(CHECKED_STATE_SET)のとき、colorPrimaryカラーを設定しています。

[ガイドライン]高さ: 56 dp

高さは、design_bottom_navigation_heightが56dpと定義されており、

<dimen name="design_bottom_navigation_height">56dp</dimen>

それをこちらで取得して、onMesureでセットしています。

[ガイドライン]アイコン: 24 x 24 dp、テキストの下に 10 dp、テキストサイズRoboto Regular 14sp (active view)、Roboto Regular 12sp (inactive view)

アイコン: 24 x 24 dp

メニュー1個を表すClassはBottomNavigationItemView になりますが、レイアウトはdesign_bottom_navigation_item.xmlで決めれています。

xmlの中身を見るとImageViewがアイコンになる箇所で、layout_width,layout_heightに24dpが直に記入されていました。

<ImageView
    android:id="@+id/icon"
    android:layout_width="24dp"
    android:layout_height="24dp"
    android:layout_gravity="center_horizontal"
    android:layout_marginTop="@dimen/design_bottom_navigation_margin"
    android:layout_marginBottom="@dimen/design_bottom_navigation_margin"
    android:duplicateParentState="true" />
<android.support.design.internal.BaselineLayout
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="bottom|center_horizontal"
    android:clipToPadding="false"
    android:paddingBottom="10dp"
    android:duplicateParentState="true">
    <TextView
        android:id="@+id/smallLabel"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="@dimen/design_bottom_navigation_text_size"
        android:singleLine="true"
        android:duplicateParentState="true" />
    <TextView
        android:id="@+id/largeLabel"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:visibility="invisible"
        android:textSize="@dimen/design_bottom_navigation_active_text_size"
        android:singleLine="true"
        android:duplicateParentState="true" />
</android.support.design.internal.BaselineLayout>

テキストの下に 10 dp

こちらは上記XMLをみたらすぐわかります。

android:paddingBottom="10dp"

となっていました。

テキストサイズRoboto Regular 14sp (active view)、Roboto Regular 12sp

こちらもXMLを見たらすぐわかります。smallLabelにdesign_bottom_navigation_text_size(12sp)が設定され、largeLabelにdesign_bottom_navigation_active_text_size(14sp)が設定されています。

[ガイドライン]アクティブ時アイコン上部のパディングは6dp, 非アクティブ時アイコン上部のパディングは8dp

アイコンのアニメーションはどのように実装されているのか?

について見ていく中でガイドラインを確認して行きたいと思います。

この記事の最初と同じものですが、

アイコンの動きをよく見ると、選択時にアイコンが少し上にアニメーションして動き、非選択時に下にアニメーションしているのがわかるかと思います。これをどのように実装しているかを見ていきます。

BottomNavigationMenuViewOnClickListenerでクリックを検知してMenuPresenterをimplementsしたBottomNavigationPresenter#updateMenuViewが呼ばれ、BottomNavigationMenuView#updateMenuViewが呼ばれます。

BottomNavigationMenuView#updateMenuViewを抜き出すと下記になります。

public void updateMenuView() {
    final int menuSize = mMenu.size();
    if (menuSize != mButtons.length) {
        // The size has changed. Rebuild menu view from scratch.
        buildMenuView();
        return;
    }
    int previousSelectedId = mSelectedItemId;
    for (int i = 0; i < menuSize; i++) {
        mPresenter.setUpdateSuspended(true);
        MenuItem item = mMenu.getItem(i);
        if (item.isChecked()) {
            mSelectedItemId = item.getItemId();
            mSelectedItemPosition = i;
        }
        mButtons[i].initialize((MenuItemImpl) item, 0);
        mPresenter.setUpdateSuspended(false);
    }
    if (previousSelectedId != mSelectedItemId) {
        mAnimationHelper.beginDelayedTransition(this);
    }
}

まず、mButtons

private BottomNavigationItemView[] mButtons;

このように定義されていますが、BottomNavigationItemView#initializeメソッドの中で、setCheckedメソッドを呼んでその中でチェック状態によって、アイコンのtopmarginを変更することによって移動させています。

@Override
public void setChecked(boolean checked) {
    // 省略
    if (checked) {
        LayoutParams iconParams = (LayoutParams) mIcon.getLayoutParams();
        iconParams.gravity = Gravity.CENTER_HORIZONTAL | Gravity.TOP;
        iconParams.topMargin = mDefaultMargin + mShiftAmount;
        mIcon.setLayoutParams(iconParams);
        // 省略
    } else {
        LayoutParams iconParams = (LayoutParams) mIcon.getLayoutParams();
        iconParams.gravity = Gravity.CENTER_HORIZONTAL | Gravity.TOP;
        iconParams.topMargin = mDefaultMargin;
        mIcon.setLayoutParams(iconParams);
        // 省略
    }
}

mDefaultMarginが8dpでmShiftAmountが-2dpになるので、アクティブ時(選択時)は6dpになります。

これだけだったら切り替わり時にパッと切り替わるだけなので、なんでアニメーションしているのかと思ったら、ポイントはupdateMenuViewメソッドの最後の下記の部分でアニメーションを実行させていました。

mAnimationHelper.beginDelayedTransition(this);

こちらの実態はICS以降の場合は下記を呼びます。ICSより以前はサポートされてませんので、メソッドの中身は空の状態です。

TransitionManager.beginDelayedTransition(view, mSet);

こちらのTransitionの詳細は荒木さんのyoutubeを見るのが分かりやすいかと思います。Transition Support Libraryについては、7:23あたりから聞くことが出来ます。

[ガイドライン]アクションが4つまたは5つの場合は、アクティブではないビューにはアイコンしか表示しません。

BottomNavigationMenuViewがメニューを作成する際にbuildMenuViewメソッドが呼ばれますが、そこでmShiftingMode = mMenu.size() > 3; とメニュー数が4か5個だったらmShiftingModeをtrueにセットしています。このmShiftingModeを使っていろいろ判断しているようです。

先程のBottomNavigationItemView#setCheckedメソッドで、mLargeLabelのみ使って、mSmallLabelをINVISIBLEにすることで実現していました。

@Override
public void setChecked(boolean checked) {
    if (mShiftingMode) {
        if (checked) {
            LayoutParams iconParams = (LayoutParams) mIcon.getLayoutParams();
            iconParams.gravity = Gravity.CENTER_HORIZONTAL | Gravity.TOP;
            iconParams.topMargin = mDefaultMargin;
            mIcon.setLayoutParams(iconParams);
            mLargeLabel.setVisibility(VISIBLE);
            mLargeLabel.setScaleX(1f);
            mLargeLabel.setScaleY(1f);
        } else {
            LayoutParams iconParams = (LayoutParams) mIcon.getLayoutParams();
            iconParams.gravity = Gravity.CENTER;
            iconParams.topMargin = mDefaultMargin;
            mIcon.setLayoutParams(iconParams);
            mLargeLabel.setVisibility(INVISIBLE);
            mLargeLabel.setScaleX(0.5f);
            mLargeLabel.setScaleY(0.5f);
        }
        mSmallLabel.setVisibility(INVISIBLE);
    } else {
        //省略
    }
}

[ガイドライン]Shifting bottom navigation barのWidthのサイズ

Active view
    Maximum: 168dp
    Minimum: 96dp
Inactive view
    Maximum: 96dp
    Minimum: 56dp

BotomNavigationMenuView#onMeasure で計算してました。

final int inactiveCount = count - 1;
final int activeMaxAvailable = width - inactiveCount * mInactiveItemMinWidth;
final int activeWidth = Math.min(activeMaxAvailable, mActiveItemMaxWidth);
final int inactiveMaxAvailable = (width - activeWidth) / inactiveCount;
final int inactiveWidth = Math.min(inactiveMaxAvailable, mInactiveItemMaxWidth);
mInactiveItemMinWidth = 56dp
mInactiveItemMaxWidth = 96dp
mActiveItemMaxWidth = 168dp

なので、activeWidthは最大168dpになり、inactiveWidthは最大96dpになります。

[ガイドライン] スナックバーの高度(6dp)は低いため、BottomNavigation(8dpの高度)の背面にスナックバーが表示されます。

関係するのはBottomNavigationViewのコンストラクタで下記の処理があったぐらいでした。 BottomNavationの上側からSnackbarが表示されるように実装する必要があります。

if (a.hasValue(R.styleable.BottomNavigationView_elevation)) {
    ViewCompat.setElevation(this, a.getDimensionPixelSize(
            R.styleable.BottomNavigationView_elevation, 0));
}

その他わかったこと

?でのリソース参照について

android:background="?android:attr/windowBackground"

テーマに設定されている値を参照する場合に ? を使うことができます。

上記の場合はAndroid SDKが定義しているAtrributeを使う場合の例です。

テーマに設定されているということなので、styles.xml<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar"> のparentをどんどんたどっていくと <style name="Theme.Light"> まで行き、そこに windowBackground

<item name="windowBackground">@drawable/screen_background_selector_light</item>

と定義されていました。<style name="Theme">の方には、

<item name="windowBackground">@drawable/screen_background_selector_dark</item>

と定義されてますね。

自分で定義したAttributeを使う場合には、?attr/<自分で定義したAttribute>?/<自分で定義したAttribute> のようにする

テーマと連動させたい場合に ? を使って参照すると良いようです。

Tintについて

API level 21 から、Drawable リソースをアルファマスクとして定義し、その Drawable に色を付けて表示する、ということができるようになりました。 同じ形で異なる色のアイコンを複数個用意したいというような場面で便利ですね。

http://vividcode.hatenablog.com/entry/android-app/drawable-tinting

こちら知らなかった。。。

PointerIconについて

BottomNavigationItemView#setEnabledメソッドを眺めていたら、

ViewCompat.setPointerIcon(this,
        PointerIconCompat.getSystemIcon(getContext(), PointerIconCompat.TYPE_HAND));

という処理があって気になったので、PointerIconCompat#setPointerIconを調べてみたら、マウスオーバーした時にマウスポインタを変更するような処理なんですね。スマホでは使わないと思うんですが、TVとかで使うことあるんですかね。

おわりに

  • BottomNavigationViewのコードは、そんなに複雑なコードじゃなかったので、読みやすいかなと思いました。


comments powered by Disqus