メモ2ブログ

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

RecyclerViewで複数のDividerを実装する

RecyclerViewでは、Divider(区切り)は自分で実装しなければなりません。

標準の線を引く方法はよく見つかるのですが、複数の線を引いたり、逆に線を引かなかったりするというのがあまり見つからないのでまとめました。

f:id:sakebook:20170423181700g:plain

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を定義します。さらに、それを実装した NoDividerCustomDivider を定義します。 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を多様をすることで本来の効果が薄れてしまう可能性があるとあるので使い所には注意が必要です。

サンプルはこちらです

github.com

参考

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

ハードウェアアクセラレーションが有効だとstrokeでの点線描写が上手く描写出来ない / みんからきりまで

Dividers / Material design guidelines

DroidKaigi 2017で登壇してChrome Custom Tabsについて発表しました。

スライドとサンプルアプリと、そのコードです。

speakerdeck.com

play.google.com

github.com

スピーカーから見た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日目 登壇

やり取りのメールを眺めると、上のような感じです。基本スピーカーは、届いたメールやアンケートに返信すれば良いだけになってます。

応募して採択されたけどやっぱり怖い!という人のために、一応内定を断ることもできます。

やることは少ないのですが、自分は画像のアップロードを忘れてしまっていて、デフォルト画像での参加となりました。

f:id:sakebook:20170312214749j:plain

DroidKaigi 前日

スピーカーズディナーに参加して、贈り物みたいなやつ(言葉でないw)を受け取りました。 居酒屋かな?と思っていたら、立食で、思った以上に人がいて驚きました。

DroidKaigi 1日目

受付が、専用受付がありスムーズでした。

登壇当日の動き

登壇当日は一つ前のセッションから、控室で資料の最終確認を行っていました。

めっちゃ緊張してたので、控室で緊張しない方法を尋ねると、色々温かいレスポンスがもらえたのが嬉しかったです。


20分の移動時間が始まり、部屋に移動しました。 早くから部屋に来てた方にChrome Custom Tabsを使ったことがあるか聞いたら、殆どの方が使ったことがないみたいでした。

事前にサンプルアプリを作ってGooglePlayに公開していました。 発表前にQRコードをスライドに写しておくことで、わりとスムーズにアプリの共有はできたのかなと思います。

発表前は、スタッフの方とタイトルの確認とオフィスアワー有無の確認をしました。


発表後はオフィスアワーとして20分程度バリスタさんのコーヒーを飲んでました。

個人的なメモ

アイコン大事

スピーカーズディナーとアフターパーティーで思ったのが、アイコン大事ってことです。

「あぁ!あのアイコンの人だ!」って思うことがよくあったので、忘れた自分は少し機会損失があったのかもしれないなと思いました。

事前知識の共有

1日目は、純粋に発表を聞くのと、スライドの作りや構成で参考にできないかなという目線で見てました。

自分も聞いててあったのですが、わからない部分が出てきたとき、それが今後の前提となってくると、ずっと置いてけぼりをくらってだんだんわからなくなる負の連鎖があります。

回避方法として、要所要所にまとめを設けるのと、事前知識として共有しておかないとまずいことは、時間を割いてでも共有しておくことで防げるのかなと思いました。

主観ですが、これができている発表は聞きやすかったように思います。

しかし、自分の発表には適応できませんでした。 Chrome Custom Tabsに触ったことのない方が多かったので、最低限のコードは入れておくと、後のイメージがしやすくなったんだろうなと思いました。

人に見てもらう

若干早いという指摘もありました(実際20分くらいで終わってしまった)。

リハでも早く終わってたので、質疑応答に充てようと思っていたのですが、実際は、前述のような追加できる要素が残っていました。

社内の人や、同じスピーカーの方に見てもらうなどできれば気づけた部分だったのかなと思いました。

スピーカー同士で、スライドの確認をできる場が公式であっても良いのかなとちょっと思いました。

やってよかった

お金を払って聞きに来てくださった方に、聞いてよかったという価値を提供することができたんだろうか?と不安だったのですが、発表後にも、オフィスアワーにも、アフターパーティーでも質問をしてもらったので、ある程度価値を提供できたのかなと思いました。

何も起きなかった運営すごい

前述の通り、スピーカーは発表以外にやることがほとんどありません。負担がないし、参加者としても問題なく楽しく参加できました。

当たり前のようになってますが、有志でこれだけ安定してイベントを運営するのは本当にすごいと思います。

まとめ

大きなイベントの登壇は不安ですが、大丈夫です。

来年も何か見つけてCFP応募するか、スタッフとしてお手伝いきればよいなと思いました。 両方してる人たちすごい。。

参考

DroidKaigi 2017 / DroidKaigi

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