RecyclerViewで複数のDividerを実装する
RecyclerViewでは、Divider(区切り)は自分で実装しなければなりません。
標準の線を引く方法はよく見つかるのですが、複数の線を引いたり、逆に線を引かなかったりするというのがあまり見つからないのでまとめました。
Support Libraryに追加されたDividerItemDecoration
確認したSupport Libraryのバージョンは 25.3.1
です。
標準の線は、Support Libraryの DividerItemDecoration
を使うことで引けます。内部の実装は、 mDivider
というDrawableを、Canvasに描画しています。
なので、描画をさせなければ線を引かない風にできますし、描画するDrawableを変えれば、異なる線を引くことができます。
基本的な流れ
描画領域の確保
区切り線の描画領域は ItemDecoration#getItemOffsets(Rect outRect, View view, RecyclerView parent, State state)
で、セルのxmlで定義したセルの大きさから、さらに余白をつけることができます。
なので、区切り線を描画させたくないときは、 outRect.set(0, 0, 0, 0)
とすべて0
にします。
下に引きたいときは、 outRect.set(0, 0, 0, mDivider.intrinsicHeight)
と下に余白を取ります(left, top, right, bottom)。
mDivider.intrinsicHeight
はDrawableの高さです。
セルごとの描画領域を求める
描画領域を確保したら、実際に描画します。
ItemDecoration#onDraw(Canvas c, RecyclerView parent, State state)
で描画していきます。画面を動かす度に呼ばれます。
Support LibraryのDividerItemDecoration
では、 drawVertical
を定義してそこで横の区切り線の処理をしています。
Math.round(ViewCompat.getTranslationY(child)
でセルの上部のY座標の求め、余白を加味したセルの高さを合わせて、区切り線を描画すべきY座標を求めます。
求めたY座標から、区切り線の高さを引くことで、区切り線を描画すべきRectがわかります。
求めたRectを、 mDivider
にセットし、Canvasに描画することで指定した領域に区切り線が表示されます。
- DividerItemDecoration.java(Support Library)
... parent.getDecoratedBoundsWithMargins(child, mBounds); final int bottom = mBounds.bottom + Math.round(ViewCompat.getTranslationY(child)); final int top = bottom - mDivider.getIntrinsicHeight(); mDivider.setBounds(left, top, right, bottom); mDivider.draw(canvas); ...
独自のDividerItemDecoration
Support Libraryの DividerItemDecoration
では、標準の区切り線か、 DividerItemDecoration#setDrawable(drawable: Drawable)
で設定した区切り線の1種類しか使えません。なので、 RecyclerView.ItemDecoration
を継承して独自の DividerItemDecoration
を作ります。
基本的な流れはSupport Libraryの DividerItemDecoration
と同じなのでソースを丸ごと拝借します。
必要なことは、 そのセルに区切り線が必要かどうか と考えます。 セルから区切り線を必要としているかどうかをわかるようにします。
RecyclerView#getChildViewHolder(View child)
で、セルのViewからViewHolderを取得できるので、ViewHolderに区切り線の有無の情報をもたせることにします。
特定のViewHolderに依存させない
Divider
というInterfaceを定義します。さらに、それを実装した NoDivider
と CustomDivider
を定義します。
CustomDivider
には区切り線の高さと、実際に描画するDrawableのリソースを持たせます。
このInterfaceをViewHolderが実装することで、 DividerItemDecoration
が特定のViewHolderに依存することを避けます。
- getItemOffsets
... val vh = parent.getChildViewHolder(view) when(vh) { is NoDivider -> outRect.set(0, 0, 0, 0) is CustomDivider -> outRect.set(0, 0, 0, vh.height) else -> outRect.set(0, 0, 0, mDivider.intrinsicHeight) } ...
Interfaceを実装していないViewHolderは、標準の区切り線を利用しています。
Drawableを使いまわす
CustomDivider
のリソースからDrawableを都度作成すると、パフォーマンスが悪くなるのでDividerItemDecoration
に保持させます。
... private val mDividerMap: HashMap<Divider, Drawable> = HashMap() ...
一度作成した Drawableは保持して保持して、使いまわすようにします。
- drawVertical
... val vh = parent.getChildViewHolder(child) when(vh) { is NoDivider -> {} is CustomDivider -> { val drawable = mDividerMap[vh]?: // Reuse divider ResourcesCompat.getDrawable(context.resources, vh.drawableRes, null)?.let { mDividerMap.put(vh, it) } val top = bottom - (vh.height + 1) // Line height < Bounds height drawable?.setBounds(left, top, right, bottom) drawable?.draw(canvas) } else -> { val top = bottom -mDivider.intrinsicHeight mDivider.setBounds(left, top, right, bottom) mDivider.draw(canvas) } } ...
区切り線の描画範囲が、Drawableより小さい場合は正しく描画されません。そのため、
val top = bottom - (vh.height + 1)
として区切り線の開始位置を調整しています。これは bottom
を求める際に Math.round(ViewCompat.getTranslationY(child)
を利用しているのと、リソースから高さを求めているので、そのあたりの丸め誤差によって生じているのだと考えています。
ViewHolderに実装する
DividerItemDecoration
を以上のように実装できれば、区切り線をつけたり消したりするのは ViewHolderを実装したクラスに、さらにDivider
を実装するだけで済むようになります。
- OddViewHolder.kt
class OddViewHolder(view: View): ViewHolder<Data>(view), CustomDivider { override val height = view.context.resources.getDimensionPixelSize(R.dimen.small_margin) override val drawableRes = R.drawable.dashed_line_divider ... }
- EvenViewHolder.kt
class EvenViewHolder(view: View): ViewHolder<Data>(view), NoDivider {
...
}
まとめ
一度作ってしまえば使いまわせるので便利です。 サンプルは横方向には対応していませんが、同じようにすることで対応できると思います。
ただし、ガイドライン的には、Divider
を多様をすることで本来の効果が薄れてしまう可能性があるとあるので使い所には注意が必要です。
サンプルはこちらです
参考
DividerItemDecoration / Android Developers
RecyclerView / Android Developer
RecyclerView.ItemDecorationについて #関モバ / Takuji->find;
How do I make a dotted/dashed line in Android? / Stack Overflow
How can a divider line be added in an Android RecyclerView? / Stack Overflow
DroidKaigi 2017で登壇してChrome Custom Tabsについて発表しました。
スライドとサンプルアプリと、そのコードです。
スピーカーから見たDroidKaigiと、それにまつわる個人的なメモです。
スピーカーから見たDroidKaigi
DroidKaigiスケジュール
日程 | 出来事 | 行動 |
---|---|---|
2016.10.01 | CFP募集開始 | |
2016.11.05 | CFP応募 | |
2016.11.15 | 採択内定 | 内定承諾、希望発表時間帯、前日のレセプションへの参加、個別の依頼事項、質問要望等返信 |
2017.01.10 | タイムテーブル確定 | オフィスアワーの可否、Tシャツサイズ選択 |
2017.02.13 | 会場設備について共有 | プロフィール画像のアップロード |
2017.02.22 | スピーカーズディナー(前日のレセプション)案内 | |
2017.03.07 | 当日の会場時刻と設備の案内 | |
2017.03.09 | 1日目 | |
2017.03.10 | 2日目 | 登壇 |
やり取りのメールを眺めると、上のような感じです。基本スピーカーは、届いたメールやアンケートに返信すれば良いだけになってます。
応募して採択されたけどやっぱり怖い!という人のために、一応内定を断ることもできます。
やることは少ないのですが、自分は画像のアップロードを忘れてしまっていて、デフォルト画像での参加となりました。
DroidKaigi 前日
スピーカーズディナーに参加して、贈り物みたいなやつ(言葉でないw)を受け取りました。 居酒屋かな?と思っていたら、立食で、思った以上に人がいて驚きました。
DroidKaigi 1日目
受付が、専用受付がありスムーズでした。
登壇当日の動き
登壇当日は一つ前のセッションから、控室で資料の最終確認を行っていました。
めっちゃ緊張してたので、控室で緊張しない方法を尋ねると、色々温かいレスポンスがもらえたのが嬉しかったです。
20分の移動時間が始まり、部屋に移動しました。 早くから部屋に来てた方にChrome Custom Tabsを使ったことがあるか聞いたら、殆どの方が使ったことがないみたいでした。
事前にサンプルアプリを作ってGooglePlayに公開していました。 発表前にQRコードをスライドに写しておくことで、わりとスムーズにアプリの共有はできたのかなと思います。
発表前は、スタッフの方とタイトルの確認とオフィスアワー有無の確認をしました。
発表後はオフィスアワーとして20分程度バリスタさんのコーヒーを飲んでました。
個人的なメモ
アイコン大事
スピーカーズディナーとアフターパーティーで思ったのが、アイコン大事ってことです。
「あぁ!あのアイコンの人だ!」って思うことがよくあったので、忘れた自分は少し機会損失があったのかもしれないなと思いました。
事前知識の共有
1日目は、純粋に発表を聞くのと、スライドの作りや構成で参考にできないかなという目線で見てました。
自分も聞いててあったのですが、わからない部分が出てきたとき、それが今後の前提となってくると、ずっと置いてけぼりをくらってだんだんわからなくなる負の連鎖があります。
回避方法として、要所要所にまとめを設けるのと、事前知識として共有しておかないとまずいことは、時間を割いてでも共有しておくことで防げるのかなと思いました。
主観ですが、これができている発表は聞きやすかったように思います。
しかし、自分の発表には適応できませんでした。 Chrome Custom Tabsに触ったことのない方が多かったので、最低限のコードは入れておくと、後のイメージがしやすくなったんだろうなと思いました。
人に見てもらう
若干早いという指摘もありました(実際20分くらいで終わってしまった)。
リハでも早く終わってたので、質疑応答に充てようと思っていたのですが、実際は、前述のような追加できる要素が残っていました。
社内の人や、同じスピーカーの方に見てもらうなどできれば気づけた部分だったのかなと思いました。
スピーカー同士で、スライドの確認をできる場が公式であっても良いのかなとちょっと思いました。
やってよかった
お金を払って聞きに来てくださった方に、聞いてよかったという価値を提供することができたんだろうか?と不安だったのですが、発表後にも、オフィスアワーにも、アフターパーティーでも質問をしてもらったので、ある程度価値を提供できたのかなと思いました。
何も起きなかった運営すごい
前述の通り、スピーカーは発表以外にやることがほとんどありません。負担がないし、参加者としても問題なく楽しく参加できました。
当たり前のようになってますが、有志でこれだけ安定してイベントを運営するのは本当にすごいと思います。
まとめ
大きなイベントの登壇は不安ですが、大丈夫です。
「当たり前じゃんと思うかもしれませんが」 大丈夫、それが聞きたいんだ! 他所様の「当たり前」のことを僕らは「知見」と呼んでるんだ!! #DroidKaigi #DroidKaigi1
— なかざん@Gen&Co. (@Nkzn) 2017年3月10日
来年も何か見つけてCFP応募するか、スタッフとしてお手伝いきればよいなと思いました。 両方してる人たちすごい。。
参考
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