はじめに
support design libraryの特にBottomNavigationViewを読んでみました。
ガイドライン原文・日本語訳に書いてあるのが、どのようにコードで書かれているのかという視点で読みました。
ちなみに、BottomNavigationというのはこのようなものです。
BottomNavigationViewを読む
[ガイドライン]最上位の移動先を3~5個表示する
- 6個にした場合
IllegalArgumentExceptionが出ます。
- 2個にした場合
Exceptionは出ずに、2個表示されます。
BottomNavigationView
が BottomNavivgationMenu
(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でitemIconTint
やitemTextColor
をセットしてないければ、デフォルトカラーをセットするようになっていました。
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;
あと、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
アイコンのアニメーションはどのように実装されているのか?
について見ていく中でガイドラインを確認して行きたいと思います。
この記事の最初と同じものですが、
アイコンの動きをよく見ると、選択時にアイコンが少し上にアニメーションして動き、非選択時に下にアニメーションしているのがわかるかと思います。これをどのように実装しているかを見ていきます。
BottomNavigationMenuView
がOnClickListener
でクリックを検知して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のコードは、そんなに複雑なコードじゃなかったので、読みやすいかなと思いました。