メモ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

Link Stackをメジャーアップデートしました

リンクをちょっとだけ後で読むアプリ、Link Stackをメジャーアップデートしました。

play.google.com

このアプリを作った経緯はこちらです。

sakebook.hatenablog.com

メジャーアップデートの機能紹介と、なぜその機能を付けたかについて書きます。

2つの新機能

  • 未読記事一覧
  • 閲覧履歴分析
未読記事一覧 閲覧履歴分析
f:id:sakebook:20170204210604p:plain:w240 f:id:sakebook:20170204210627p:plain:w240

それぞれタブを追加してます。実装的には、サポートライブラリのBottom NavigationViewを使いました。

未読記事一覧

起動して表示されるフィード以外に、未読記事のみを抽出した未読記事一覧を作りました。未読記事一覧では、未読記事を見ることはもちろん、既読チェックだけをつけることもできます。

閲覧履歴分析

Link Stackで表示した記事を対象に、グラフィカルに閲覧履歴がわかる機能を付けました。

  • 閲覧ドメインTOP10
    • よく見るサイトとその割合がわかります。
  • 曜日ごとの平均記事閲覧数
    • どの曜日に記事をどれだけ見ているかがわかります。
  • 毎日の記事閲覧数
    • 一日どれだけ記事を見ているのかがわかります。

どうしてその機能を付けたのか

普段使いにしてるからこそ痒いところを解決したかった

Android 7.0以降で、特に指定がなければ同じアプリからの通知が3通貯まるとまとまって表示されるようになりました。 展開すると3通入っている別の通知が作成される感じです。

Link Stackは、未読通知はスワイプでは消せないようにしているのですが、まとまる通知になってしまうと スワイプで消せるようになってしまいます

まとめさせないことは簡単なのですが、OSの挙動に逆らう感じがして気乗りせず、対応していませんでした。

なので、誤って消してしまうことが多くなりました。 また、端末ももう2年以上使っているからか、意味の分からない再起動や、バッテリー切れなどが度々起きてしまい、未読のまま読み忘れる記事が結構多くなってきていました。

読み忘れたものの一覧を表示して、再び読む機会が欲しいと思っていました。

foursqaureのstatsが好きだった

今はSwarmに機能が移ってますが、自分のチェックイン履歴から、どこにどれだけ行っているとか誰と何回チェックインしているとかを分析してくれる機能です。 Link Stackを開発してすぐに、これは似たようなことができるかもしれないと思い、データだけ貯めていました。

初期からあった展望で、いつか追加したいと思ってました。追加するならBottom Navigationで実装したいなと思っていたのですが、3つ目のタブが思いつかず、実装まで着手していませんでした。 前述の履歴問題が顕著になってきて、あぁ、これをタブにすればと気づいたので合わせて実装することにしました。

今後の展望

気づきを与えてくれたり、何かのきっかけをくれるサービスが好きです。 Link Stackもそういうサービスにしていけたらなと思います。

まだ案はいくつもあるので、適宜更新していきたいと思います。

参考

BottomNavigationView / Android Developers

Android 7.0 for Developers / Android Developers

Introducing Swarm 4.0: Stats on stats on stats / The Foursquare Blog

Bottom navigation - Components - / Material design guidelines

2016年を振り返って

2017年も12分の1が過ぎようとしてますが、2016年を目標とともに振り返ります。

2016年の目標

  • 本を10冊以上読む
    • 未達。
  • プロダクトのアウトプットを4つ以上
    • 未達。
  • 個人ページ作成
    • できた!
  • ブログを20回以上更新
    • 未達。

本を10冊以上読む

3冊くらいしか読んでない気がします。別に厳選しているというわけではなく、単純に読む習慣をつけられていません。

自分も2017年で30歳になるのもあってか、次の本が面白かったです。歯ブラシを忘れないためにすべてのリュックに歯ブラシを入れておく件が、自分もKindleを忘れたりするので、枕元用にもう1台買っておくとかで習慣化の手助けになるならやろうと思いました。

これを読んだ場所が楽天カフェだったのですが、ソファーがあると本が読むのが捗ったので、家にもソファーがほしいと思いました。

プロダクトのアウトプットを4つ以上

2015年に作ったLink Stackはわりと更新してたり、古いアプリの更新など行いました。また、依頼でiOSのアプリも作りました。 振り返ってみると、ストアに個人の新しいアプリをあげませんでした。2016年末にそれに気づいて、アプリを仕上げられれば!と思ったのですが全然間に合いませんでした。

個人ページ作成

これはようやく達成できました!前々から欲しかったものです。個人ドメインとかではないのですが、GitHubページを作成しました。内容不足は否めないですがわりと満足しています。

ブログを20回以上更新

記事数は19本でした。気づいていれば追い上げで記事を書いたんでしょうけど、気づきませんでした。

目標を掲げておいてそれに気づいていないというのは問題ですよね。

その他したことなど

Androidの講師

2015年にも行ったのですが、縁あって2016年にも行いました。 都度反省があるので次に生かせればなと。

Kotlin

会社のアプリでも個人のアプリでもKotlinを使ってます。非力なPCでビルドが遅くなるのは辛いですが、それ以外は楽しく触ってます。 以前より状態を意識できるようになった気がします。

企画展

4月~5月に企画展に参加しました。 他の参加者や来場者と交流できて楽しかったので、機会はそうないとは思いますがまた縁があればなと。

cr6ad7.net

引越し

東京で初めてひとり暮らしを始め、4年ちょっと過ごした家から引越ししました。 新江古田から神保町に。ちょうど半年ほど経ちましたが、通勤が徒歩になったのはめっちゃ便利です。

開発合宿

公私どちらでも開発合宿をしました。土善旅館、満足度高かったのでおすすめです。

結婚式

自分のはまだですが、ラッシュすごかったです。毎月参加してたような気がします。時には海外まで参加しに行きました。

親知らず抜歯

人によっては経験しないこともあるのでレアだなと思いました。右の上下を抜きました。 2017年に入ってから、左の上下も抜いたので、親知らずがなくなりました。


振り返ると、結構ライフイベントがあった年でした。 触れる言語が増えたことによる影響か、以前より開発の見通しについて考えられるようになった気がします。

2017年の目標

  • ブログを20回以上更新
  • GoとかPythonとかでWeb APIみたいなの作ってアプリに組み込み
  • ライブラリにPRを送る
  • 設計力を高める

定量的じゃないのもありますが。これはやっておきたい的な感じです。
30歳になるんですよね。。おっさんやん。

達成するために

KPTをやろうと2016年に言っていたのに、一つもできませんでした。とりあえずカレンダーに登録してそのための日を事前に用意しておこうと思いました。