Takuji->find;

株式会社はてなでアプリケーションエンジニアやってます、技術的な記事を書いているつもり

RecyclerView.ItemDecorationについて #関モバ

こんばんは、最近Androidエンジニアを名乗っていいのか悩ましいid:takuji31です。

今日はRecyclerViewのItemDecorationについて簡単な使い方をまとめてみました。

なお、この記事の一部は本日開催の関西モバイルアプリ研究会#20で発表された内容です。

RecyclerView.ItemDecoration とは

RecyclerViewの各アイテムに対して何らかの装飾を施す仕組みです。

代表的なのは罫線を引いてくれる(Support Library25.0.0でようやく追加された)DividerItemDecorationやアイテムのDnDやスワイプが行えるようになるめっちゃ便利なItemTouchHelperがあります(というより公式で提供されている子クラスはこの2つだけ)。

色んなRecyclerViewのアイテムに対して簡単な装飾を施したいのなら、レイアウトで頑張るよりこちらでやる方がよいかと思います。

メソッド

getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state)

指定したViewが本来レイアウトした位置からどれくらいズレるのかを指定するメソッドです。

最後の state がないメソッドがありますが、これは非推奨です。

view が対象のView、 outRect がアイテムのオフセットを返すための Rect です、 outRectleft|top|right|bottom に直接値をセットします。

onDraw(Canvas c, RecyclerView parent, RecyclerView.State state)

装飾の描画時に呼ばれるメソッドです。呼ばれるタイミングは各アイテムの内容が描画される前です。

最後の state がないメソッドがありますが、これは非推奨です。

c はRecyclerView全体のCanvasです、各要素に対して呼ばれるのではなく、描画ごとに1回だけ呼ばれます。

このメソッドで装飾を描画することになります。

onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state)

基本的には onDraw と同じですが、こちらは呼び出されるタイミングがアイテムの内容が描画された後です。

最後の state がないメソッドがありますが、これは非推奨です。

アイテムに重ねて描画したい場合に使うとよさそうです。

使い方

基本的には ItemDecoration を継承したクラスを作って、getItemOffsetonDraw もしくは onDrawOver (あるいは両方)を実装してやるとよいです。

使う時はRecyclerView.addItemDecoration(RecyclerView.ItemDecoration decor) を呼んで装飾を追加します。

今回はiOSUITableViewCell にある accessoryType のようなものを作ってみました。

AccessoryTypeを定義する

雑にenumを定義しました、とりあえず2タイプだけサポート。

enum class AccessoryType(@DrawableRes val resId: Int) {
    NONE(0),
    DISCLOSURE_INDICATOR(R.drawable.ic_chevron_right_black_24dp),
    CHECK_MARK(R.drawable.ic_done_black_24dp),
}

Adapterを作る

文字列と AccessoryType を持つ Pair のリストを持つだけの Adapter を作りました。

ポイントは ViewHolder.itemViewsetTagAccessoryType を渡していること。

ViewRecyclerView から ViewHolder を取り出すことはできますが、これだと ViewHolder の型に ItemDecoration が依存してしまうので、Tagで渡しています。

class Adapter(var items: List<Pair<String, AccessoryType>>) : RecyclerView.Adapter<ViewHolder>() {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val inflator = LayoutInflater.from(parent.context)
        return ViewHolder(view = inflator.inflate(R.layout.recyler_row_simple_textview, parent, false))
    }

    override fun getItemCount(): Int {
        return items.size
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val (text, accessoryType) = items[position]
        holder.textView.text = text
        holder.textView.setOnClickListener {
            Log.d(javaClass.simpleName, "clicked")
        }
        holder.itemView.setTag(R.id.tagAccessoryType, accessoryType)
    }
}

ItemDecorationを作る

今回は AccessoryType.NONE 以外の時に右側にいい感じに装飾を表示することにしました。

ItemDecoration 全体のコードは↓の通り

class AccessoryItemDecoration(private val context: Context) : RecyclerView.ItemDecoration() {
    val horizontalMargin by lazy {
        context.resources.getDimensionPixelOffset(R.dimen.accessory_horizontal_margin)
    }

    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
        val accessoryType = view.getTag(R.id.tagAccessoryType) as? AccessoryType
        if (accessoryType != null) {
            if (accessoryType != AccessoryType.NONE) {
                val drawable = ContextCompat.getDrawable(view.context, accessoryType.resId)
                outRect.right = drawable.intrinsicWidth + horizontalMargin  * 2
            }
        }
    }

    override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {

        val childCount = parent.childCount
        for (i in 0..childCount - 1) {
            val child = parent.getChildAt(i)
            val accessoryType = child.getTag(R.id.tagAccessoryType) as? AccessoryType
            if (accessoryType != null && accessoryType != AccessoryType.NONE) {
                val drawable = ContextCompat.getDrawable(child.context, accessoryType.resId)
                val bounds = Rect()
                parent.getDecoratedBoundsWithMargins(child, bounds)
                val left = bounds.right - horizontalMargin - drawable.intrinsicWidth
                val right = left + drawable.intrinsicWidth
                val top = bounds.top + ((bounds.bottom - bounds.top - drawable.intrinsicHeight) / 2) + Math.round(ViewCompat.getTranslationY(child))
                val bottom = top + drawable.intrinsicHeight
                drawable.setBounds(left, top, right, bottom)
                drawable.draw(c)
            }
        }
    }
}

getItemOffsets

ViewHolder でセットした AccessoryType のTagを取得して、修飾に使うDrawableとmargin分だけ右側にoffsetを設定しています。

override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
    val accessoryType = view.getTag(R.id.tagAccessoryType) as? AccessoryType
    if (accessoryType != null) {
        if (accessoryType != AccessoryType.NONE) {
            val drawable = ContextCompat.getDrawable(view.context, accessoryType.resId)
            outRect.right = drawable.intrinsicWidth + horizontalMargin  * 2
        }
    }
}

onDrawOver

子の View を1つずつ取得して、それぞれ必要に応じて Drawable を描画しています。

parent.getDecoratedBoundsWithMargins(child, bounds) でそのViewの装飾込みの境界を表すRectが取得できます。

ViewCompat.getTranslationY(child) でViewのtranslationYが取得できるので、この分描画位置をずらす必要があります。

あとは DrawableCanvas に描画するだけです。

override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {

    val childCount = parent.childCount
    for (i in 0..childCount - 1) {
        val child = parent.getChildAt(i)
        val accessoryType = child.getTag(R.id.tagAccessoryType) as? AccessoryType
        if (accessoryType != null && accessoryType != AccessoryType.NONE) {
            val drawable = ContextCompat.getDrawable(child.context, accessoryType.resId)
            val bounds = Rect()
            parent.getDecoratedBoundsWithMargins(child, bounds)
            val left = bounds.right - horizontalMargin - drawable.intrinsicWidth
            val right = left + drawable.intrinsicWidth
            val top = bounds.top + ((bounds.bottom - bounds.top - drawable.intrinsicHeight) / 2) + Math.round(ViewCompat.getTranslationY(child))
            val bottom = top + drawable.intrinsicHeight
            drawable.setBounds(left, top, right, bottom)
            drawable.draw(c)
        }
    }
}

スクリーンショット

f:id:takuji31:20161128184829p:plain

このコードの問題点

  • Offsetを指定してる分選択した時の背景描画が小さくなる
  • もちろんdrawしているだけなので、その装飾をタップできたりはしない

今回は分かりやすい例を出すためにこんな感じにしましたが、こういうのはViewに直接レイアウトした方がいいかもしれません。

まとめ

  • getItemOffsets でoffsetを決める
  • onDrawonDrawOver で描画する
  • RecyclerView.addItemDecoration() して使う

今回のサンプルコードはこちら

github.com