メモ2ブログ

メモtoウェブログ。旧ブログはこちら。 http://sakebook.blogspot.jp/

Bottom Navigationをガイドラインぽく実装する

Bottom Navigationはサポートライブラリが提供されています。 しかし、単純に使うだけではガイドラインぽくなりません。

ガイドラインぽく実装してみました。

f:id:sakebook:20170212031306g:plain

ガイドラインぽさ

  • 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の挙動になります。

f:id:sakebook:20170212031616p:plain

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"/>

実際に指定している値は次の通りです

    <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を表示している間、どのような挙動が望ましいか調べきれませんでした。

  1. Snackbar表示中もBottom Navigationの動きに合わせて上下する
  2. Snackbar表示中はBottom Navigationは動かさない
  3. 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が追加されたのですが、本当に最低限度しか用意されていませんでした。。

f:id:sakebook:20170303134743p:plain

f:id:sakebook:20170303134808p:plain

参考

Bottom navigation - Components - / Material design guidelines

StatusBar 透明化の正しい方法 / Y.A.M の 雑記帳

nickbutcher/plaid / GitHub

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

Google Play ニューススタンド / Google Play