Bottom Navigationをガイドラインぽく実装する
Bottom Navigationはサポートライブラリが提供されています。 しかし、単純に使うだけではガイドラインぽくなりません。
ガイドラインぽく実装してみました。
ガイドラインぽさ
- Navigation Barを含んでBottom Navigationにする
- 下にスクロールすると隠れる
- コンテンツを最大限見せる
- Snackbarを上に表示する
- 影を表示する
- 同じタブを選択するとタブの一番上にスクロールする
- ステートの初期化
Navigation Barを含んでBottom Navigationにする
戻るボタンなどが置いてある、Navigation BarもBottom Navigationに合わせた配色になっています。
API Level 21から、Navigation Barの色を変更できるのでstyleで設定しています。
- values-21/styles.xml
<resources> <style name="AppTheme" parent="BaseTheme"> <item name="android:statusBarColor">@color/translucent</item> <item name="android:windowDrawsSystemBarBackgrounds">true</item> <item name="android:navigationBarColor">@color/colorPrimaryDark</item> </style> </resources>
下にスクロールすると隠れる
画面内のスクロールに合わせて、画面下に向かうときはコンテンツの邪魔にならないように隠れ、画面上に向かうときは再び表示させるという動きが必要です。
CoordinatorLayoutを使い、AppBarLayoutの動きに合わせたBehavior定義することで実現させます。この記事のカスタムBehaviorと同じ動きです。
コンテンツを最大限見せる
Activity#onCreate
で、全画面の描画を可能にします。 setContentView
の前に呼ぶ必要があります。
- MainActivity.kt
findViewById(android.R.id.content).systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION setContentView(R.layout.activity_main)
ここまでだと以下のような表示になります。 ガイドラインのgifっぽくはなりません。Navigation Barは隠れないし、スクロールの動きと同期したBottom Navigationの挙動になります。
ToolbarとBottom Navigationをハックします。
Toolbarハック
ガイドラインはSystem Barが半透明に残っているので、System Barは半透明にします。ガイドライン的に合わせ、20%透過の黒にします。 #33000000
半透明にしただけでは、Toolbarが食い込んで見えるだけなので、ToolbarにSystem Bar分のPaddingを与えて、いい感じに見えるようにします。
- activity_main.xml
<android.support.v7.widget.Toolbar android:id="@+id/toolbar" android:paddingTop="@dimen/status_bar_height" android:layout_width="match_parent" android:layout_height="@dimen/tool_bar_height" android:theme="@style/ToolbarTheme" android:background="@color/colorPrimary" app:layout_scrollFlags="scroll|enterAlways" android:elevation="@dimen/small_margin"/>
Toolbarの高さは、Paddingを加えた高さに指定します。こうすることでタイトルの表示位置がずれるのを防ぎます。
Bottom Navigationハック
Toolbarでやったように、こちらもNavigation Barで隠れてしまうので、Navigation Barの色を半透明にします。
そして、Paddingを与えて調整します。
- activity_main.xml
<android.support.design.widget.BottomNavigationView android:id="@+id/bottom_navigation" android:paddingBottom="@dimen/navigation_bar_padding" android:background="@color/colorPrimary" app:menu="@menu/bottom_navigation" app:itemIconTint="@drawable/bottom_navigation_selector" app:itemTextColor="@drawable/bottom_navigation_selector" android:layout_width="match_parent" android:layout_height="@dimen/bottom_navigation_height"/>
実際に指定している値は次の通りです
- dimens.xml
<dimen name="status_bar_height">24dp</dimen> <dimen name="tool_bar_height">80dp</dimen><!-- status bar(24dp) + tool bar(56dp) --> <dimen name="navigation_bar_padding">46dp</dimen><!-- navigation bar(46dp) --> <dimen name="bottom_navigation_height">104dp</dimen><!-- navigation bar(46dp) + bottom navigation(58dp) -->
このままだと横画面に対応できないので、調整します。横画面では、Toolbarの高さが変わるのと、Navigation Barが横にあるという違いがあります。
- values-land/dimens.xml
<dimen name="status_bar_height">24dp</dimen> <dimen name="tool_bar_height">72dp</dimen><!-- status bar(24dp) + landscape tool bar(48dp) --> <dimen name="navigation_bar_padding">0dp</dimen><!-- landscape navigation bar(0dp) --> <dimen name="bottom_navigation_height">58dp</dimen><!-- landscape navigation bar(0dp) + bottom navigation(58dp) -->
Bottom Navigationの高さがToolbarの高さと等しくなくなったので、現状のカスタムBehaviorでは対応できません。スクロールと非同期にし、完全に隠れるように修正します。
スクロールは以前と同じくAppBarLayoutから受け取り、アニメーションを制御することで対応します。
- BottomLayoutBehavior.kt
private fun bottomLayoutChange(bottomLayout: BottomNavigationView, dependency: View) { val diff = Math.abs(lastPosition - dependency.top) val scrollLimit = dependency.top - dependency.bottom if (lastPosition == dependency.top && lastPosition == 0) { // on top limit animation(bottomLayout, true) } else if (lastPosition == dependency.top && lastPosition == scrollLimit) { // on bottom limit animation(bottomLayout, false) } else if (diff > THRESHOLD && lastPosition < dependency.y) { // go to top animation(bottomLayout, true) } else if (diff > THRESHOLD && lastPosition > dependency.y) { // go to bottom animation(bottomLayout, false) } else { // nothing } lastPosition = dependency.y.toInt() } private fun animation(view: View, reverse: Boolean) { if (isAnimate) { return } isAnimate = true val animateValue = (view.bottom - view.top).toFloat() ViewCompat.animate(view) .setDuration(250) .translationY(if (reverse) 0f else animateValue) .setInterpolator(AccelerateDecelerateInterpolator()) .setListener(object : ViewPropertyAnimatorListener { override fun onAnimationEnd(view: View?) { isAnimate = false } override fun onAnimationCancel(view: View?) { isAnimate = false } override fun onAnimationStart(view: View?) { isAnimate = true } }) .start() }
ViewCompat.animate
で、Bottom Navigationの高さ分アニメーションさせています。
Snackbarを上に表示する
何もしなければSnackbarはBottom Navigationに隠れてしまいます。こちらも、カスタムBehaviorで解決させます。 (余談ですがToolbar, Snackbarは単語をつなげるけどNavigation Bar, System Barは単語を分けて書いてます。個人的な感覚ですが、Viewとして触るからかこれがしっくり来てます。)
Bottom Navigationの位置に応じて、Paddingを与えることで隠れないようにします。
- BottomLayoutBehavior.kt
private fun snackbarChange(bottomLayout: BottomNavigationView, dependency: View) { val diff = bottomLayout.bottom - bottomLayout.y // If bottom layout is collapsing, need navigation bar padding. val padding = when(bottomLayout.bottom == bottomLayout.y.toInt()) { true -> context.resources.getDimension(R.dimen.navigation_bar_padding) false -> 0f } ViewCompat.setPaddingRelative(dependency, dependency.paddingStart, dependency.paddingTop, dependency.paddingEnd, (diff + padding).toInt()) }
Bottom Navigationが動くので、Snackbarを表示している間、どのような挙動が望ましいか調べきれませんでした。
- Snackbar表示中もBottom Navigationの動きに合わせて上下する
- Snackbar表示中はBottom Navigationは動かさない
- Snackbat表示中はBottom Navigationは展開された状態で動かさない
以上のようなパターンが考えられたのですが、2の案にしました。1は見にくいのでやめ、3はBottom Navigationが隠れている場合に敢えて合わせて出すのは、見せたいものが違うんじゃないかと思ったので、消去法で2の案にしました。
Snackbarが表示中かどうか判定することでBotom Navigationのアニメーションを制御しています。
- BottomLayoutBehavior.kt
override fun onDependentViewChanged(parent: CoordinatorLayout, bottomLayout: BottomNavigationView, dependency: View): Boolean { when(dependency) { is AppBarLayout -> { if (snacking) { // Do not animation while the Snackbar is fixed. return true } bottomLayoutChange(bottomLayout, dependency) } is Snackbar.SnackbarLayout -> { snacking = dependency.top == dependency.y.toInt() // If true, Snackbar is completely displayed. snackbarChange(bottomLayout, dependency) } } return true }
影を表示する
現状のサポートライブラリ(25.1.1)では、Bottom Navigationに影がつきません。なので影っぽいViewを用意して、影を表現します。
影もBottom Navigationに合わせて動く必要があるので、2つをLinearLayoutに入れてBehaviorで制御するようにしました。
- activity_main.xml
<LinearLayout android:id="@+id/layout_bottom" android:orientation="vertical" android:gravity="bottom" android:layout_gravity="bottom" android:elevation="@dimen/small_margin" app:layout_behavior="@string/bottom_layout_behavior" android:layout_width="match_parent" android:layout_height="wrap_content"> <View android:id="@+id/view_shadow" android:layout_width="match_parent" android:layout_height="@dimen/shadow_height" android:background="@drawable/shadow"/> <android.support.design.widget.BottomNavigationView android:id="@+id/bottom_navigation" android:paddingBottom="@dimen/navigation_bar_padding" android:background="@color/colorPrimary" app:menu="@menu/bottom_navigation" app:itemIconTint="@drawable/bottom_navigation_selector" app:itemTextColor="@drawable/bottom_navigation_selector" android:layout_width="match_parent" android:layout_height="@dimen/bottom_navigation_height"/> </LinearLayout>
Behaviorはxml上で補完が効かなかったので、サポートライブラリのようにstrings.xmlに定義しました。
strings.xml
<string name="bottom_layout_behavior" translatable="false">com.sakebook.android.sample.bottomnavigationviewsample.BottomLayoutBehavior</string>
同じタブを選択するとタブの一番上にスクロールする
ガイドラインでは、同じタブを選択した場合は一番上にスクロールするのが望ましいとされていますが、現状のサポートライブラリ(25.1.1)では、選択したタブが同じだったかどうかの判定はしてくれません。TabLayoutにはListenerがあるのに。。
Bottom Navigationのタブを押された際に、現在表示しているFragmentと同じかどうか確認し、同じなら一番上にスクロールさせてます。
Fragmentは都度生成しています(後述)。
- MainActivity.kt
private fun updateBottomNavigation(position: Int, tag: String): Boolean { val fragment = supportFragmentManager.findFragmentByTag(tag) when(fragment) { null -> { supportFragmentManager .beginTransaction() .replace(R.id.layout_container, MainFragment.newInstance(position), tag) .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) .commit() } else -> { if (fragment is BottomNavigationInterface) { fragment.scrollToTop() } } } return true }
- MainFragment.kt
override fun scrollToTop() { recyclerView?.smoothScrollToPosition(0) }
ステートの初期化
ガイドライン的には、タブが切り替えて表示されたときには、そのタブの状態を初期化することが推奨されています。
そのために今回はFragmentを作り直しています。 Fragmentの生成コストが高い場合は問題だなと思ってます。また、縦に長かったり、階層を持つコンテンツの場合、先程まで触っていたものに戻れないので、適さないと感じました。 縦に長い場合はキャッシュして二度目は高速表示できるようしたり、階層を持つものの場合はBottom Navigationを隠して表示するとか工夫が必要な気がします。
Webでいうサイドバーと同じ効果を期待しているものみたいなのでそうなっているのかなと思いました。
Play ニュースが初期化するようになっているので参考になるかもしれません。
まとめ
ガイドライン通りに実装するには現状工夫が必要になります。 別のライブラリも出ているので、こだわるならそちらを使うのも一つかもしれません。 タブを追加するだけなら簡単なので、コンテンツが合うなら良いと思います。
サンプルはこちらです。
GitHub - sakebook/BottomNavigationViewSample: BottomNavigation sample with support library.
追記(2017/3/3)
Android Studio 2.3から、プロジェクトテンプレートにBottom Navigation Activityが追加されたのですが、本当に最低限度しか用意されていませんでした。。
参考
Bottom navigation - Components - / Material design guidelines
StatusBar 透明化の正しい方法 / Y.A.M の 雑記帳
Android transparent status bar and actionbar / Stack Overflow
AndroidのCoordinatorLayoutを使いこなして、モダンなスクロールを実装しよう / Yahoo! JAPAN Tech Blog
Structure - Layout - / Material design guidelines
Move snackbar above the bottom bar / Stack Overflow
Android Bottom Navigation Bar with drop shadow / Stack Overflow
TabLayout.OnTabSelectedListener / Android Developers
Selected tab’s color in Bottom Navigation View / Stack Overflow