日々是好日

プログラミングについてのあれこれ、ムダ知識など

StickyListHeaders風のライブラリを自作する-2

StickyListHeaders風のライブラリを自作する-1 - 日々是好日の続き。

ItemDecoration でがんばって Sticky な動作を実現してみました。

次は、ごく簡単な機能を提供する記事を書きたい。

前回こんなこと言ってましたが、結局実装までやっちゃいましたね。←

f:id:kcpoipoi:20190707233752g:plain:w200

ItemDecoration の実装について

StackOverFlow の回答を参考にしつつ、Kotlin に置き換えて要らなそうな部分を削りました。

次の ItemDecoration はライブラリ側で実装。

class PinningHeaderDecoration(private val listener: PinningHeaderListener) : RecyclerView.ItemDecoration() {

  //ヘッダービューのキャッシュ(雑実装)
  private var header = Pair<Int?, View?>(null, null)

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

    //表示されている一番上のビューを取得
    val topView = parent.getChildAt(0) ?: return

    //アダプタ上でのインデックスを取得
    val adapterPosition = parent.getChildAdapterPosition(topView)

    //NO_POSITION なら return
    if (adapterPosition == RecyclerView.NO_POSITION) return

    //表示したいヘッダーのインデックスを取得
    //getCurrentHeaderPosition はアダプタ側で実装
    val currentHeaderPosition = listener.getCurrentHeaderPosition(adapterPosition) ?: return

    //ヘッダービューがすでに展開されていればフィールドから取得、無ければ inflate
    val currentHeader = when (val layoutResId = listener.getHeaderLayout() ?: return) {
      header.first -> {
        header.second!!
      }
      else -> {
        val inflater = LayoutInflater.from(parent.context)
        val view = inflater.inflate(layoutResId, parent, false)
        header = Pair(layoutResId, view)
        view
      }
    } ?: return

    //ヘッダーのサイズを RecyclerView にフィット
    fixLayoutSize(parent, currentHeader)

    //ヘッダーにデータをバインド
    listener.bindHeaderData(currentHeader, currentHeaderPosition)

    val contactPoint = currentHeader.bottom
    val nextHeaderTop = getNextHeaderPoint(parent)

    //表示中のヘッダーに次のヘッダーが接触したら動かす
    if (nextHeaderTop != null
      && nextHeaderTop <= contactPoint
      && nextHeaderTop > 0 ) {
      moveHeader(c, currentHeader, nextHeaderTop)
    } else {
      drawHeader(c, currentHeader)
    }
  }

  //次のヘッダーの位置を探す
  private fun getNextHeaderPoint(parent: RecyclerView): Int? {
    for (childPosition in 0 until parent.childCount) {
      val child = parent.getChildAt(childPosition)
      val adapterPosition = parent.getChildAdapterPosition(child)
      if (listener.isHeader(adapterPosition)) return child.top
    }
    return null
  }

  private fun drawHeader(c: Canvas, view: View) {
    c.save()
    c.translate(0F, 0F)
    view.draw(c)
    c.restore()
  }

  private fun moveHeader(c: Canvas, currentView: View, nextViewTop: Int) {
    c.save()
    //Canvas の座標を調整
    c.translate(0F, (nextViewTop - currentView.height).toFloat())
    currentView.draw(c)
    c.restore()
  }

  private fun fixLayoutSize(parent: ViewGroup, view: View) {
    ...
  }

  //アダプタに必要なインターフェース
  interface PinningHeaderListener {
    fun isHeader(adapterPosition: Int): Boolean
    fun getCurrentHeaderPosition(adapterPosition: Int): Int?
    fun getHeaderLayout() : Int?
    fun bindHeaderData(header: View, adapterPosition: Int)
  }
}

Adapter 側の実装について

次にアダプタ側の PinningHeaderListener の実装。 ここは app モジュールで実装しているのでかなり雑ですが、 PinningHeaderAdapter とかにしてライブラリに取り込む予定。

class MyAdapter(private val list: List<Int>) : RecyclerView.Adapter<MyAdapter.MyItemViewHolder>(), PinningHeaderDecoration.PinningHeaderListener {

  //フィールドでいいのかなぁと思いつつ保持
  private var headerLayout: Int? = null

  override fun isHeader(adapterPosition: Int): Boolean {
    return getItemViewType(adapterPosition) == HEADER
  }

  override fun getCurrentHeaderPosition(adapterPosition: Int): Int? {
    var index = adapterPosition
    //adapterPosition からヘッダーにあたるまでさかのぼる
    while (index > -1) {
      if (getItemViewType(index) == HEADER) return index
      index--
    }
    return null
  }

  //ヘッダーのレイアウトはとりあえずフィールドに保持
  override fun getHeaderLayout(): Int? {
    return headerLayout
  }

  //ヘッダーにデータをバインド
  override fun bindHeaderData(header: View, adapterPosition: Int) {
    val textView = header.textView
    textView.text = "Header ${list[adapterPosition]}"
  }

  //ヘッダーとそれ以外とを viewType で仕分け
  override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyItemViewHolder {
    val inflater = LayoutInflater.from(parent.context)
    return when (viewType) {
      0 -> { MyItemViewHolder(inflater.inflate(R.layout.item_layout, parent, false)) }
      else -> {
        headerLayout = R.layout.header_layout
        MyItemViewHolder(inflater.inflate(R.layout.header_layout, parent, false))
      }
    }
  }

  //ヘッダーとそれ以外とを viewType で仕分け
  override fun getItemViewType(adapterPosition: Int): Int {
    return when (list[adapterPosition] % 5) {
      0 -> HEADER
      else -> super.getItemViewType(adapterPosition)
    }
  }
}

次は Adapter を抽象化してライブラリとして取り込む…つもり。

参考サイト

qiita.com

stackoverflow.com